diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index f712d485..9b22b757 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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", diff --git a/crates/benilla-app/src/spell/disenchant.rs b/crates/benilla-app/src/spell/disenchant.rs new file mode 100644 index 00000000..3688616d --- /dev/null +++ b/crates/benilla-app/src/spell/disenchant.rs @@ -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), +} + +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 { + if let Ok(id) = text.trim().parse::() { + return (id != 0).then_some(Filter::ItemId(id)); + } + let parts: Vec<&str> = text.split('|').map(str::trim).collect(); + let qualities: Option> = 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, + 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.")); + } +} diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index dfeed35b..adb3d03c 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -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 { + 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 diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index a8c67c4f..c7b9edad 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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::() .init_resource::() .init_resource::() + // nampower's explicit queue, and the two CVar rows that govern it. + .init_resource::() + .init_resource::() + .add_observer(queue::on_cvar) .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/spell/queue.rs b/crates/benilla-app/src/spell/queue.rs new file mode 100644 index 00000000..f0855eef --- /dev/null +++ b/crates/benilla-app/src/spell/queue.rs @@ -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, + non_gcd: Option, + normal: Option, + scripts: Vec