`cvars.rs` announces a `CvarChanged` only when a value *moves*, and `config.toml`
holds only the rows that left their defaults ("CVar values that moved off their
defaults" — its own header). So a row the player never touched never reaches an
`on_cvar` observer, and the resource's own `Default` is what the client runs with
for the whole session.
`PressRules` and `SpellQueue` both derived `Default`. Every toggle was false, every
window 0 and every buffer 0, so the press-interception queue held nothing, the mount
refusal never fired, and `SpellQueue`'s zero window expired each explicit entry on
the frame it was queued — the day after all of it was committed. Fourteen of the
sixteen rows have a non-zero registered default.
grom's own `WoWCombatLog.txt` is what named it, with no live run to ask for: 147
`Sunder Armor: Not yet recovered` against 163 landed Sunders, in bursts 0.17-0.23 s
apart — every press refused, none held. Sunder Armor rank 1 on this realm is
`rec=0 cat=0 catrec=0 gcdcat=133 gcd=1500`, read off its own `Spell.dbc`: a global
cooldown and nothing else, which is precisely the case the queue must hold.
Both now hand-write `Default` with the registered values, which is the pattern
`cvars.rs::registered_defaults_mirror_the_code_truths` already welds for the engine
knobs; `np_press.rs::the_default_is_the_registry` welds these sixteen the same way.
The test-only `defaults_for_tests()` constructors are gone — they spelled out the
registered numbers for tests to read while the client could not reach them, which is
the whole shape of the bug. A sweep of the other nine CVar-cached resources (list
every `fn on_cvar`, take its `ResMut` types, check for a hand-written `Default`, look
up each CVar it names in `REGISTERED`) found no further victims.
Two things so the next session can speak for itself:
- `a_press_made_during_the_global_cooldown_is_held_and_sent_when_it_ends` — the
end-to-end cycle with the GCD as the only guard, which is what a class with no cast
bar presses into all day. The existing end-to-end test arms `PendingCast` by hand
and so never starts a GCD at all; this one presses through the real ladder, so the
commit arms it the way a cast does.
- one `info!` a minute from the drain, and only when something moved: held, sent,
dropped, retried, fell-through, channels cut — plus the live windows and buffers.
Every other line this half writes is `debug!` and the default filter is INFO, so a
67-minute session could not say one word about the queue. A line of zeroed knobs
now diagnoses this class of fault by itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
743 lines
31 KiB
Rust
743 lines
31 KiB
Rust
//! 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,
|
|
/// Where the window is measured from: the latest guard end this entry has been told about.
|
|
///
|
|
/// **It ratchets forward**, in [`SpellQueue::expire`], while something is still guarding.
|
|
/// A fixed `expires` stamped at queue time looks right and has a hole in it: a cast pushed
|
|
/// back twice (`SMSG_SPELL_DELAYED`) ends after the window a press made at its start was
|
|
/// given, so the entry would time out while the thing it is waiting for is still running and
|
|
/// the press would vanish with no packet and no error. The same hole swallows a press held
|
|
/// through a cast whose global cooldown then outlasts the window.
|
|
base: Instant,
|
|
/// How long past [`Self::base`] this entry survives: one of nampower's four windows, picked
|
|
/// by what guarded the press (`np_press.rs`'s `window_for`).
|
|
window: Duration,
|
|
/// The earliest [`SpellQueue::pop`] will take it. The moment it was queued for an ordinary
|
|
/// entry, which is to say no constraint at all; later only for a retry
|
|
/// (`NP_RetryServerRejectedSpells`), where the point is to not re-send inside the same
|
|
/// millisecond the server just refused.
|
|
not_before: Instant,
|
|
}
|
|
|
|
impl Slot {
|
|
/// Whether the window has run out, `base` having already been brought up to date.
|
|
fn expired(&self, now: Instant) -> bool {
|
|
now >= self.base + self.window
|
|
}
|
|
}
|
|
|
|
/// One queued script, with the priority that orders it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Script {
|
|
priority: i64,
|
|
chunk: String,
|
|
expires: Instant,
|
|
}
|
|
|
|
/// What the press queue did in the reporting period, for the one line it prints at `info`.
|
|
///
|
|
/// **Every other line this half of the client writes is `debug!`, which the default filter drops**
|
|
/// — so without this a whole session says nothing about whether a press was held, popped, dropped
|
|
/// or refused, and the only evidence left is the player's own combat log. The period is long and
|
|
/// the line is skipped when nothing moved, so an idle session stays quiet.
|
|
#[derive(Default)]
|
|
pub(crate) struct Tally {
|
|
/// Presses held by [`super::np_press::PressRules::admit`].
|
|
pub(crate) held: u32,
|
|
/// Held presses that reached the wire.
|
|
pub(crate) sent: u32,
|
|
/// Held presses that gave up: the window ran out, or the spell was cold with
|
|
/// `NP_QueueSpellsOnCooldown` off. The failure mode a player cannot describe.
|
|
pub(crate) dropped: u32,
|
|
/// Casts re-sent after a server refusal (`NP_RetryServerRejectedSpells`).
|
|
pub(crate) retried: u32,
|
|
/// Presses the rules declined to hold while something still guarded — each one is an error
|
|
/// line the player read. A high count beside a low `held` is the queue not doing its job.
|
|
pub(crate) fell_through: u32,
|
|
/// Presses that ended a channel instead (`NP_InterruptChannelsOutsideQueueWindow`).
|
|
pub(crate) interrupted: u32,
|
|
/// When the period began; `None` until the first event.
|
|
since: Option<Instant>,
|
|
}
|
|
|
|
impl Tally {
|
|
fn moved(&self) -> bool {
|
|
self.held | self.sent | self.dropped | self.retried | self.fell_through | self.interrupted
|
|
!= 0
|
|
}
|
|
}
|
|
|
|
/// 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)]
|
|
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. [`Default`] carries the registered 500, because a row
|
|
/// the player never moved never reaches [`on_cvar`] — see [`super::np_press::PressRules`].
|
|
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,
|
|
/// The spell a server refusal has already been retried for. See [`Self::queue_retry`].
|
|
retried: Option<u32>,
|
|
/// The period's counters, reported by [`Self::take_report`].
|
|
tally: Tally,
|
|
}
|
|
|
|
/// `NP_SpellQueueWindowMs` 500 and `NP_QueueSpellsOnCooldown` 1 — the values `cvars.rs` registers,
|
|
/// which is what a player who touched neither row runs. A derived `Default` gave a window of 0,
|
|
/// and an entry whose window is 0 expires on the frame it was queued.
|
|
impl Default for SpellQueue {
|
|
fn default() -> Self {
|
|
Self {
|
|
on_swing: None,
|
|
non_gcd: None,
|
|
normal: None,
|
|
scripts: Vec::new(),
|
|
events: Vec::new(),
|
|
window_ms: 500,
|
|
allow_on_cooldown: true,
|
|
stop_channel: false,
|
|
retried: None,
|
|
tally: Tally::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
self.queue_with_window(spell_id, kind, now, in_flight_until, self.window_ms);
|
|
}
|
|
|
|
/// [`Self::queue`] with the window named by the caller, for the press-interception half: a
|
|
/// held **press** gives up on one of four windows picked by what guarded it
|
|
/// (`np_press.rs`'s `window_for`), where an explicit `QueueSpellByName` has only ever had the
|
|
/// one. Both land in the same slot and raise the same event, because upstream has one queue.
|
|
pub(crate) fn queue_with_window(
|
|
&mut self,
|
|
spell_id: u32,
|
|
kind: Kind,
|
|
now: Instant,
|
|
in_flight_until: Option<Instant>,
|
|
window_ms: u32,
|
|
) {
|
|
let slot = Slot {
|
|
spell_id,
|
|
base: in_flight_until.unwrap_or(now).max(now),
|
|
window: Duration::from_millis(u64::from(window_ms)),
|
|
not_before: now,
|
|
};
|
|
*self.slot_mut(kind) = Some(slot);
|
|
self.events.push((kind.queued_code(), spell_id));
|
|
}
|
|
|
|
/// Put `spell_id` back after the **server** refused it for a timing reason
|
|
/// (`NP_RetryServerRejectedSpells`), held `delay_ms` before it may be sent again.
|
|
///
|
|
/// **Once per spell, and that bound is the whole design.** An unbounded retry of a refusal
|
|
/// that keeps arriving is a packet a second forever, which is worse than the dropped cast it
|
|
/// was meant to save — upstream's own `Retry cast after cancel still failed` says it gives up
|
|
/// too. [`Self::clear_retry`] forgets the memo on any cast the server accepts, so a second
|
|
/// rejection later in the fight is retried again; what cannot happen is two retries of one
|
|
/// press.
|
|
///
|
|
/// Returns whether it was queued, for the caller's log line.
|
|
pub(crate) fn queue_retry(
|
|
&mut self,
|
|
spell_id: u32,
|
|
kind: Kind,
|
|
now: Instant,
|
|
delay_ms: u32,
|
|
window_ms: u32,
|
|
) -> bool {
|
|
if self.retried == Some(spell_id) {
|
|
return false;
|
|
}
|
|
self.retried = Some(spell_id);
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.retried += 1;
|
|
let not_before = now + Duration::from_millis(u64::from(delay_ms));
|
|
*self.slot_mut(kind) = Some(Slot {
|
|
spell_id,
|
|
base: not_before,
|
|
window: Duration::from_millis(u64::from(window_ms)),
|
|
not_before,
|
|
});
|
|
self.events.push((kind.queued_code(), spell_id));
|
|
true
|
|
}
|
|
|
|
/// The counter pokes. Each is one line at its site, so a new path that holds or drops a press
|
|
/// cannot forget to be countable — see [`Tally`] for why counting matters at all.
|
|
pub(crate) fn note_held(&mut self, now: Instant) {
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.held += 1;
|
|
}
|
|
|
|
pub(crate) fn note_fell_through(&mut self, now: Instant) {
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.fell_through += 1;
|
|
}
|
|
|
|
pub(crate) fn note_interrupted(&mut self, now: Instant) {
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.interrupted += 1;
|
|
}
|
|
|
|
/// The period's line, or `None` while it is too young or empty. Resets on the way out.
|
|
///
|
|
/// 60 s, because the question it answers ("is the queue working at all") does not need a
|
|
/// finer grain, and a line a minute is a size a session log can carry.
|
|
pub(crate) fn take_report(&mut self, now: Instant) -> Option<Tally> {
|
|
let since = self.tally.since?;
|
|
if !self.tally.moved() || now.duration_since(since) < Duration::from_secs(60) {
|
|
return None;
|
|
}
|
|
Some(std::mem::take(&mut self.tally))
|
|
}
|
|
|
|
/// Forget the retry memo: a cast the server accepted means the next refusal is a new one.
|
|
pub(crate) fn clear_retry(&mut self) {
|
|
self.retried = None;
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// `now` is read only to honour a retry's hold ([`Self::queue_retry`]); an ordinary entry is
|
|
/// never held, so this is the same unconditional take it always was for one.
|
|
pub(crate) fn pop(&mut self, kind: Kind, now: Instant) -> Option<u32> {
|
|
if !self.ready(kind, now) {
|
|
return None;
|
|
}
|
|
let spell_id = self.slot_mut(kind).take()?.spell_id;
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.sent += 1;
|
|
self.events.push((kind.popped_code(), spell_id));
|
|
Some(spell_id)
|
|
}
|
|
|
|
/// Take `kind`'s slot and count it as given up rather than sent — the queue's own decision to
|
|
/// abandon an entry, as against [`Self::expire`]'s window running out. The `*_POPPED` row still
|
|
/// goes out, because an addon tracking the slot has to see it empty either way.
|
|
pub(crate) fn discard(&mut self, kind: Kind, now: Instant) -> Option<u32> {
|
|
let spell_id = self.slot_mut(kind).take()?.spell_id;
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.dropped += 1;
|
|
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> {
|
|
self.slot(kind).map(|s| s.spell_id)
|
|
}
|
|
|
|
/// Whether `kind`'s slot holds something [`Self::pop`] would take now.
|
|
pub(crate) fn ready(&self, kind: Kind, now: Instant) -> bool {
|
|
self.slot(kind).is_some_and(|s| now >= s.not_before)
|
|
}
|
|
|
|
fn slot(&self, kind: Kind) -> Option<Slot> {
|
|
match kind {
|
|
Kind::OnSwing => self.on_swing,
|
|
Kind::NonGcd => self.non_gcd,
|
|
Kind::Normal => self.normal,
|
|
}
|
|
}
|
|
|
|
/// 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, guard_until: Option<Instant>) {
|
|
for kind in [Kind::OnSwing, Kind::NonGcd, Kind::Normal] {
|
|
let Some(slot) = self.slot_mut(kind) else {
|
|
continue;
|
|
};
|
|
// Ratchet the base forward while something still guards the press: the window is
|
|
// "this long past the guard's end", and the guard's end can move — pushback stretches
|
|
// a cast, and a global cooldown outlives the cast that armed it. See [`Slot::base`].
|
|
if let Some(guard) = guard_until {
|
|
slot.base = slot.base.max(guard);
|
|
}
|
|
if slot.expired(now) {
|
|
let dropped = self.slot_mut(kind).take();
|
|
if let Some(s) = dropped {
|
|
self.tally.since.get_or_insert(now);
|
|
self.tally.dropped += 1;
|
|
debug!(
|
|
"spell-queue: dropping {:?} spell {} — nothing popped it inside the window",
|
|
kind, s.spell_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// The scripts keep a fixed deadline: their contract is "after queued spells", and a
|
|
// script whose spells never fired has nothing left to run after.
|
|
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 period line counts what happened and holds its tongue until the period is up — and
|
|
/// `Default` is the registry, which is the bug this file shipped with.
|
|
#[test]
|
|
fn the_tally_reports_once_a_period_and_only_when_something_moved() {
|
|
let mut q = SpellQueue::default();
|
|
assert_eq!(q.window_ms, 500, "the registered NP_SpellQueueWindowMs");
|
|
assert!(
|
|
q.allow_on_cooldown,
|
|
"the registered NP_QueueSpellsOnCooldown"
|
|
);
|
|
|
|
let t0 = Instant::now();
|
|
assert!(q.take_report(t0).is_none(), "nothing has happened yet");
|
|
q.note_held(t0);
|
|
q.note_fell_through(t0);
|
|
assert!(
|
|
q.take_report(t0).is_none(),
|
|
"the period is a minute long: two events in the first millisecond print nothing"
|
|
);
|
|
let later = t0 + Duration::from_secs(61);
|
|
let r = q.take_report(later).expect("the period is up and it moved");
|
|
assert_eq!((r.held, r.fell_through), (1, 1));
|
|
assert!(
|
|
q.take_report(later + Duration::from_secs(61)).is_none(),
|
|
"the counters reset, so a quiet minute stays out of the log"
|
|
);
|
|
}
|
|
|
|
/// 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, t0), 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, t0), 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), None);
|
|
assert_eq!(q.peek(Kind::Normal), Some(133), "inside the window");
|
|
q.expire(t0 + Duration::from_millis(500), None);
|
|
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), None);
|
|
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), None);
|
|
assert_eq!(q.peek(Kind::Normal), Some(133));
|
|
q.expire(cast_ends + Duration::from_millis(500), None);
|
|
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), None);
|
|
assert_eq!(q.peek(Kind::Normal), Some(133));
|
|
}
|
|
|
|
/// The window is measured from the **live** guard end, not the one stamped at queue time.
|
|
///
|
|
/// A fixed deadline has a hole in it: a cast pushed back twice ends after the window a press
|
|
/// made at its start was given, so the entry would time out while the thing it is waiting for
|
|
/// is still running, and the press would vanish with no packet and no error line. The same
|
|
/// hole swallows a press held through a cast whose global cooldown then outlasts the window.
|
|
#[test]
|
|
fn a_held_entry_cannot_time_out_while_something_still_guards_it() {
|
|
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));
|
|
|
|
// Two pushbacks of 500 ms each: the cast now ends at t0 + 4 s, well past the entry's
|
|
// original t0 + 3.5 s deadline.
|
|
let pushed = cast_ends + Duration::from_secs(1);
|
|
q.expire(t0 + Duration::from_millis(3_600), Some(pushed));
|
|
assert_eq!(
|
|
q.peek(Kind::Normal),
|
|
Some(133),
|
|
"past where the window would have ended, but the cast is still running"
|
|
);
|
|
|
|
// And once it really ends, the window runs from there.
|
|
q.expire(pushed + Duration::from_millis(499), None);
|
|
assert_eq!(q.peek(Kind::Normal), Some(133));
|
|
q.expire(pushed + Duration::from_millis(500), None);
|
|
assert_eq!(q.peek(Kind::Normal), None, "500 ms past the real end");
|
|
|
|
// The base only ever moves forward: a guard that reads *earlier* than the one already
|
|
// recorded cannot shorten an entry's life.
|
|
q.queue(133, Kind::Normal, t0, Some(cast_ends));
|
|
q.expire(t0, Some(t0));
|
|
q.expire(cast_ends + Duration::from_millis(499), None);
|
|
assert_eq!(
|
|
q.peek(Kind::Normal),
|
|
Some(133),
|
|
"a shorter guard end does not pull the deadline in"
|
|
);
|
|
}
|
|
|
|
/// `NP_RetryServerRejectedSpells`: a refused cast goes back in its slot, held for the delay,
|
|
/// and **once** — a retry that could itself be retried is a packet a second forever.
|
|
#[test]
|
|
fn a_retry_is_held_for_its_delay_and_happens_once() {
|
|
let t0 = Instant::now();
|
|
let mut q = SpellQueue {
|
|
window_ms: 500,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(q.queue_retry(133, Kind::Normal, t0, 55, 500));
|
|
assert_eq!(q.peek(Kind::Normal), Some(133));
|
|
assert!(
|
|
!q.ready(Kind::Normal, t0 + Duration::from_millis(40)),
|
|
"inside the delay the entry is there but not sendable — re-sending inside the \
|
|
millisecond the server refused it is the one thing a retry must not do"
|
|
);
|
|
assert_eq!(q.pop(Kind::Normal, t0 + Duration::from_millis(40)), None);
|
|
assert!(q.ready(Kind::Normal, t0 + Duration::from_millis(55)));
|
|
assert_eq!(
|
|
q.pop(Kind::Normal, t0 + Duration::from_millis(55)),
|
|
Some(133)
|
|
);
|
|
|
|
// The same spell refused again is not retried a second time.
|
|
assert!(
|
|
!q.queue_retry(133, Kind::Normal, t0, 55, 500),
|
|
"once per rejection chain"
|
|
);
|
|
assert_eq!(q.peek(Kind::Normal), None, "and nothing was put back");
|
|
|
|
// A cast the server accepted forgets the memo, so a later rejection is its own.
|
|
q.clear_retry();
|
|
assert!(q.queue_retry(133, Kind::Normal, t0, 55, 500));
|
|
|
|
// An ordinary queue is never held: `ready` is about retries alone.
|
|
let mut plain = SpellQueue {
|
|
window_ms: 500,
|
|
..Default::default()
|
|
};
|
|
plain.queue(133, Kind::Normal, t0, None);
|
|
assert!(plain.ready(Kind::Normal, t0));
|
|
}
|
|
|
|
/// 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), None);
|
|
assert!(q.take_ready_scripts().is_empty());
|
|
}
|
|
}
|