app+ui: the queue, the disenchant sequencer — 62/62

QueueSpellByName, QueueScript, ChannelStopCastingNextTick,
DisenchantAll. Every one of nampower's 62 registered Lua functions is
now defined; the `comm -23` in the doc prints nothing.

**Upstream's queue has two halves and one is deliberately not built.**
The Lua surface — remember this spell, send it when the client will
accept it — is `spell/queue.rs`. The other half intercepts *every
ordinary press*, so a keypress during a cast is held rather than
refused, and it is governed by NP_QueueCastTimeSpells,
NP_QueueInstantSpells, the buffer rows and the quickcast pair. That half
is not a binding: it changes how every key in the game feels, its bugs
are double-casts and dropped casts, and it is not something to switch on
as a side effect of porting three function names. Those twenty CVars
stay stored-only, each row saying so; the machinery they would sit on is
now here, and turning it on is its own piece of work with its own live
test.

Two of the twenty-two went live, because they are the explicit queue's
own knobs: NP_SpellQueueWindowMs and NP_QueueSpellsOnCooldown.

Three slots, because upstream has three and its own event says so:
SPELL_QUEUE_EVENT's code is 0/1 on-swing, 2/3 normal, 4/5 non-GCD, so
the queue is keyed by what kind of cast a spell is — and the kind comes
off its own Spell.dbc row through the same three tests the send path
makes. Each pops on its own condition: non-GCD and on-swing as soon as
no cast guards (neither uses the global cooldown), normal when the GCD
has run out too.

Three decisions at the queue, each pinned:

- A replacement raises no popped event. A second queue of one kind
  replaces the first — upstream's own log line is `Replacing queued
  nonGcd spell X with Y` — and the replaced entry was never sent, so a
  consumer counting pops against queues would see one too many.
- An expiry raises nothing either. `*_POPPED` means "this was sent".
- The expiry is measured from the end of the cast in flight, not from
  the moment of queueing, so queueing early during a long cast is not
  punished. A lapsed deadline cannot shorten the window.

**`DisenchantAll` is specified completely by the DLL's own strings**,
which is why it is built this way rather than designed. `0x93087`
onward carries the argument note, the precondition, the three quality
keywords, every stop reason and the three chat lines — one of them as a
Lua chunk that builds the item link itself.

"move during cast to cancel" is the abort, and it needed nothing new:
moving already cancels a cast here, the cast then fails, and a failed
cast stops the run. The one piece of machinery a sequencer usually
needs is the client's own, and the player's way out is the one upstream
documents.

How a step's outcome is read: the item is gone, or it is not. There is
no "disenchant succeeded" packet — the item leaves the bag. Each step
remembers the guid it cast at and waits for it to vanish with a
ten-second deadline. An observable test with no new wire plumbing,
right for the same reason the cast bar's own edges are.

The queue's drain and the sequencer both live inside
`drain_spell_casts`, which that file's own note explains: only one
system may hold `CastLadder`, because a second would share its
resources with every other cast-send system in an order the ambiguity
gate cannot know is safe.

`spell/queue.rs` names no VM — the first draft mentioned `UiScript` in
one comment and `ui_script_wall.rs` counted it as gameplay code naming
the VM, which is the gate working as intended: that module really is
gameplay state and not a feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 13:25:27 -06:00
co-authored by Claude Opus 5
parent b652c90a4b
commit 702eb475a4
13 changed files with 1366 additions and 30 deletions
+9 -7
View File
@@ -1114,13 +1114,16 @@ pub(crate) const REGISTERED: &[Registered] = &[
ours(
"NP_QueueSpellsOnCooldown",
"1",
"nampower v2 addon-compat, STORED ONLY: no spell queue here",
"nampower v2 addon-compat, LIVE: whether an entry in `QueueSpellByName`'s queue may wait \
out the spell's own cooldown, or is dropped — see crate::spell::queue::SpellQueue",
),
// The queue windows, in ms (README defaults 500 / 1500 / 250 / 500).
ours(
"NP_SpellQueueWindowMs",
"500",
"nampower v2 addon-compat, STORED ONLY: no spell queue to size",
"nampower v2 addon-compat, LIVE: how long past the in-flight cast's end an entry in \
`QueueSpellByName`'s queue is held before it gives up — see \
crate::spell::queue::SpellQueue",
),
ours(
"NP_ChannelQueueWindowMs",
@@ -4870,11 +4873,10 @@ mod tests {
),
("NP_QueueTargetingSpells", "NampowerSettings' queue toggles"),
("NP_QueueOnSwingSpells", "NampowerSettings' queue toggles"),
(
"NP_QueueSpellsOnCooldown",
"NampowerSettings' queue toggles",
),
("NP_SpellQueueWindowMs", "NampowerSettings' queue windows"),
// `NP_QueueSpellsOnCooldown` and `NP_SpellQueueWindowMs` are deliberately NOT in
// this list: `spell/queue.rs` reads both as the explicit queue's own two knobs and
// must keep proving so. Every other row here tunes the press-interception half of
// upstream's queue, which is not built (that module's header says why).
("NP_ChannelQueueWindowMs", "NampowerSettings' queue windows"),
(
"NP_CooldownQueueWindowMs",
+341
View File
@@ -0,0 +1,341 @@
//! nampower's `DisenchantAll(itemIdOrName | quality, [includeSoulbound])`: cast Disenchant on
//! every matching bag item, one after another.
//!
//! **The DLL specifies this one completely in its own strings**, which is why it is built the way
//! it is rather than designed here. Its string pool at `0x93087`–`0x932d0` carries, in order: the
//! argument note (`includeSoulbound: optional number - pass 1 to include soulbound items
//! (defaults to 0)`), the precondition (`You do not have the Disenchant spell`), the three quality
//! keywords (`greens`, `blues`, `purples`), the trace (`Trying disenchant with quality filter `),
//! every stop reason (`No disenchantable item found, stopping disenchant`, `Invalid item, stopping
//! disenchant`, `No player unit, stopping disenchant`, `Cast failed, stopping disenchant`) and the
//! three chat lines it prints — one of them as a Lua chunk:
//!
//! ```text
//! local itemLink = GetContainerItemLink(%d, %d); if itemLink then
//! DEFAULT_CHAT_FRAME:AddMessage("Disenchanting " .. itemLink .. " move during cast to cancel.")
//! end
//! ```
//!
//! **"move during cast to cancel" is the abort, and it needs nothing new.** Moving already cancels
//! a cast here (`inflight.rs`'s `local_self_cancel`, `AbortCast 0x6e4940`), the cast then fails,
//! and a failed cast stops the run. So the one piece of machinery a sequencer usually needs — a
//! way out — is the client's own, and the player's way out is the one upstream documents.
//!
//! **How a step's outcome is read: the item is gone, or it is not.** There is no "disenchant
//! succeeded" packet; what happens is the item leaves the bag. So each step remembers the guid it
//! cast at and waits for that guid to vanish from the slot, with a deadline. Gone is success and
//! the run continues; still there past the deadline is a failure and the run stops. That is an
//! observable test with no new wire plumbing, and it is right for the same reason the cast bar's
//! own edges are: the thing the player sees is the thing the client keys on.
use std::time::{Duration, Instant};
use bevy::prelude::*;
/// Disenchant's `Spell.dbc` id. In the player's book or the run refuses — upstream's own
/// precondition (`You do not have the Disenchant spell`).
pub(crate) const DISENCHANT: u32 = 13_262;
/// How long a step waits for its item to leave the bag before calling the cast failed.
///
/// Generous on purpose: the cast is 5 s, plus the server's round trip, plus `CAST_SLACK`. A run
/// that gives up early would stop on a laggy step and leave the player's bags half done, which is
/// worse than one that takes an extra second to notice a real failure.
const STEP_TIMEOUT: Duration = Duration::from_secs(10);
/// What `DisenchantAll`'s first argument selected.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Filter {
/// A number: one item id.
ItemId(u32),
/// A string that is not a quality keyword: an item name, matched case-insensitively.
Name(String),
/// One or more of the three quality keywords, pipe-combinable (`"greens|blues"`), as a set of
/// `Quality` values.
Qualities(Vec<u32>),
}
impl Filter {
/// Parse upstream's own argument grammar. A number is an item id; a string is tried as
/// pipe-separated quality keywords first and falls back to a name.
///
/// The three keywords and their qualities: `greens` is Uncommon (2), `blues` Rare (3),
/// `purples` Epic (4) — the three a 1.12 enchanter can disenchant. A string mixing a keyword
/// with something else is a **name**, not a partial quality set: `"greens|Linen Cloth"` asking
/// for one of each would be a reading nothing suggests, and a name with a pipe in it is
/// already impossible.
pub(crate) fn parse(text: &str) -> Option<Self> {
if let Ok(id) = text.trim().parse::<u32>() {
return (id != 0).then_some(Filter::ItemId(id));
}
let parts: Vec<&str> = text.split('|').map(str::trim).collect();
let qualities: Option<Vec<u32>> = parts
.iter()
.map(|p| match p.to_ascii_lowercase().as_str() {
"greens" => Some(2),
"blues" => Some(3),
"purples" => Some(4),
_ => None,
})
.collect();
match qualities {
Some(q) if !q.is_empty() => Some(Filter::Qualities(q)),
_ => (!text.trim().is_empty()).then(|| Filter::Name(text.trim().to_lowercase())),
}
}
/// Whether this filter takes an item of `(entry, quality, name)`.
pub(crate) fn takes(&self, entry: u32, quality: u32, name: &str) -> bool {
match self {
Filter::ItemId(id) => entry == *id,
Filter::Name(want) => name.to_lowercase() == *want,
Filter::Qualities(set) => set.contains(&quality),
}
}
}
/// A run in progress.
#[derive(Resource, Default)]
pub(crate) struct DisenchantRun {
filter: Option<Filter>,
include_soulbound: bool,
/// The item the current step cast at, and when it gives up waiting for it to vanish.
step: Option<(u64, Instant)>,
}
/// What the driver should do with a run this frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Step {
/// Nothing is running.
Idle,
/// A step is in flight; wait.
Waiting,
/// Cast at this item. `(bag, slot)` are Lua-space, for the chat line's own
/// `GetContainerItemLink(bag, slot)`.
Cast { guid: u64, bag: i64, slot: u32 },
/// Stop, with the reason upstream prints.
Stop(&'static str),
}
/// Upstream's own chat lines, verbatim from the DLL's string pool.
pub(crate) const CHAT_NO_MORE: &str =
r#"DEFAULT_CHAT_FRAME:AddMessage("No more items to disenchant.")"#;
pub(crate) const CHAT_FAILED: &str =
r#"DEFAULT_CHAT_FRAME:AddMessage("Disenchant interrupted or failed.")"#;
/// The per-item line, also a Lua chunk upstream: the link is built in Lua because that is where
/// `GetContainerItemLink` lives.
pub(crate) fn chat_disenchanting(bag: i64, slot: u32) -> String {
format!(
r#"local itemLink = GetContainerItemLink({bag}, {slot}); if itemLink then DEFAULT_CHAT_FRAME:AddMessage("Disenchanting " .. itemLink .. " move during cast to cancel.") end"#
)
}
impl DisenchantRun {
/// Start (or restart) a run. A second call replaces the filter, which is the only sensible
/// reading of `/dea blues` typed while `/dea greens` is running.
pub(crate) fn start(&mut self, filter: Filter, include_soulbound: bool) {
info!("disenchant: starting a run — filter {filter:?}, soulbound {include_soulbound}");
self.filter = Some(filter);
self.include_soulbound = include_soulbound;
self.step = None;
}
pub(crate) fn running(&self) -> bool {
self.filter.is_some()
}
/// Stop, clearing everything.
pub(crate) fn stop(&mut self, why: &str) {
if self.filter.is_some() {
info!("disenchant: {why}");
}
self.filter = None;
self.step = None;
}
/// Record that a step has been sent at `guid`.
pub(crate) fn armed(&mut self, guid: u64, now: Instant) {
self.step = Some((guid, now + STEP_TIMEOUT));
}
/// What to do this frame.
///
/// `still_there` answers whether a guid is still in the bags, and `next` finds the next
/// matching item; both are the caller's, because the inventory walk needs the object store.
pub(crate) fn advance(
&mut self,
now: Instant,
in_flight: bool,
still_there: impl Fn(u64) -> bool,
next: impl Fn(&Filter, bool) -> Option<(u64, i64, u32)>,
) -> Step {
let Some(filter) = self.filter.clone() else {
return Step::Idle;
};
if let Some((guid, deadline)) = self.step {
// A cast still running is simply not finished.
if in_flight {
return Step::Waiting;
}
if still_there(guid) {
if now < deadline {
return Step::Waiting;
}
// Upstream's `Cast failed, stopping disenchant`.
self.stop("cast failed, stopping disenchant");
return Step::Stop(CHAT_FAILED);
}
// Gone: that step worked. Fall through to the next item.
self.step = None;
} else if in_flight {
// Something else is casting — a run does not fight the player for the cast slot.
return Step::Waiting;
}
match next(&filter, self.include_soulbound) {
Some((guid, bag, slot)) => Step::Cast { guid, bag, slot },
None => {
// Upstream's `No disenchantable item found, stopping disenchant`.
self.stop("no disenchantable item found, stopping disenchant");
Step::Stop(CHAT_NO_MORE)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Upstream's argument grammar: a number is an id, the three keywords are qualities
/// (pipe-combinable), anything else is a name.
#[test]
fn the_filter_grammar_is_upstreams_own() {
assert_eq!(Filter::parse("12345"), Some(Filter::ItemId(12_345)));
assert_eq!(Filter::parse(" 12345 "), Some(Filter::ItemId(12_345)));
assert_eq!(Filter::parse("0"), None, "item 0 is no item");
assert_eq!(Filter::parse("greens"), Some(Filter::Qualities(vec![2])));
assert_eq!(Filter::parse("GREENS"), Some(Filter::Qualities(vec![2])));
assert_eq!(
Filter::parse("greens|blues|purples"),
Some(Filter::Qualities(vec![2, 3, 4]))
);
assert_eq!(
Filter::parse("greens | blues"),
Some(Filter::Qualities(vec![2, 3])),
"spaces around the pipe, because a player types them"
);
assert_eq!(
Filter::parse("Linen Cloth"),
Some(Filter::Name("linen cloth".into()))
);
assert_eq!(
Filter::parse("greens|Linen Cloth"),
Some(Filter::Name("greens|linen cloth".into())),
"a mix is a NAME, not a partial quality set — nothing suggests that reading, and a \
name with a pipe in it is already impossible"
);
assert_eq!(Filter::parse(" "), None);
}
/// What each filter takes.
#[test]
fn a_filter_takes_what_it_names() {
assert!(Filter::ItemId(7).takes(7, 2, "Anything"));
assert!(!Filter::ItemId(7).takes(8, 2, "Anything"));
assert!(Filter::Name("linen cloth".into()).takes(2589, 1, "Linen Cloth"));
assert!(!Filter::Name("linen cloth".into()).takes(2589, 1, "Wool Cloth"));
let q = Filter::Qualities(vec![2, 4]);
assert!(q.takes(1, 2, "x") && q.takes(1, 4, "x"));
assert!(!q.takes(1, 3, "x"), "blues are not in `greens|purples`");
}
/// The step machine: cast, wait, the item vanishes, cast the next; and the two stops.
#[test]
fn a_step_ends_when_its_item_leaves_the_bag_and_stops_when_it_does_not() {
let t0 = Instant::now();
let mut run = DisenchantRun::default();
assert_eq!(
run.advance(t0, false, |_| true, |_, _| None),
Step::Idle,
"nothing running"
);
// Two items to work through.
let items = std::cell::RefCell::new(vec![(0xa1u64, 0i64, 1u32), (0xa2, 0, 2)]);
let next = |_: &Filter, _: bool| items.borrow().first().copied();
run.start(Filter::Qualities(vec![2]), false);
assert_eq!(
run.advance(t0, false, |_| true, next),
Step::Cast {
guid: 0xa1,
bag: 0,
slot: 1
}
);
run.armed(0xa1, t0);
// The cast is running.
assert_eq!(run.advance(t0, true, |_| true, next), Step::Waiting);
// It resolved, but the item is still there and the deadline has not passed: still waiting,
// because a disenchant's removal takes a round trip after the cast ends.
assert_eq!(run.advance(t0, false, |_| true, next), Step::Waiting);
// Gone: on to the next.
items.borrow_mut().remove(0);
assert_eq!(
run.advance(t0, false, |g| g != 0xa1, next),
Step::Cast {
guid: 0xa2,
bag: 0,
slot: 2
}
);
run.armed(0xa2, t0);
items.borrow_mut().remove(0);
// No items left: the run stops with upstream's own line.
assert_eq!(
run.advance(t0, false, |_| false, next),
Step::Stop(CHAT_NO_MORE)
);
assert!(!run.running());
// And a step whose item never leaves stops as a failure once the deadline passes.
let one = std::cell::RefCell::new(Some((0xb1u64, 0i64, 3u32)));
let next_one = |_: &Filter, _: bool| *one.borrow();
run.start(Filter::ItemId(7), false);
assert!(matches!(
run.advance(t0, false, |_| true, next_one),
Step::Cast { guid: 0xb1, .. }
));
run.armed(0xb1, t0);
assert_eq!(run.advance(t0, false, |_| true, next_one), Step::Waiting);
assert_eq!(
run.advance(t0 + STEP_TIMEOUT, false, |_| true, next_one),
Step::Stop(CHAT_FAILED),
"the item is still there past the deadline — the cast failed, so the run stops"
);
assert!(!run.running());
let _ = one;
}
/// A run does not fight the player for the cast slot: a cast already in flight when the run
/// has no step of its own means wait, not send.
#[test]
fn a_run_waits_for_a_cast_it_did_not_start() {
let t0 = Instant::now();
let mut run = DisenchantRun::default();
run.start(Filter::ItemId(7), false);
assert_eq!(
run.advance(t0, true, |_| false, |_, _| Some((0xc1, 0, 1))),
Step::Waiting,
"the player is mid-cast; the run waits rather than being refused"
);
}
/// The per-item chat line is upstream's own chunk, with the Lua-space bag and slot.
#[test]
fn the_item_line_is_upstreams_own_chunk() {
let chunk = chat_disenchanting(2, 7);
assert!(chunk.contains("GetContainerItemLink(2, 7)"));
assert!(chunk.contains("move during cast to cancel."));
}
}
+7
View File
@@ -120,6 +120,13 @@ impl PendingCast {
}
}
/// The outstanding cast's deadline, for nampower's queue: an entry queued during a cast is
/// held until that cast's own end plus the window, so a player who queues early is not
/// punished for it. `None` with nothing in flight.
pub(crate) fn deadline(&self) -> Option<Instant> {
self.state.as_ref().map(|p| p.deadline)
}
/// [`Self::cast_seq`]'s reader, for the self-cast feed.
pub(crate) fn cast_seq(&self) -> u32 {
self.cast_seq
+6
View File
@@ -14,9 +14,11 @@ use benilla_world::schedule::WorldStage;
mod cast_send;
pub(crate) mod cast_target;
pub(crate) mod cooldowns;
pub(crate) mod disenchant;
mod inflight;
mod mods;
pub(crate) mod net;
pub(crate) mod queue;
pub(crate) mod targeting;
pub(crate) mod usable;
pub(crate) mod validator;
@@ -54,6 +56,10 @@ impl Plugin for SpellPlugin {
.init_resource::<SpellModifiers>()
.init_resource::<AutoSelfCast>()
.init_resource::<SpellTargeting>()
// nampower's explicit queue, and the two CVar rows that govern it.
.init_resource::<queue::SpellQueue>()
.init_resource::<disenchant::DisenchantRun>()
.add_observer(queue::on_cvar)
.init_resource::<targeting::EnchantConfirmItem>()
.init_resource::<targeting::PicksSelf>()
.init_resource::<targeting::CorpsePick>()
+420
View File
@@ -0,0 +1,420 @@
//! nampower's explicit spell queue: the three slots behind `QueueSpellByName`, `QueueScript` and
//! `ChannelStopCastingNextTick`.
//!
//! **What this is, and what it deliberately is not.** Upstream's queue has two halves. One is the
//! Lua surface — "remember this spell and send it when the client will accept it" — and that is
//! what is built here. The other intercepts **every ordinary press**, so a keypress during a cast
//! is held rather than refused; that half is governed by `NP_QueueCastTimeSpells`,
//! `NP_QueueInstantSpells`, `NP_QueueTargetingSpells` and friends, and it is **not** built. It is
//! not a binding: it changes how every key in the game feels, it is the one piece whose bugs are
//! double-casts and dropped casts, and it is not something to switch on as a side effect of
//! porting three function names. Those CVars stay stored-only in `cvars.rs`, which says so at
//! each row.
//!
//! So: a spell reaches a slot only because Lua asked, and leaves it the moment the client would
//! accept that press anyway.
//!
//! **Three slots, because upstream has three and its own event says so.** `SPELL_QUEUE_EVENT`'s
//! first argument is `0` on-swing queued, `1` on-swing popped, `2` normal queued, `3` normal
//! popped, `4` non-GCD queued, `5` non-GCD popped — so the queue is keyed by what *kind* of cast a
//! spell is, and one slot per kind is the shape a consumer reads. The kind comes off the spell's
//! own `Spell.dbc` row, the same three tests the send path makes.
use std::time::{Duration, Instant};
use bevy::prelude::*;
/// `SPELL_QUEUE_EVENT`'s first argument. Numbers, not an enum, because they cross to Lua as
/// numbers and an addon compares them against these literals.
pub(crate) mod queue_event {
pub(crate) const ON_SWING_QUEUED: i64 = 0;
pub(crate) const ON_SWING_POPPED: i64 = 1;
pub(crate) const NORMAL_QUEUED: i64 = 2;
pub(crate) const NORMAL_POPPED: i64 = 3;
pub(crate) const NON_GCD_QUEUED: i64 = 4;
pub(crate) const NON_GCD_POPPED: i64 = 5;
}
/// Which slot a spell belongs in, from its own `Spell.dbc` row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
/// `Attributes & 0x404`: Heroic Strike, Cleave, Raptor Strike.
OnSwing,
/// `StartRecoveryTime == 0`: it costs no global cooldown, so it need not wait for one.
NonGcd,
/// Everything else.
Normal,
}
impl Kind {
/// The same three tests the send path makes, in the same order: the on-next-swing attribute
/// first (it outranks everything, since such a spell never occupies the cast slot at all),
/// then the GCD pair.
pub(crate) fn of(spell: Option<&benilla_formats::SpellDisplay>) -> Self {
match spell {
Some(d) if d.on_next_swing() => Kind::OnSwing,
Some(d) if d.start_recovery_ms == 0 => Kind::NonGcd,
// An uncatalogued spell is Normal, which is the slot that waits for the most: a queue
// that fires something early is worse than one that fires it late.
_ => Kind::Normal,
}
}
fn queued_code(self) -> i64 {
match self {
Kind::OnSwing => queue_event::ON_SWING_QUEUED,
Kind::NonGcd => queue_event::NON_GCD_QUEUED,
Kind::Normal => queue_event::NORMAL_QUEUED,
}
}
fn popped_code(self) -> i64 {
match self {
Kind::OnSwing => queue_event::ON_SWING_POPPED,
Kind::NonGcd => queue_event::NON_GCD_POPPED,
Kind::Normal => queue_event::NORMAL_POPPED,
}
}
}
/// One occupied slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Slot {
spell_id: u32,
/// When this entry gives up. See [`SpellQueue::queue`] for where it comes from.
expires: Instant,
}
/// One queued script, with the priority that orders it.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Script {
priority: i64,
chunk: String,
expires: Instant,
}
/// The queue: one slot per [`Kind`], plus the scripts.
///
/// `pub(crate)` fields are avoided on purpose — every mutation goes through [`Self::queue`] or
/// [`Self::pop`], so the `SPELL_QUEUE_EVENT` rows cannot be forgotten by a caller that writes a
/// slot directly.
#[derive(Resource, Default)]
pub(crate) struct SpellQueue {
on_swing: Option<Slot>,
non_gcd: Option<Slot>,
normal: Option<Slot>,
scripts: Vec<Script>,
/// `(code, spellId)` rows for `SPELL_QUEUE_EVENT`, drained by the one system that holds the
/// VM. A queue rather than a direct fire for the reason every producer here uses one: this
/// resource is written from the send path, which holds no VM. (Naming the VM's type here —
/// even in a comment — puts this file in `ui_script_wall.rs`'s gated family, which is the
/// gate working as intended: this module really is gameplay state and not a feed.)
events: Vec<(i64, u32)>,
/// `NP_SpellQueueWindowMs`, the one window this half of the queue has a use for: how long an
/// entry is held before it gives up. Seeded by the `CvarChanged` observer.
window_ms: u32,
/// `NP_QueueSpellsOnCooldown`: whether an entry may sit in the queue while the spell itself is
/// on cooldown, or is dropped on the spot.
allow_on_cooldown: bool,
/// `ChannelStopCastingNextTick`: the channel is cancelled on the next frame, not this one.
stop_channel: bool,
}
impl SpellQueue {
/// Put `spell_id` in the slot its `kind` implies, replacing whatever was there.
///
/// **Replacing, not refusing**, which is upstream's own behaviour — its log line is
/// `Replacing queued nonGcd spell X with Y`. The replaced entry raises no popped event: it was
/// never sent, and a consumer counting pops against queues would see one too many.
///
/// The expiry is `max(now, in_flight_until) + window`: an entry queued during a three-second
/// cast is held until that cast's own end plus the window, so a player who queues early is not
/// punished for it, and one that nothing ever pops gives up rather than firing minutes later
/// out of nowhere. That last part is the whole reason the window exists here.
pub(crate) fn queue(
&mut self,
spell_id: u32,
kind: Kind,
now: Instant,
in_flight_until: Option<Instant>,
) {
let until = in_flight_until.unwrap_or(now).max(now);
let slot = Slot {
spell_id,
expires: until + Duration::from_millis(u64::from(self.window_ms)),
};
*self.slot_mut(kind) = Some(slot);
self.events.push((kind.queued_code(), spell_id));
}
/// Queue a chunk for [`Self::take_ready_scripts`].
pub(crate) fn queue_script(&mut self, chunk: String, priority: i64, now: Instant) {
self.scripts.push(Script {
priority,
chunk,
expires: now + Duration::from_millis(u64::from(self.window_ms)),
});
}
fn slot_mut(&mut self, kind: Kind) -> &mut Option<Slot> {
match kind {
Kind::OnSwing => &mut self.on_swing,
Kind::NonGcd => &mut self.non_gcd,
Kind::Normal => &mut self.normal,
}
}
/// Take the spell in `kind`'s slot if it is still live, raising its popped event.
pub(crate) fn pop(&mut self, kind: Kind) -> Option<u32> {
let spell_id = self.slot_mut(kind).take()?.spell_id;
self.events.push((kind.popped_code(), spell_id));
Some(spell_id)
}
/// What is in `kind`'s slot, without taking it.
pub(crate) fn peek(&self, kind: Kind) -> Option<u32> {
match kind {
Kind::OnSwing => self.on_swing,
Kind::NonGcd => self.non_gcd,
Kind::Normal => self.normal,
}
.map(|s| s.spell_id)
}
/// Drop every entry whose window has passed.
///
/// **No popped event for an expiry**, and that is the point of the distinction: a consumer
/// reads `*_POPPED` as "this spell was sent". An entry that timed out was not. Upstream has
/// no code for it either.
pub(crate) fn expire(&mut self, now: Instant) {
for kind in [Kind::OnSwing, Kind::NonGcd, Kind::Normal] {
if self.slot_mut(kind).is_some_and(|s| now >= s.expires) {
let dropped = self.slot_mut(kind).take();
if let Some(s) = dropped {
debug!(
"spell-queue: dropping {:?} spell {} — nothing popped it inside the window",
kind, s.spell_id
);
}
}
}
self.scripts.retain(|s| now < s.expires);
}
/// The queued scripts in priority order, taken.
///
/// Ascending priority, which is the order `SuperCleveRoidMacros` assumes when it passes `3`
/// for "after queued spells" (`Core.lua:312`) — a bigger number is later. Taken all at once
/// because the caller runs them in one breath once the queue has drained.
pub(crate) fn take_ready_scripts(&mut self) -> Vec<String> {
let mut ready = std::mem::take(&mut self.scripts);
// Stable, so two scripts at one priority keep the order they were queued in.
ready.sort_by_key(|s| s.priority);
ready.into_iter().map(|s| s.chunk).collect()
}
/// Whether every **spell** slot is empty — the gate the queued scripts wait behind, since
/// their contract is "after queued spells" and not "after everything".
pub(crate) fn no_spells_waiting(&self) -> bool {
self.on_swing.is_none() && self.non_gcd.is_none() && self.normal.is_none()
}
/// Whether anything at all is waiting, spells or scripts. Tests only, for now: the drain's own
/// gate is [`Self::no_spells_waiting`].
#[cfg(test)]
fn is_empty(&self) -> bool {
self.no_spells_waiting() && self.scripts.is_empty()
}
/// Take the `SPELL_QUEUE_EVENT` rows.
pub(crate) fn take_events(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.events)
}
/// `NP_QueueSpellsOnCooldown`'s applied value.
pub(crate) fn allows_on_cooldown(&self) -> bool {
self.allow_on_cooldown
}
/// Arm `ChannelStopCastingNextTick`.
pub(crate) fn request_channel_stop(&mut self) {
self.stop_channel = true;
}
/// Take the channel-stop request.
pub(crate) fn take_channel_stop(&mut self) -> bool {
std::mem::take(&mut self.stop_channel)
}
}
/// `NP_SpellQueueWindowMs` and `NP_QueueSpellsOnCooldown`, the two rows this half of the queue
/// reads. Every other `NP_Queue*`/`NP_*BufferTimeMs` row tunes the press-interception half, which
/// is not built — `cvars.rs` says so at each.
pub(crate) const CVAR_QUEUE_WINDOW: &str = "NP_SpellQueueWindowMs";
pub(crate) const CVAR_QUEUE_ON_COOLDOWN: &str = "NP_QueueSpellsOnCooldown";
/// Apply a change of either row. The registry fires this at boot with the registered default, so
/// neither field is read before it has been seeded.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut queue: ResMut<SpellQueue>) {
if ev.is(CVAR_QUEUE_WINDOW) {
// Clamped at 10 s: the window's whole job is to stop an entry firing out of nowhere, and
// a player who types a huge number into the settings panel should not lose that.
queue.window_ms = ev.num().clamp(0.0, 10_000.0) as u32;
}
if ev.is(CVAR_QUEUE_ON_COOLDOWN) {
queue.allow_on_cooldown = ev.flag();
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::SpellDisplay;
fn display(attributes: u32, start_recovery_ms: u32) -> SpellDisplay {
SpellDisplay {
attributes,
start_recovery_ms,
..Default::default()
}
}
/// The three kinds, off the same tests the send path makes. `0x404` is the on-next-swing pair.
#[test]
fn a_spells_kind_is_its_own_dbc_row() {
assert_eq!(Kind::of(Some(&display(0x404, 0))), Kind::OnSwing);
assert_eq!(
Kind::of(Some(&display(0x404, 1500))),
Kind::OnSwing,
"the on-swing attribute outranks a GCD pair: such a spell never occupies the cast slot"
);
assert_eq!(Kind::of(Some(&display(0, 0))), Kind::NonGcd);
assert_eq!(Kind::of(Some(&display(0, 1500))), Kind::Normal);
assert_eq!(
Kind::of(None),
Kind::Normal,
"an uncatalogued spell waits for the most — firing early is worse than firing late"
);
}
/// Queue, replace, pop — and the events each raises.
#[test]
fn a_replacement_raises_no_pop_and_an_expiry_raises_nothing() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
q.queue(133, Kind::Normal, t0, None);
assert_eq!(q.peek(Kind::Normal), Some(133));
q.queue(116, Kind::Normal, t0, None);
assert_eq!(
q.peek(Kind::Normal),
Some(116),
"a second queue replaces, as upstream's own log line says"
);
assert_eq!(
q.take_events(),
vec![
(queue_event::NORMAL_QUEUED, 133),
(queue_event::NORMAL_QUEUED, 116),
],
"two queues, and NO pop for the replaced entry — it was never sent, and a consumer \
counting pops against queues would see one too many"
);
assert_eq!(q.pop(Kind::Normal), Some(116));
assert_eq!(q.peek(Kind::Normal), None);
assert_eq!(q.take_events(), vec![(queue_event::NORMAL_POPPED, 116)]);
assert_eq!(q.pop(Kind::Normal), None, "an empty slot pops nothing");
assert!(q.take_events().is_empty());
// The three slots are independent: a normal spell does not displace a strike.
q.queue(78, Kind::OnSwing, t0, None);
q.queue(133, Kind::Normal, t0, None);
q.queue(2139, Kind::NonGcd, t0, None);
assert_eq!(
(
q.peek(Kind::OnSwing),
q.peek(Kind::Normal),
q.peek(Kind::NonGcd)
),
(Some(78), Some(133), Some(2139))
);
let codes: Vec<i64> = q.take_events().into_iter().map(|(c, _)| c).collect();
assert_eq!(
codes,
vec![
queue_event::ON_SWING_QUEUED,
queue_event::NORMAL_QUEUED,
queue_event::NON_GCD_QUEUED
]
);
// An expiry raises nothing: `*_POPPED` means "this was sent", and this was not.
q.expire(t0 + Duration::from_millis(499));
assert_eq!(q.peek(Kind::Normal), Some(133), "inside the window");
q.expire(t0 + Duration::from_millis(500));
assert!(q.is_empty(), "every slot gave up at the window");
assert!(
q.take_events().is_empty(),
"and an expiry is not a pop — upstream has no code for one either"
);
}
/// The expiry is measured from the **end of the cast in flight**, not from the moment of
/// queueing: a player who queues early during a long cast is not punished for it.
#[test]
fn an_entry_queued_during_a_cast_is_held_until_that_casts_own_end() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
let cast_ends = t0 + Duration::from_secs(3);
q.queue(133, Kind::Normal, t0, Some(cast_ends));
q.expire(t0 + Duration::from_millis(600));
assert_eq!(
q.peek(Kind::Normal),
Some(133),
"600 ms in, the cast is still running — the window has not started"
);
q.expire(cast_ends + Duration::from_millis(499));
assert_eq!(q.peek(Kind::Normal), Some(133));
q.expire(cast_ends + Duration::from_millis(500));
assert_eq!(q.peek(Kind::Normal), None);
// A deadline already in the past is not a shorter window than a fresh queue's: the `max`
// is what stops a lapsed cast making the entry expire instantly.
q.queue(133, Kind::Normal, t0, Some(t0 - Duration::from_secs(10)));
q.expire(t0 + Duration::from_millis(499));
assert_eq!(q.peek(Kind::Normal), Some(133));
}
/// Scripts come back in ascending priority, and two at one priority keep their order —
/// `SuperCleveRoidMacros` passes `3` for "after queued spells", so a bigger number is later.
#[test]
fn queued_scripts_come_back_in_priority_order() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
q.queue_script("third".into(), 3, t0);
q.queue_script("first".into(), 1, t0);
q.queue_script("also-third".into(), 3, t0);
q.queue_script("second".into(), 2, t0);
assert_eq!(
q.take_ready_scripts(),
vec!["first", "second", "third", "also-third"]
);
assert!(q.is_empty(), "taking them empties the list");
// And they expire on the same window as a spell does.
q.queue_script("gone".into(), 1, t0);
q.expire(t0 + Duration::from_millis(500));
assert!(q.take_ready_scripts().is_empty());
}
}
@@ -2736,6 +2736,14 @@ mod tests {
same reason, and the spell id comes from the live window because the packet \
carries nothing but the duration",
),
(
"SPELL_QUEUE_EVENT",
"nampower: a spell entering or leaving its queue, as `code, spellId` — code 0/1 \
on-swing queued/popped, 2/3 normal, 4/5 non-GCD. 1.12 has no queue and so no \
event for one; this client has the explicit half (`QueueSpellByName`, \
`QueueScript`), and the codes are what tells a consumer which slot moved. \
`SuperCleveRoidMacros` registers it from `Core.lua:4887`",
),
(
"SPELL_CAST_EVENT",
"nampower: the player's own cast as the client committed it, carrying the spell \
+242
View File
@@ -447,6 +447,14 @@ fn drain_spell_casts(
targeting: cast_target::CastTargeting,
hovered: Option<Res<crate::target::Hovered>>,
mut ladder: CastLadder,
// nampower's explicit queue (`spell/queue.rs`). Folded into this system for the reason its
// own header gives: only one system may hold `CastLadder`, and the queue's pop is a cast
// send like any other.
mut queue: ResMut<crate::spell::queue::SpellQueue>,
channel: Res<crate::spell::ActiveChannel>,
// nampower's `DisenchantAll` run, driven from here for the same reason: it sends casts.
mut disenchant: ResMut<crate::spell::disenchant::DisenchantRun>,
items: Res<crate::items::Items>,
) {
let Some(mut script) = script else {
return;
@@ -499,6 +507,240 @@ fn drain_spell_casts(
);
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
}
drain_spell_queue(&mut script, &targeting, &mut ladder, &mut queue, &channel);
drive_disenchant(
&mut script,
&targeting,
&mut ladder,
&mut disenchant,
&items,
);
}
/// Take nampower's queue requests, pop whatever is sendable, and run the scripts behind it.
///
/// **The pop's gate is the same question the send path would ask**, checked here rather than let
/// the ladder refuse: a refusal pushes a red error line, and a queued spell that is merely *not
/// yet* sendable has nothing to report. So this tests the two conditions the ladder would refuse
/// on — a guarding cast in flight, and the spell's own cooldown — and leaves the entry alone
/// otherwise. Everything else (reagents, range, power, form) is left to the ladder, because those
/// are real refusals a player should see.
///
/// The three slots pop in the order upstream's own event codes list them, and each on its own
/// condition:
///
/// - **non-GCD** as soon as no cast guards. It costs no global cooldown, so waiting for one would
/// be waiting for something it does not use.
/// - **normal** when no cast guards *and* the global cooldown has run out. This is the slot the
/// queue exists for.
/// - **on-swing** when no cast guards. Such a spell never occupies the cast slot itself (the
/// server holds it on `CURRENT_MELEE_SPELL`), so the GCD is not its gate either.
///
/// Scripts run **after** the slots, and only once every slot is empty — "after queued spells",
/// which is `SuperCleveRoidMacros`' own words for what it uses `QueueScript` for.
fn drain_spell_queue(
script: &mut UiScript,
targeting: &cast_target::CastTargeting,
ladder: &mut CastLadder,
queue: &mut crate::spell::queue::SpellQueue,
channel: &crate::spell::ActiveChannel,
) {
use crate::spell::queue::Kind;
let now = std::time::Instant::now();
// `ChannelStopCastingNextTick`: armed on the frame it was called, sent on the next. The delay
// is the point — see the binding.
if script.take_channel_stops() > 0 {
queue.request_channel_stop();
} else if queue.take_channel_stop() {
if let Some(spell_id) = channel.current(now) {
debug!("spell-queue: stopping the channel of {spell_id} (next tick)");
let _ = ladder
.commands
.0
.send(crate::net::ClientCommand::CancelChannelling { spell_id });
}
}
// The requests, resolved once here: a name becomes a spell id through the player's own book,
// which is the same walk `CastSpellByName` makes, so one rank cannot be queued and another
// cast.
let in_flight_until = ladder.pending.deadline();
for request in script.take_queue_requests() {
match request {
benilla_ui::script::QueueRequest::Spell { name, priority } => {
let book = script.spellbook();
let Some(spell_id) =
benilla_ui::script::resolve_spell_by_name(&book, &name).map(|s| s.spell_id)
else {
debug!("spell-queue: {name:?} is not a spell in the book — not queued");
continue;
};
let kind = Kind::of(ladder.spells.as_ref().and_then(|s| s.catalog.get(spell_id)));
debug!(
"spell-queue: queueing {spell_id} ({name:?}) as {kind:?}, priority {priority}"
);
queue.queue(spell_id, kind, now, in_flight_until);
}
benilla_ui::script::QueueRequest::Script { chunk, priority } => {
queue.queue_script(chunk, priority, now);
}
}
}
queue.expire(now);
// The pop. `in_flight` is the ladder's own guard test, so a queued spell waits exactly as
// long as a press would have been refused for.
let guarded = ladder.pending.in_flight(now);
let gcd_running = ladder.cooldowns.gcd(now).is_some();
for (kind, ready) in [
(Kind::NonGcd, !guarded),
(Kind::Normal, !guarded && !gcd_running),
(Kind::OnSwing, !guarded),
] {
if !ready {
continue;
}
let Some(spell_id) = queue.peek(kind) else {
continue;
};
// The spell's own cooldown, which `NP_QueueSpellsOnCooldown` governs: off, an entry whose
// spell is cold is dropped rather than held, because holding it means firing the moment
// the cooldown ends — which is a cast the player asked for a long time ago.
let def = ladder.spells.as_ref().and_then(|s| s.catalog.get(spell_id));
if ladder.cooldowns.not_ready(spell_id, 0, def, now) {
if queue.allows_on_cooldown() {
continue;
}
debug!("spell-queue: dropping {spell_id} — on cooldown, and NP_QueueSpellsOnCooldown is off");
queue.pop(kind);
continue;
}
let Some(spell_id) = queue.pop(kind) else {
continue;
};
debug!("spell-queue: popping {kind:?} spell {spell_id}");
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
}
// The scripts, once every **spell** slot is empty — "after queued spells", which is
// `SuperCleveRoidMacros`' own words for what it uses `QueueScript` for.
if queue.no_spells_waiting() {
for chunk in queue.take_ready_scripts() {
debug!(
"spell-queue: running a queued script ({} bytes)",
chunk.len()
);
crate::ui_script::run_or_warn(script, &chunk);
}
}
for (code, spell_id) in queue.take_events() {
script.fire_event(
"SPELL_QUEUE_EVENT",
vec![
benilla_ui::script::ScriptValue::Int(code),
benilla_ui::script::ScriptValue::Int(i64::from(spell_id)),
],
);
}
}
/// Drive nampower's `DisenchantAll` run: start what Lua asked for, then take one step.
///
/// The step machine is `spell/disenchant.rs`; this supplies the two things it cannot have — the
/// bag walk and the cast send — and prints upstream's own chat lines.
///
/// **The precondition is checked once, at the start**: Disenchant has to be in the player's book,
/// which is upstream's `You do not have the Disenchant spell`. Checked here rather than left to
/// the ladder because the ladder's refusal is a red error line per attempt, and the honest answer
/// to "disenchant everything" from someone who cannot disenchant is one line, now.
fn drive_disenchant(
script: &mut UiScript,
targeting: &cast_target::CastTargeting,
ladder: &mut CastLadder,
run: &mut crate::spell::disenchant::DisenchantRun,
items: &crate::items::Items,
) {
use crate::spell::disenchant::{Filter, Step, DISENCHANT};
for ask in script.take_disenchant_asks() {
if !script
.spellbook()
.slots
.iter()
.any(|s| s.spell_id == DISENCHANT)
{
// Upstream's own line, through the chat frame rather than the error line: it is an
// answer to a command, not a failed cast.
crate::ui_script::run_or_warn(
script,
r#"DEFAULT_CHAT_FRAME:AddMessage("You do not have the Disenchant spell")"#,
);
continue;
}
match Filter::parse(&ask.filter) {
Some(filter) => run.start(filter, ask.include_soulbound),
None => debug!("disenchant: {:?} names nothing — not started", ask.filter),
}
}
if !run.running() {
return;
}
let now = std::time::Instant::now();
let Some(store) = targeting.self_store.iter().next() else {
// Upstream's `No player unit, stopping disenchant`.
run.stop("no player unit, stopping disenchant");
return;
};
// Is this guid still in a bag? The run's success test — a disenchanted item leaves.
let still_there = |guid: u64| {
(0i64..=4).any(|bag| {
(0u8..=35).any(|slot| {
crate::ui_items::slot_guid(&store.0, bag, slot, &ladder.objects) == Some(guid)
})
})
};
// The next matching item, ascending bag then ascending slot — the order the reference's own
// inventory walk takes, and so the order a player reads their bags in.
let next = |filter: &Filter, include_soulbound: bool| {
(0i64..=4).find_map(|bag| {
(0u8..=35).find_map(|slot| {
let guid = crate::ui_items::slot_guid(&store.0, bag, slot, &ladder.objects)?;
let fields = ladder.objects.object(guid)?;
let entry = fields.object_entry()?;
// `ITEM_FIELD_FLAGS & 1` is bound-at-runtime, the same bit the tooltip's
// Soulbound line reads.
if !include_soulbound && fields.item_flags().unwrap_or(0) & 1 != 0 {
return None;
}
// An unresolved template is skipped rather than cast at: upstream's `Invalid
// item, stopping disenchant` is about a bad slot, and a template still in flight
// is not bad — it is not here yet, and the next frame will have it.
let t = items.template_cached(entry)?;
filter
.takes(entry, t.quality, &t.name)
.then_some((guid, bag, u32::from(slot) + 1))
})
})
};
match run.advance(now, ladder.pending.in_flight(now), still_there, next) {
Step::Idle | Step::Waiting => {}
Step::Stop(chat) => crate::ui_script::run_or_warn(script, chat),
Step::Cast { guid, bag, slot } => {
crate::ui_script::run_or_warn(
script,
&crate::spell::disenchant::chat_disenchanting(bag, slot),
);
debug!("disenchant: casting at item {guid:#x} (bag {bag}, slot {slot})");
ladder.commit_targeted(DISENCHANT, CastCommit::Spell, TargetedBind::Item(guid));
run.armed(guid, now);
}
}
}
#[cfg(test)]
@@ -990,6 +990,133 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?,
)?;
// `DisenchantAll(itemIdOrName | quality, [includeSoulbound])`: cast Disenchant on every
// matching bag item, one after another.
//
// **The sequencer is `benilla-app`'s `spell/disenchant.rs`**, and the DLL specifies it
// completely in its own strings — the quality keywords, every stop reason, and the three chat
// lines, one of them as a Lua chunk. That module's header carries the lot.
//
// The argument is passed through as **text**, unparsed. Upstream's grammar is "a number is an
// item id, `greens`/`blues`/`purples` are qualities and pipe-combine, anything else is a
// name", and it is parsed once, app-side, where the bag walk that uses it lives.
//
// `includeSoulbound` is the second argument's own doc line, verbatim from the pool:
// "optional number - pass 1 to include soulbound items (defaults to 0)". So `1` includes
// them and anything else — including `true`, which a Lua caller might reach for — does not,
// because the doc says *number*.
//
// No return value: upstream's is a slash command's worth of work, reported in chat.
lua.globals().set(
"DisenchantAll",
lua.create_function(|lua, (filter, soulbound): (Option<Value>, Option<Value>)| {
let filter = match filter {
Some(Value::Integer(n)) => n.to_string(),
Some(Value::Number(n)) => (n as i64).to_string(),
Some(Value::String(s)) => s.to_str()?.to_string(),
_ => return Ok(()),
};
let mut model = lua
.app_data_mut::<crate::script::Model>()
.expect("model app_data");
model.disenchant_asks.push(crate::script::DisenchantAsk {
filter,
include_soulbound: matches!(soulbound, Some(Value::Integer(1)))
|| matches!(soulbound, Some(Value::Number(n)) if n == 1.0),
});
Ok(())
})?,
)?;
// ── nampower's explicit spell queue ───────────────────────────────────────────────────────
//
// `QueueSpellByName`, `QueueScript` and `ChannelStopCastingNextTick`. The slots, the window
// and the pops are `benilla-app`'s `spell/queue.rs`, which also sets out the half of
// upstream's queue that is **not** built: the one that intercepts every ordinary press. That
// half changes how every key in the game feels and is governed by its own CVars, which stay
// stored-only; this is the Lua surface, and a spell reaches a slot only because Lua asked.
// `QueueSpellByName(name, [priority])`: remember a spell and send it when the client would
// accept the press anyway.
//
// The **name** is carried through rather than resolved here: resolving one means the player's
// book then the host's `Spell.dbc`, which is the same walk `CastSpellByName` already makes
// app-side. Two resolvers could disagree about which rank "Fireball" means.
//
// `priority` is accepted and recorded. It orders *scripts* against each other; for a spell it
// has nothing to order — the slot a spell lands in is decided by its own kind (on-swing,
// non-GCD, normal), and there is one slot per kind, so there is never a choice to make between
// two spells of the same kind. A second queue of one kind replaces the first, which is
// upstream's own behaviour (its log line is `Replacing queued nonGcd spell X with Y`).
lua.globals().set(
"QueueSpellByName",
lua.create_function(|lua, (name, priority): (Option<String>, Option<i64>)| {
let Some(name) = name.filter(|n| !n.is_empty()) else {
return Ok(());
};
let mut model = lua
.app_data_mut::<crate::script::Model>()
.expect("model app_data");
model
.queue_requests
.push(crate::script::QueueRequest::Spell {
name,
priority: priority.unwrap_or(0),
});
Ok(())
})?,
)?;
// `QueueScript("script", [priority])`: run a chunk once the queue has drained.
//
// `SuperCleveRoidMacros` uses it for exactly one thing — swapping a weapon after the cast in
// flight finishes (`Core.lua:310-314`, `QueueScript('EquipItemByName("%s",%d)', 3)` with the
// comment "Priority 3 = after queued spells") — so "after the queue" is the contract, and
// ascending priority orders scripts among themselves.
//
// The chunk is a string of Lua, run at global scope by the app's drain, which is where the
// queue's state lives. A syntax error is reported there, through the usual error path: this
// verb cannot report it, because the chunk does not run until a later frame.
lua.globals().set(
"QueueScript",
lua.create_function(|lua, (chunk, priority): (Option<String>, Option<i64>)| {
let Some(chunk) = chunk.filter(|c| !c.is_empty()) else {
return Ok(());
};
let mut model = lua
.app_data_mut::<crate::script::Model>()
.expect("model app_data");
model
.queue_requests
.push(crate::script::QueueRequest::Script {
chunk,
priority: priority.unwrap_or(0),
});
Ok(())
})?,
)?;
// `ChannelStopCastingNextTick()`: end the running channel on the **next** frame.
//
// Why a frame later matters, and why upstream spells the delay into the name: a macro that
// stops its own channel and then casts something would, if the stop were immediate, send
// `CMSG_CANCEL_CHANNELLING` and the next cast in one breath — and the server has not yet seen
// the cancel when the cast arrives. One frame is enough ordering for the pair to land in
// order, which is the whole trick.
//
// Counted rather than flagged so a double call is visible to the app as one request; it
// cancels one channel either way.
lua.globals().set(
"ChannelStopCastingNextTick",
lua.create_function(|lua, ()| {
let mut model = lua
.app_data_mut::<crate::script::Model>()
.expect("model app_data");
model.channel_stop_next_tick = model.channel_stop_next_tick.saturating_add(1);
Ok(())
})?,
)?;
// ── nampower's credential pair ────────────────────────────────────────────────────────────
//
// `EncryptPassword(password)` and `EncryptedServerLogin(username, encryptedPasswordBase64String)`.
@@ -3409,6 +3536,89 @@ mod tests {
/// `GetSpellRecField`: the columns this client has, and **nil for the ones it does not** —
/// asserted, because a plausible zero is worse than nothing here (DoiteAuras' Clearcasting
/// The three queue verbs and `DisenchantAll`: what reaches the app, which is this side's whole
/// job. The slots, the window and the sequencer are pinned in `benilla-app`'s
/// `spell/queue.rs` and `spell/disenchant.rs`.
#[test]
fn the_queue_verbs_and_disenchant_all_pass_their_asks_through() {
use crate::script::QueueRequest as Q;
let mut s = UiScript::new().unwrap();
// `QueueSpellByName` carries the **name**, unresolved: resolving it means the player's
// book, which is the app's walk, and two resolvers could disagree about which rank
// "Fireball" means.
s.eval::<()>(r#"QueueSpellByName("Fireball")"#).unwrap();
s.eval::<()>(r#"QueueSpellByName("Fireball(Rank 3)", 7)"#)
.unwrap();
s.eval::<()>(r#"QueueScript('EquipItemByName("Thunderfury", 16)', 3)"#)
.unwrap();
// Empty and absent arguments queue nothing rather than an empty request.
for call in [
"QueueSpellByName()",
r#"QueueSpellByName("")"#,
"QueueScript()",
r#"QueueScript("")"#,
] {
s.eval::<()>(call).unwrap();
}
assert_eq!(
s.take_queue_requests(),
vec![
Q::Spell {
name: "Fireball".into(),
priority: 0
},
Q::Spell {
name: "Fireball(Rank 3)".into(),
priority: 7
},
Q::Script {
chunk: r#"EquipItemByName("Thunderfury", 16)"#.into(),
priority: 3
},
],
"the rank suffix rides along untouched, and an absent priority is 0"
);
assert!(
s.take_queue_requests().is_empty(),
"taking empties the queue, so one call cannot be drained twice"
);
// `ChannelStopCastingNextTick` counts, so a double call is one request to the app.
assert_eq!(s.take_channel_stops(), 0);
s.eval::<()>("ChannelStopCastingNextTick()").unwrap();
s.eval::<()>("ChannelStopCastingNextTick()").unwrap();
assert_eq!(s.take_channel_stops(), 2);
assert_eq!(s.take_channel_stops(), 0);
// `DisenchantAll`: the filter as text, and `includeSoulbound` as upstream's own doc line
// says — "pass **1**", a number.
s.eval::<()>(r#"DisenchantAll("greens")"#).unwrap();
s.eval::<()>("DisenchantAll(12345)").unwrap();
s.eval::<()>(r#"DisenchantAll("blues|purples", 1)"#)
.unwrap();
s.eval::<()>(r#"DisenchantAll("greens", true)"#).unwrap();
s.eval::<()>("DisenchantAll()").unwrap();
let asks: Vec<(String, bool)> = s
.take_disenchant_asks()
.into_iter()
.map(|a| (a.filter, a.include_soulbound))
.collect();
assert_eq!(
asks,
vec![
("greens".to_string(), false),
("12345".to_string(), false),
("blues|purples".to_string(), true),
("greens".to_string(), false),
],
"a number argument becomes its own digits (the app parses the grammar once), and \
`true` does NOT include soulbound — the doc line says pass 1, a number"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `GetUnitData` and `GetUnitField`'s scalar half, over one fed block — asserted together,
/// because the point of building them off one block is that they cannot disagree.
#[test]
+3 -3
View File
@@ -243,9 +243,9 @@ pub use model::{
RaceInfo,
};
pub use model::{
CustomFileAccess, CustomFileMode, PasswordEncryptProbe, PlayerCacheEntry, RecValue, SelfCast,
SelfMovement, ServerClock, SpellModifierProbe, SpellRangeProbe, SpellRecFieldProbe,
SpellRecordProbe,
CustomFileAccess, CustomFileMode, DisenchantAsk, PasswordEncryptProbe, PlayerCacheEntry,
QueueRequest, RecValue, SelfCast, SelfMovement, ServerClock, SpellModifierProbe,
SpellRangeProbe, SpellRecFieldProbe, SpellRecordProbe,
};
pub use model::{
FontProbe, ItemIconProbe, SpellDbcExtras, SpellIconProbe, SpellLookup, SpellMechanicProbe,
+35
View File
@@ -71,6 +71,32 @@ pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
/// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent.
pub type ItemIconProbe = Box<dyn Fn(u32) -> Option<String>>;
/// A `DisenchantAll(itemIdOrName | quality, [includeSoulbound])` call, as the verb received it.
///
/// The argument is carried through as **text**, unparsed: upstream's grammar (a number is an item
/// id, `greens|blues|purples` are qualities, anything else is a name) is parsed app-side in
/// `spell/disenchant.rs`, where the bag walk that uses it lives. Parsing it twice would be two
/// readings of `"greens|Linen Cloth"`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisenchantAsk {
pub filter: String,
pub include_soulbound: bool,
}
/// One nampower queue request: `QueueSpellByName(name, [priority])` or
/// `QueueScript(script, [priority])`.
///
/// The name is not resolved here. `QueueSpellByName` takes a *name*, and resolving one to a spell
/// id means the player's book then the host's `Spell.dbc` — which is the same walk
/// `CastSpellByName` makes app-side, so the request carries the name and the app does the walk
/// once. The alternative would be two resolvers that can disagree about which rank "Fireball"
/// means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueueRequest {
Spell { name: String, priority: i64 },
Script { chunk: String, priority: i64 },
}
/// nampower's `EncryptPassword(password)`: the blob, or `None` with no config home to keep a key
/// file in (a hermetic capture). The scheme is `benilla-app`'s `credential.rs`.
pub type PasswordEncryptProbe = Box<dyn Fn(&str) -> Option<String>>;
@@ -877,6 +903,12 @@ pub(crate) struct Model {
pub(crate) spell_range_probe: Option<SpellRangeProbe>,
pub(crate) custom_files: Option<CustomFileAccess>,
pub(crate) password_encrypt_probe: Option<PasswordEncryptProbe>,
/// `DisenchantAll` calls, drained by `benilla-app`'s `drain_spell_casts`.
pub(crate) disenchant_asks: Vec<DisenchantAsk>,
/// nampower's queue requests, drained by `benilla-app`'s `drain_spell_casts`.
pub(crate) queue_requests: Vec<QueueRequest>,
/// `ChannelStopCastingNextTick()` calls; the app cancels the channel on the next frame.
pub(crate) channel_stop_next_tick: u32,
/// `EncryptedServerLogin(username, blob)` calls, drained by `benilla-app`'s
/// `ui_script::drain_credential_logins`.
pub(crate) credential_logins: Vec<(String, String)>,
@@ -1788,6 +1820,9 @@ impl Model {
custom_files: None,
password_encrypt_probe: None,
credential_logins: Vec::new(),
disenchant_asks: Vec::new(),
queue_requests: Vec::new(),
channel_stop_next_tick: 0,
spell_mechanic_probe: None,
player_cache: HashMap::new(),
player_cache_enabled: [true, true],
+15
View File
@@ -138,6 +138,21 @@ impl super::UiScript {
self.model_mut().password_encrypt_probe = Some(probe);
}
/// Take the `DisenchantAll` calls this tick.
pub fn take_disenchant_asks(&mut self) -> Vec<super::DisenchantAsk> {
std::mem::take(&mut self.model_mut().disenchant_asks)
}
/// Take nampower's queue requests this tick.
pub fn take_queue_requests(&mut self) -> Vec<super::QueueRequest> {
std::mem::take(&mut self.model_mut().queue_requests)
}
/// Take the `ChannelStopCastingNextTick()` calls this tick.
pub fn take_channel_stops(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().channel_stop_next_tick)
}
/// Take the `EncryptedServerLogin` calls this tick.
pub fn take_credential_logins(&mut self) -> Vec<(String, String)> {
std::mem::take(&mut self.model_mut().credential_logins)
@@ -128,6 +128,10 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"GetRaidTargets",
"LearnTalentRank",
"GetUnitData",
"QueueSpellByName",
"QueueScript",
"ChannelStopCastingNextTick",
"DisenchantAll",
// nampower v4's spellbook index helpers (script/compat/nampower.rs), all four of them the
// fast paths DoiteAuras takes before falling back to a 200-slot `GetSpellName` scan per
// lookup — it does one of those per tracked aura per condition evaluation.
+66 -20
View File
@@ -476,7 +476,7 @@ real when something else agrees — an addon calling it, or the mod's own docs.
What it found that this file did not already have: **38 names in `nampower.dll` that grom's addons
call**, on top of the families the nampower section above already tracks. Unported, and listed
rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CombatLogFlush`, `CustomFileExists`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`,
rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `ChannelStopCastingNextTick`, `CombatLogFlush`, `CustomFileExists`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`,
`CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`,
`CustomFileExists`, `EncryptPassword`, `ExecuteCustomLuaFile`, `FindPlayerItemSlot`, `GetBagItem`,
`GetCastInfo`, `GetCurrentCastingInfo`, `GetItemIconTexture`, `GetItemIdCooldown`,
@@ -620,18 +620,11 @@ migrates by itself. The machinery to make the Remember Password tick-box write a
now all here — one line in `login/mod.rs` — and that is his call to make, not a side effect of
porting two verbs.
**Needs a feed this client does not have, with what it would take (2).**
| Name | What is missing |
|---|---|
| `GetUnitData(unit)` | The whole descriptor as a table, where `GetUnitField` answers one field. Needs a decision about which fields: the real one returns GUID-typed columns (charm, summon, charmedBy) this client's field reader does not type |
**Not ours to add (3).** `QueueSpellByName`, `QueueScript` and `ChannelStopCastingNextTick` are the
spell queue itself, which this client deliberately does not have (see the nampower rows above:
namreeb's always-on cast rule is ported, pepo's queue machinery is not). `LearnTalentRank(page,
index, rank)` is left out for a narrower reason: `LearnTalent` here learns the *next* rank, so
reaching a named rank means queueing the pair once per missing rank, and whether the server accepts
that burst is not something this client can check before sending.
**Everything below this line is the reasoning of earlier rounds, kept rather than deleted.** Each
section records what was missing *then*, what it would have taken, and — where a later round built
it — which of those reasons turned out to be wrong. That is the point of keeping them: three of the
"needs a feed" rows were answerable with data the tree already had, and a list of only the final
answers would hide that the first reading was the expensive mistake.
### The cast feed — `GetCurrentCastingInfo`, `GetCastInfo` and `SPELL_CAST_EVENT`
@@ -874,6 +867,62 @@ Three decisions at that mapping:
the player. The other five (`guid`, `posX`, `posY`, `power`, `maxPower`) are filled by the
caller, which knows them.
### The queue, and the one verb that needs it — 62/62
**Built**, on grom's "62/62": `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick` and
`DisenchantAll`.
**Upstream's queue has two halves, and one of them is deliberately not built.** The Lua surface —
"remember this spell and send it when the client will accept it" — is `spell/queue.rs`. The other
half intercepts **every ordinary press**, so a keypress during a cast is held rather than refused;
it is governed by `NP_QueueCastTimeSpells`, `NP_QueueInstantSpells`, `NP_QueueTargetingSpells`,
the buffer rows and the quickcast pair. That half is not a binding: it changes how every key in the
game feels, it is the one piece whose bugs are double-casts and dropped casts, and it is not
something to switch on as a side effect of porting three function names. **Those twenty CVars stay
stored-only**, each row saying so. If grom wants press-interception, it is its own piece of work
with its own live test — and the machinery it would sit on is now all here.
Two of the twenty-two went **live**, because they are the explicit queue's own knobs:
`NP_SpellQueueWindowMs` (how long past the in-flight cast's end an entry is held before it gives
up) and `NP_QueueSpellsOnCooldown` (whether an entry may wait out the spell's own cooldown).
**Three slots, because upstream has three and its own event says so.** `SPELL_QUEUE_EVENT`'s first
argument is `0`/`1` on-swing queued/popped, `2`/`3` normal, `4`/`5` non-GCD — so the queue is keyed
by what *kind* of cast a spell is, and the kind comes off its own `Spell.dbc` row through the same
three tests the send path makes. Each slot pops on its own condition: non-GCD and on-swing as soon
as no cast guards (neither uses the global cooldown), normal when the GCD has run out too.
Three decisions at the queue:
- **A replacement raises no popped event.** A second queue of one kind replaces the first — which
is upstream's own behaviour, its log line being `Replacing queued nonGcd spell X with Y` — and the
replaced entry was never sent. A consumer counting pops against queues would otherwise see one
too many.
- **An expiry raises nothing either.** `*_POPPED` means "this spell was sent"; a timed-out entry
was not, and upstream has no code for one.
- **The expiry is measured from the end of the cast in flight**, not from the moment of queueing,
so a player who queues early during a three-second cast is not punished for it. A lapsed
deadline cannot shorten the window below a fresh queue's.
**`DisenchantAll` is specified completely by the DLL's own strings**, which is why it is built the
way it is rather than designed here. `0x93087`–`0x932d0` carries the argument note
(`includeSoulbound: optional number - pass 1 to include soulbound items (defaults to 0)`), the
precondition (`You do not have the Disenchant spell`), the three quality keywords (`greens`,
`blues`, `purples`), every stop reason, and the three chat lines — one of them as a Lua chunk that
builds the item link with `GetContainerItemLink`.
**"move during cast to cancel" is the abort, and it needed nothing new.** Moving already cancels a
cast here (`inflight.rs`'s `local_self_cancel`), the cast then fails, and a failed cast stops the
run — so the one piece of machinery a sequencer usually needs is the client's own, and the player's
way out is the one upstream documents.
**How a step's outcome is read: the item is gone, or it is not.** There is no "disenchant
succeeded" packet; what happens is the item leaves the bag. Each step remembers the guid it cast at
and waits for it to vanish, with a ten-second deadline. Gone is success and the run continues;
still there past the deadline is a failure and the run stops with upstream's own line. An
observable test with no new wire plumbing — right for the same reason the cast bar's own edges are,
that the thing the player sees is the thing the client keys on.
### The whole registered list, accounted for
With the registration table read (the section above), the surface is no longer a count of what a
@@ -890,15 +939,12 @@ comm -23 /tmp/np /tmp/ours
| Bucket | Count | Names |
|---|---|---|
| **Built** | 58 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetRaidTargets`, `GetSpellPower`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetUnitData`, `ImportFile`, `ReadCustomFile`, `StartItemExport`, `WriteCustomFile`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `LearnTalentRank`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `UseItemIdOrName`, `UseTrinket` |
| **Built** | **62** | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`, `CustomFileExists`, `DisenchantAll`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetCastInfo`, `GetCurrentCastingInfo`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetRaidTargets`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellPower`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitData`, `GetUnitField`, `GetUnitGUID`, `ImportFile`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `LearnTalentRank`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `QueueScript`, `QueueSpellByName`, `ReadCustomFile`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `StartItemExport`, `UseItemIdOrName`, `UseTrinket`, `WriteCustomFile` |
| **The queue, and the one verb that needs it** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `DisenchantAll` |
58 + 4 = 62, and the `comm -23` above is the check: run it and the four it prints are exactly the
last row — `DisenchantAll` and the queue trio.
The two that were at the head of that last bucket are now built — see "The cast feed" above for
why they came first and what each decision cost.
**62 of 62.** The `comm -23` above is the check, and it now prints nothing. What remains unbuilt is
not a *name* — it is upstream's press-interception half of the queue, which has no Lua surface of
its own and whose twenty CVars say at each row that they are stored-only and why.
### The event table, accounted for