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:
@@ -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",
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user