Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)
Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) 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
8505187668
commit
fd386e75ae
@@ -1,86 +1,15 @@
|
||||
//! The game-state aura bindings — **two dialects over one list** (decisions 0255, 0257).
|
||||
//! 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.
|
||||
//!
|
||||
//! 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:
|
||||
//! `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`
|
||||
//! ([`player_buff`]) takes a 0-based index and returns a cache position, the same number under
|
||||
//! every filter, which its siblings and `GameTooltip:SetPlayerBuff` take in place of the counter.
|
||||
//!
|
||||
//! - 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 (the 1.12.1 binary)
|
||||
//!
|
||||
//! 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.
|
||||
//! `source` is always nil, and only the player's auras carry a duration: the 1.12 wire has no aura
|
||||
//! caster and sends durations for the player alone, so the stock target frame shows no timers.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
@@ -88,69 +17,51 @@ 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.
|
||||
/// One aura on one unit, as the app's [`crate::script::UiScript::set_auras`] feed pushes it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AuraState {
|
||||
/// `Spell.dbc` id (`UnitAura`'s `spellId`).
|
||||
/// `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).
|
||||
/// The spell's name; `None` when the catalog lacks the id.
|
||||
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.
|
||||
/// Stack count, at least 1; the reference shows it 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.
|
||||
/// `"Magic"`, `"Curse"`, `"Disease"`, `"Poison"` or `None`; the debuff border tints by it.
|
||||
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).
|
||||
/// Seconds, from the last apply or refresh; 0 when permanent or on a unit not 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.
|
||||
/// The `GetTime()` instant it runs out; 0 when [`Self::duration`] is 0.
|
||||
pub expiration_time: f64,
|
||||
/// A buff (helpful) rather than a debuff — the `HELPFUL`/`HARMFUL` filter's discriminator.
|
||||
/// A buff rather than a debuff: the `HELPFUL`/`HARMFUL` filter's test.
|
||||
pub helpful: bool,
|
||||
/// Whether right-clicking it will be honoured (the wire's `AFLAG_CANCELABLE`) — the
|
||||
/// `CANCELABLE`/`NOT_CANCELABLE` filter's discriminator.
|
||||
/// The wire's `AFLAG_CANCELABLE`: the `CANCELABLE`/`NOT_CANCELABLE` filter's test.
|
||||
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.
|
||||
/// `GetPlayerBuff`'s second return, cache record `+0xc`: no finite duration to show. Derived
|
||||
/// from the spell (`0x4e452e`-`0x4e45c5`), not from `expiration_time`, so it holds before any
|
||||
/// `SMSG_UPDATE_AURA_DURATION` and a permanent aura never shows a `0 s` timer.
|
||||
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`].
|
||||
/// `AttributesEx & 0x4` (`SPELL_ATTR_EX_IS_CHANNELED`), [`cancel_authorized`]'s second arm.
|
||||
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 (`0x4e4a20`, 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).
|
||||
/// The player's active tracking aura, the reference's tracking global (`0xbc6378`): the cache
|
||||
/// rebuild records a spell with a tracking effect (`0x2c`, `0x2d`, `0x97`) there instead of in
|
||||
/// the display cache, and `GetTrackingTexture` (`0x4e4a20`) reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TrackingState {
|
||||
/// `Spell.dbc` id — `CancelTrackingBuff`'s `CMSG_CANCEL_AURA` payload.
|
||||
/// `Spell.dbc` id, the `CMSG_CANCEL_AURA` payload of `CancelTrackingBuff`.
|
||||
pub spell_id: u32,
|
||||
/// The spell's name, or `None` when the catalog doesn't know the id (tooltip fallback line).
|
||||
/// The spell's name; `None` when the catalog lacks the id.
|
||||
pub name: Option<String>,
|
||||
/// The icon's extensionless MPQ path — `GetTrackingTexture`'s return (`nil` when unknown).
|
||||
/// The icon's extensionless MPQ path, `GetTrackingTexture`'s return.
|
||||
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.
|
||||
/// The wire's `AFLAG_CANCELABLE` on the aura's slot, `CancelTrackingBuff`'s gate.
|
||||
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).
|
||||
/// `UnitAura`'s `filter`: `|`-separated tokens, the sign defaulting to `HELPFUL` as in the Era API.
|
||||
struct Filter {
|
||||
helpful: bool,
|
||||
cancelable: Option<bool>,
|
||||
@@ -161,12 +72,11 @@ impl Filter {
|
||||
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).
|
||||
// Both or neither: no constraint; the reference never passes both.
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
@@ -177,35 +87,16 @@ impl Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The cancel gate of `CancelUnitBuff` and `CancelPlayerBuff` (`0x4e49a0`, `0x4e49fb`-`0x4e4a14`):
|
||||
/// a helpful aura with `AFLAG_CANCELABLE` (vmangos: unless `SPELL_ATTR_NO_AURA_CANCEL`), or any
|
||||
/// aura of a channeled spell, the only negative aura that cancels (breaking a channel on you).
|
||||
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.
|
||||
/// The **1.12** return tuple, which is a different shape and not a prefix of the Era one: the first
|
||||
/// value is the TEXTURE, not the name. `UnitBuff` returns `(texture, applications)` (`0x519500`) and
|
||||
/// `UnitDebuff` returns `(texture, applications, dispelType)` (`0x5198f0`) — both are what the
|
||||
/// reference's own FrameXML reads
|
||||
/// (`TargetFrame.lua:287-290` binds `debuff, debuffStack, debuffType` and then
|
||||
/// `SetTexture(debuff)`). Decision 1818, which also records why the Era shape was serving nobody.
|
||||
/// The 1.12 tuple, not a prefix of the Era one: `UnitBuff` returns `(texture, applications)`
|
||||
/// (`0x519500`) and `UnitDebuff` adds `dispelType` (`0x5198f0`), as stock `TargetFrame.lua:287-290`
|
||||
/// reads them.
|
||||
fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Result<MultiValue> {
|
||||
let icon = match &a.icon {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
@@ -221,10 +112,10 @@ fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Resul
|
||||
Ok(MultiValue::from_vec(out))
|
||||
}
|
||||
|
||||
/// Which return shape a getter wants — the two are not compatible, see [`returns_1121`].
|
||||
/// Which return tuple a getter pushes.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Shape {
|
||||
/// `UnitAura`'s ten values. Era-only: the name does not exist in 1.12 FrameXML at all.
|
||||
/// `UnitAura`'s ten values, the Era signature.
|
||||
Era,
|
||||
/// `UnitBuff`'s two.
|
||||
Buff,
|
||||
@@ -246,16 +137,15 @@ fn returns(lua: &Lua, a: &AuraState) -> mlua::Result<MultiValue> {
|
||||
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, // source: the 1.12 wire carries no aura caster
|
||||
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.
|
||||
/// 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(
|
||||
lua: &Lua,
|
||||
token: &Option<String>,
|
||||
@@ -289,9 +179,8 @@ fn nth_aura(
|
||||
}
|
||||
|
||||
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.
|
||||
/// 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>>) {
|
||||
let mut model = self.model_mut();
|
||||
match auras {
|
||||
@@ -304,28 +193,24 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Push or clear the player's active [`TrackingState`].
|
||||
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).
|
||||
/// Drain the queued cancels, one `CMSG_CANCEL_AURA` per spell id: the server cancels by spell,
|
||||
/// never by slot.
|
||||
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`]).
|
||||
/// Register the aura and tracking globals, the `GetPlayerBuff` family from [`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. This one
|
||||
// keeps the Era tuple: it is not a 1.12 verb at all (zero occurrences in the reference's
|
||||
// FrameXML) and has zero call sites in the 110-addon corpus, so it costs nothing and stays the
|
||||
// Era-compat surface 0068 asked for.
|
||||
// UnitAura(unit, index [, filter]), filter defaulting to HELPFUL, with the Era tuple: not a
|
||||
// 1.12 verb.
|
||||
g.set(
|
||||
"UnitAura",
|
||||
lua.create_function(
|
||||
@@ -341,18 +226,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// UnitBuff(unit, index [, raidFilter]) / UnitDebuff(unit, index [, raidFilter]) — the 1.12
|
||||
// signature and the 1.12 RETURN SHAPE (decision 1818). The sign is fixed by the verb, not by a
|
||||
// filter word: `0x519500` reads aura slots 0..31 and `0x5198f0` reads 32..47.
|
||||
//
|
||||
// **The third argument is a `raidFilter` flag, not Era's filter string.** Non-zero enables ONE
|
||||
// extra per-slot predicate — castable-by-me for buffs (`0x4b3870`), dispellable-by-me for
|
||||
// debuffs (`0x4b3920`). We accept it and DO NOT apply it, because [`AuraState`] holds neither
|
||||
// fact and neither is derivable from the aura feed: castable-by-me needs the player's spellbook,
|
||||
// dispellable-by-me needs a class→dispel-type table joined to `debuff_type`. Inventing an answer
|
||||
// is the guess 1203 exists to prevent, so the flag is parked here in the open. Visible
|
||||
// consequence, so nobody has to rediscover it: the pet frame's buff row and the
|
||||
// dispellable-debuff rows show everything rather than only what the player can cast or dispel.
|
||||
// UnitBuff(unit, index [, raidFilter]) and UnitDebuff: the verb fixes the sign, `0x519500`
|
||||
// reading aura slots 0..31 and `0x5198f0` slots 32..47. A non-zero `raidFilter` keeps only
|
||||
// buffs the player can cast (`0x4b3870`) or debuffs they can dispel (`0x4b3920`); it is
|
||||
// accepted, not applied, as `AuraState` holds neither fact, so the pet frame's buff row and the
|
||||
// dispellable-debuff rows show every aura.
|
||||
g.set(
|
||||
"UnitBuff",
|
||||
lua.create_function(
|
||||
@@ -382,11 +260,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// CancelUnitBuff(unit, index [, filter]): not a 1.12 verb, the Era name for the reference's
|
||||
// `CancelPlayerBuff`. It queues the aura's spell id, what `CMSG_CANCEL_AURA` carries; an aura
|
||||
// the gate refuses is a silent no-op, as in the reference.
|
||||
g.set(
|
||||
"CancelUnitBuff",
|
||||
lua.create_function(
|
||||
@@ -420,9 +296,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
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).
|
||||
// GetTrackingTexture() (`0x4e4a20`): the tracking spell's icon or nil, which shows or hides
|
||||
// the stock `MiniMapTrackingFrame` (`Minimap.xml:150-159`).
|
||||
g.set(
|
||||
"GetTrackingTexture",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -431,10 +306,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// CancelTrackingBuff() (`0x4e4a80`), the tracking frame's right-click: queues the tracking
|
||||
// spell's id behind its `AFLAG_CANCELABLE`; with no tracking it is a no-op.
|
||||
g.set(
|
||||
"CancelTrackingBuff",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -470,8 +343,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -483,14 +354,12 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
// `UnitBuff`/`UnitDebuff` enumerate the same list, but their first return is the ICON, not
|
||||
// the name (1.12's shape — decision 1818), so they are addressed by it here.
|
||||
// `UnitBuff` and `UnitDebuff` return the icon first, the 1.12 shape.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitBuff("player", 2))"#)
|
||||
.unwrap(),
|
||||
@@ -502,14 +371,12 @@ mod tests {
|
||||
.unwrap(),
|
||||
"Interface\\Icons\\Spell_589"
|
||||
);
|
||||
// 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());
|
||||
@@ -525,11 +392,11 @@ mod tests {
|
||||
"player",
|
||||
Some(vec![
|
||||
aura(2457, "Battle Stance", true, true),
|
||||
aura(9999, "Sealed", true, false), // helpful but not cancelable
|
||||
aura(9999, "Sealed", true, false), // helpful, not cancelable
|
||||
aura(589, "Pain", false, false),
|
||||
]),
|
||||
);
|
||||
// No filter ⇒ HELPFUL: two hits, the debuff invisible.
|
||||
// No filter means HELPFUL.
|
||||
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"#
|
||||
@@ -537,13 +404,11 @@ mod tests {
|
||||
.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(),
|
||||
@@ -554,7 +419,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
"Sealed"
|
||||
);
|
||||
// A bare CANCELABLE still means helpful (the sign defaults, it isn't cleared).
|
||||
// A bare CANCELABLE keeps the helpful default.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1, "CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
@@ -586,11 +451,6 @@ mod tests {
|
||||
assert_eq!(spell, 589);
|
||||
}
|
||||
|
||||
/// The **1.12** shape, which is not a prefix of the Era one: `UnitBuff` → `(texture,
|
||||
/// applications)`, `UnitDebuff` → `(texture, applications, dispelType)`, and the FIRST value is
|
||||
/// the texture. Decision 1818; `0x519500`/`0x5198f0`. The
|
||||
/// reference's own FrameXML reads exactly this (`TargetFrame.lua:287-290`), and so does every
|
||||
/// one of the 184 call sites in the addon corpus.
|
||||
#[test]
|
||||
fn unit_buff_and_unit_debuff_return_the_1121_tuple_not_the_era_one() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -601,7 +461,6 @@ mod tests {
|
||||
debuff.debuff_type = Some("Magic".into());
|
||||
s.set_auras("target", Some(vec![buff, debuff]));
|
||||
|
||||
// UnitBuff returns exactly TWO values, texture first.
|
||||
let (icon, count, third) = s
|
||||
.eval::<(String, i64, Option<String>)>(
|
||||
r#"local a, b, c = UnitBuff("target", 1) return a, b, c"#,
|
||||
@@ -611,7 +470,6 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(third, None, "UnitBuff returns two values, never a third");
|
||||
|
||||
// UnitDebuff returns THREE, the dispel type last.
|
||||
let (icon, count, dispel, fourth) = s
|
||||
.eval::<(String, i64, String, Option<String>)>(
|
||||
r#"local a, b, c, d = UnitDebuff("target", 1) return a, b, c, d"#,
|
||||
@@ -624,8 +482,7 @@ mod tests {
|
||||
"UnitDebuff returns three values, never a fourth"
|
||||
);
|
||||
|
||||
// The reference's own read, verbatim from TargetFrame.lua:287-297 — the comparison that
|
||||
// raised `attempt to compare number with string` while we returned the Era tuple.
|
||||
// Stock `TargetFrame.lua:287-297`'s read, verbatim.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"local d, stack, dtype = UnitDebuff("target", 1)
|
||||
@@ -633,8 +490,7 @@ mod tests {
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// The raidFilter argument is ACCEPTED and its predicate is not applied (1818): passing it
|
||||
// must not error, and must not change the answer.
|
||||
// The raidFilter flag is accepted and not applied.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitDebuff("target", 1, 1))"#)
|
||||
.unwrap(),
|
||||
@@ -655,24 +511,17 @@ mod tests {
|
||||
);
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// A cancelable buff queues its SPELL id (not its index).
|
||||
// Only the cancelable buff queues, by spell id; the others are silent no-ops.
|
||||
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());
|
||||
@@ -692,7 +541,6 @@ mod tests {
|
||||
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,
|
||||
@@ -701,7 +549,6 @@ mod tests {
|
||||
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")
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
//! **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.
|
||||
//! The 1.12 aura verbs: `GetPlayerBuff`, which returns a cache position, and the five that take
|
||||
//! one. A position is a 0-based index into the pushed `"player"` list, which is the reference's
|
||||
//! insertion-ordered cache (`0xbc6040`).
|
||||
|
||||
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.
|
||||
/// `GetPlayerBuff`'s filter mask, not `UnitAura`'s parser: no filter argument means
|
||||
/// `HELPFUL|HARMFUL` (`0x4e4618`), and a filter string starts the mask at zero (`0x4e4639`), so a
|
||||
/// bare `"CANCELABLE"` matches nothing.
|
||||
#[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.
|
||||
/// Case-insensitive match against the four tokens (`0x4e4661`-`0x4e46cf`); anything else,
|
||||
/// `"PASSIVE"` included, sets no bit. Space and pipe both delimit, runs collapsed (`0x84bc3c`).
|
||||
fn parse(spec: Option<&str>) -> Self {
|
||||
const HELPFUL: u32 = 0x1;
|
||||
const HARMFUL: u32 = 0x2;
|
||||
@@ -35,7 +22,6 @@ impl PlayerBuffFilter {
|
||||
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;
|
||||
@@ -54,8 +40,8 @@ impl PlayerBuffFilter {
|
||||
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`.
|
||||
/// The enumerator's per-record test (`0x4e43c9`-`0x4e43f7`): the record's sign bit, then any
|
||||
/// named `CANCELABLE`/`NOT_CANCELABLE` against `record+0xa & 1`.
|
||||
fn matches(self, a: &AuraState) -> bool {
|
||||
const HELPFUL: u32 = 0x1;
|
||||
const HARMFUL: u32 = 0x2;
|
||||
@@ -69,47 +55,29 @@ impl PlayerBuffFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// The family's index argument, as every verb opens (`0x4e45d6`, `0x4e4748`, `0x4e4808`,
|
||||
/// `0x4e48bc`, `0x4e493e`, `0x4e49a8`): a number or numeric string, truncated; anything else raises
|
||||
/// the reference's usage line through `luaL_error` (`0x6f4940`).
|
||||
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).
|
||||
// `__ftol` truncates toward zero, as `as i64` does.
|
||||
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`.)
|
||||
/// The record at cache position `pos`, or `None` past the packed prefix, where the reference has
|
||||
/// NULL outside `0..0x30` (`0x4e4430`) and cleared records its siblings treat as absent. Deviation:
|
||||
/// `GetPlayerBuffApplications` answers 1 for a cleared record where the reference reads its stale
|
||||
/// `+0x9`, the previous occupant's count, because no guarded caller reaches it.
|
||||
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`.
|
||||
/// The enumerator (`0x4e43b0`): the `index`-th record passing `filter`, by ascending position,
|
||||
/// with that position. A negative `index` never matches the counter, which counts up from 0.
|
||||
fn enumerate_player_buff(
|
||||
lua: &Lua,
|
||||
index: i64,
|
||||
@@ -129,19 +97,16 @@ fn enumerate_player_buff(
|
||||
.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.
|
||||
/// Register the six 1.12 globals.
|
||||
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.
|
||||
// GetPlayerBuff(index [, "filter"]) (`0x4e45d0`) returns two numbers on both paths
|
||||
// (`0x4e471e`, `0x4e4733`): the position, -1 past the end and never nil, as addons loop while
|
||||
// it is `>= 0`; and untilCancelled as the number 0 or 1, which stock `BuffFrame.lua:124`
|
||||
// compares with `1`. The index is 0-based, used without `UnitBuff`'s `dec` (`0x4e460a`,
|
||||
// `0x519579`). Unfiltered, every record passes, so `GetPlayerBuff(i)` is `i`, which addons use
|
||||
// to cancel by the counter.
|
||||
g.set(
|
||||
"GetPlayerBuff",
|
||||
lua.create_function(|lua, (index, filter): (Value, Option<String>)| {
|
||||
@@ -158,14 +123,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// GetPlayerBuffTexture(buffIndex) (`0x4e4740`): the icon path, or nil for an absent position or
|
||||
// a missing spell or icon row; addons loop until the nil. A surplus argument is ignored, as the
|
||||
// reference reads only the first.
|
||||
g.set(
|
||||
"GetPlayerBuffTexture",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -174,17 +134,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// GetPlayerBuffDispelType(buffIndex) (`0x4e4800`): the `SpellDispelType.dbc` name of the
|
||||
// spell's `Dispel`, or nil for dispel 0 or a missing row (`0x4e485e`); never `""`, which Lua
|
||||
// takes as true. Stock `BuffFrame.lua:83` passes it `GetPlayerBuff`'s result unguarded, -1 too.
|
||||
g.set(
|
||||
"GetPlayerBuffDispelType",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -193,15 +145,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.)
|
||||
// GetPlayerBuffApplications(buffIndex) (`0x4e48b0`): the stack count, and 1 for an absent
|
||||
// position (`0x4e4917`), never nil, as addons test `count > 1` unguarded. Deviation: the
|
||||
// reference's usage line names `GetPlayerBuffTimeLeft` (`0x84bcc0`, shared with `0x4e4930`);
|
||||
// ours names this verb, because nothing can depend on the wrong name.
|
||||
g.set(
|
||||
"GetPlayerBuffApplications",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -210,36 +157,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// GetPlayerBuffTimeLeft(buffIndex) (`0x4e4930`, reader `0x4e4450`): seconds, the reader's ms
|
||||
// times the 0.001 at `0x801608` (`0x4e4986`), as `max(0, expiry - now)`, and 0 for an absent,
|
||||
// cleared or permanent aura, never nil, as addons do arithmetic on it unguarded. It is computed
|
||||
// per call against `GetTime()`, as stock `BuffFrame.lua:130` re-reads it every frame.
|
||||
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.
|
||||
// `max(0, …)` covers both zero cases: an expired aura, and a permanent one, expiry 0.
|
||||
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.
|
||||
// CancelPlayerBuff(buffIndex) (`0x4e49a0`): no return values on any path. It queues the spell
|
||||
// id, the one `u32` of `CMSG_CANCEL_AURA` (0x136, `Spell_C::CancelAura` `0x6e7040`), on the
|
||||
// queue `CancelUnitBuff` uses; a refused or absent aura is a silent no-op.
|
||||
g.set(
|
||||
"CancelPlayerBuff",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
@@ -259,8 +193,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
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,
|
||||
@@ -277,14 +209,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The player's cache as the app pushes it: buffs and debuffs in one insertion-ordered list.
|
||||
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
|
||||
stance.until_cancelled = true; // permanent
|
||||
let mut pain = aura(589, "Shadow Word: Pain", false, false);
|
||||
pain.count = 3;
|
||||
pain.debuff_type = Some("Magic".into());
|
||||
@@ -299,14 +230,7 @@ mod tests {
|
||||
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 arity 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.
|
||||
/// The arity is the point: a nil miss passes each single read but breaks every `>= 0` loop.
|
||||
#[test]
|
||||
fn get_player_buff_returns_two_numbers_and_minus_one_past_the_end() {
|
||||
let mut s = with_player_cache();
|
||||
@@ -323,12 +247,11 @@ mod tests {
|
||||
"and a MISS pushes two as well (0x4e4733), not zero and not nil"
|
||||
);
|
||||
|
||||
// 0-based: position 0 is the first aura, not the second.
|
||||
// 0-based: position 0 is the first aura.
|
||||
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)
|
||||
@@ -337,20 +260,17 @@ mod tests {
|
||||
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.
|
||||
// untilCancelled is the number 1, not `true`: callers compare it with `1`.
|
||||
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(),
|
||||
@@ -358,15 +278,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **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).
|
||||
/// The corpus's loop (`_LazyPig/LazyPig.lua:1174`) visits every aura, debuffs included, and
|
||||
/// ends: a hang there freezes the client inside a `PLAYER_AURAS_CHANGED` handler.
|
||||
#[test]
|
||||
fn the_corpus_while_ge_zero_loop_terminates_and_visits_every_aura() {
|
||||
let mut s = with_player_cache();
|
||||
@@ -397,7 +310,6 @@ mod tests {
|
||||
"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>(
|
||||
@@ -410,32 +322,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **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.
|
||||
/// Under a `HELPFUL` default an unfiltered walk for `"Poison"` would find nothing, silently;
|
||||
/// and a position is the same number under every filter, which callers compare.
|
||||
#[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.
|
||||
// "HARMFUL" finds the debuff at ordinal 0 and returns position 2, the unfiltered number.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
@@ -446,7 +349,6 @@ mod tests {
|
||||
.unwrap(),
|
||||
-1
|
||||
);
|
||||
// "HELPFUL" alone: two, at positions 0 and 1.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>(
|
||||
r#"return (GetPlayerBuff(0, "HELPFUL")), (GetPlayerBuff(1, "HELPFUL"))"#
|
||||
@@ -454,7 +356,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
(0, 1)
|
||||
);
|
||||
// CT_BuffMod's own literal, and the space-delimited spelling the reference's strtok accepts.
|
||||
// Pipe- and space-delimited.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(2, "HELPFUL|HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
@@ -465,20 +367,18 @@ mod tests {
|
||||
.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.
|
||||
// A filter string with no sign token matches nothing: the mask starts at 0.
|
||||
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.
|
||||
// "PASSIVE" is not a token.
|
||||
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(),
|
||||
@@ -491,14 +391,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **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
|
||||
s.tick(0.0); // GetTime() = 0
|
||||
|
||||
// Every accessor returns exactly ONE value, hit or miss — the reference's `mov eax,0x1`.
|
||||
// One value, hit or miss.
|
||||
for verb in [
|
||||
"GetPlayerBuffTexture",
|
||||
"GetPlayerBuffDispelType",
|
||||
@@ -513,13 +411,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// CancelPlayerBuff pushes NONE (`xor eax,eax; ret`), on every path.
|
||||
for arg in ["0", "-1", "99"] {
|
||||
assert_eq!(s.arity(&format!("CancelPlayerBuff({arg})")).unwrap(), 0);
|
||||
}
|
||||
s.take_cancel_aura_requests();
|
||||
|
||||
// Texture: nil past the end — SnaFu's `while GetPlayerBuffTexture(i) do` loop condition.
|
||||
// Texture: nil past the end, a loop condition in the corpus.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffTexture(3) == nil")
|
||||
.unwrap());
|
||||
@@ -536,21 +433,18 @@ mod tests {
|
||||
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.
|
||||
// DispelType: nil, never "".
|
||||
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.
|
||||
// Stock BuffFrame.lua:83 nests the calls unguarded.
|
||||
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(),
|
||||
@@ -570,8 +464,6 @@ mod tests {
|
||||
.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
|
||||
@@ -589,7 +481,7 @@ mod tests {
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(-1)").unwrap(),
|
||||
0.0
|
||||
);
|
||||
// The unguarded corpus arithmetic, run for real.
|
||||
// The corpus's unguarded arithmetic.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return math.floor(GetPlayerBuffTimeLeft(-1) + .5)")
|
||||
.unwrap(),
|
||||
@@ -601,28 +493,26 @@ mod tests {
|
||||
0.0
|
||||
);
|
||||
|
||||
// It counts down against the VM's GetTime clock, live, per call.
|
||||
// It counts down against GetTime, per call, and floors at 0.
|
||||
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.
|
||||
// A surplus argument is ignored.
|
||||
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.
|
||||
// A non-number argument raises the reference's usage line.
|
||||
let err = s
|
||||
.eval::<()>("GetPlayerBuff({})")
|
||||
.expect_err("a table index must raise");
|
||||
@@ -632,34 +522,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `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.
|
||||
// Position 0 is cancelable: its spell id queues.
|
||||
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.
|
||||
// Not cancelable, a plain debuff, absent: all silent no-ops.
|
||||
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.
|
||||
// Both names reach one queue: the first helpful aura is 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.
|
||||
// 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]));
|
||||
|
||||
Reference in New Issue
Block a user