app+ui: nampower's press-interception queue — the half that changes how keys feel

The half of upstream's queue that `queue.rs` has said for three rounds it
deliberately left out. A press the client would refuse for a **timing**
reason is now held in the same slot an explicit `QueueSpellByName` uses,
and the drain sends it the moment the guard clears: no packet, no global
cooldown, no error line while it waits. One queue, because upstream has
one — a second set of slots beside the first would be a different feature
wearing the same name.

**The guard reopens early, and that is the headline.** `PendingCast` kept
one timestamp and now keeps two. The guard reopens at the cast's own
already-known length plus `NP_MinBufferTimeMs`; the record still lives for
the cast plus two seconds, so a resolution arriving a round trip later is
still recognised as the answer to that cast. That split is not tidiness —
the `modalNextSpell` chain that starts a hunter's Auto Shot reads the
record, and a record that died with the guard would break shooting.

The buffer is not a latency figure, which is why upstream's default is
55 ms and not a ping: our packet reaches the server half a trip after the
send, the server finishes its cast half a trip after our own cast time
elapses, so a next cast sent at exactly `cast_time` arrives just as the
server frees up and the latency cancels out of the arithmetic. The buffer
is jitter margin on top. `NP_NonGcdBufferTimeMs` is wider because a
non-GCD spell has nothing *but* the guard between two presses.

**`NP_RetryServerRejectedSpells` is the mitigation for that**, and that is
why upstream defaults it on. If the margin is ever wrong the server refuses
the next cast and the press is simply gone — the dropped cast the early
guard buys its speed with. It goes back once, held a buffer's worth, on
exactly two codes: 0x3c not-ready and 0x61 another-action. Every other
refusal is the server telling the player something true, and re-sending one
would be a packet a second that never succeeds.

**What a window measures, and how that was decided.** Upstream publishes
four and no sentence saying. Two readings fit: how long an entry survives,
or how close to the guard's end a press must land. This takes the first,
because `queue.rs` already reads `NP_SpellQueueWindowMs` that way and one
number meaning two things in one client is a trap nobody finds twice;
because `Replacing queued nonGcd spell X with Y` means the last press wins,
so holding every press costs nothing in staleness; and because the other
reading would make the 250 ms cooldown window refuse almost every press
made during a global cooldown, which is the complaint nampower exists to
answer.

Two rows do read a window as remaining time, and both say so themselves.
`NP_InterruptChannelsOutsideQueueWindow`'s name states one. And
`NP_CooldownQueueWindowMs` has to be one: a cast ends in a second, a
Hearthstone cooldown is half an hour, and holding that press means either
firing it half an hour later or swallowing it with no error line. A quarter
of a second is an allowance for pressing a touch early, not a queue.

**Where the rung sits.** Five rungs stay above it — the profession opener,
the auto-repeat re-press, the dead caster, the targeting cursor's clear and
the on-swing re-press — because each is what the press *means* whatever is
in flight. Everything below is a real refusal a player should read: no
reagent, out of range, no power, stunned, moving, wrong form. Upstream
hooks the cast entry and holds those too, so its error arrives a second
later about something the player stopped doing. Refusing them now is the
deviation and the better half of the trade.

Fourteen CVar rows went live. What stays stored is named with its cost: the
dynamic buffer optimizer and its ceiling (off in upstream's own defaults),
`NP_OnSwingBufferCooldownMs` (nothing to rate-limit — a re-press of the
queued strike already takes the reference's silent bail), the channel
latency trim, and the three quickcast rows, all off by default and all
needing the world point under the mouse in the cast path.

`CastSpellNoQueue`/`CastSpellByNameNoQueue` became meaningful: they take
`CastLadder::send_now`, which skips the rung. The queue's own pop takes it
too — a pop that could be re-held is a loop that never casts anything.

The test worlds now seed the **registered** defaults rather than
`Default`'s all-zeroes, which is a configuration nobody runs: three ladder
tests were passing only because the queue was off in them. Each now asserts
the queue behaviour and keeps the reference's 0x61 in a world where a
player unticked the queue. And one test drives the whole cycle through the
real drain — press held, nothing on the wire, guard cleared, one packet out
— because the failure this guards against is a keypress that produced no
packet, no error and no cast, which is the one a player cannot describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 23:46:42 -06:00
co-authored by Claude Opus 5
parent 94c571e3ea
commit a9bb9624e9
16 changed files with 1848 additions and 217 deletions
+23 -15
View File
@@ -1790,11 +1790,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `CastSpellNoQueue(spellId, spellBook [, unit])` (v3.1+) and `CastSpellByNameNoQueue(name
// [, unit])` (v2.0+): cast without entering nampower's spell queue.
//
// **benilla has no spell queue to bypass**, so these are the plain cast — and that is the
// honest mapping rather than a stub, because what the caller asked for ("cast this now, do not
// queue it") is exactly what happens here. `spell/inflight.rs` carries namreeb's always-on
// cast-ready rule, not pepo's queue windows; `docs/ADDON_COMPAT.md` records why the queue is
// unbuilt.
// There is a queue to bypass now (`benilla-app`'s `spell/np_press.rs`), and these take the
// ladder entry that skips it: a press from here is sent or refused on the spot, never held.
// That is the whole reason upstream publishes the pair — a macro that has already decided
// *when* to cast does not want the client deciding to cast later instead.
//
// Unlike stock `CastSpell`, the first argument is a **spell id**, not a book slot — upstream's
// whole reason for the function. The id goes on the same queue `CastSpell` fills, so a cast
@@ -1821,9 +1820,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let spell_id = spell_ident(&model, Some(&first));
if let Some(spell_id) = spell_id.filter(|&id| id != 0) {
if mouseover {
// The mouseover path commits straight at the hovered unit without
// running the ladder at all, so there is no queue there to bypass.
model.mouseover_spell_casts.push(spell_id);
} else {
model.spell_casts.push(spell_id);
model.no_queue_spell_casts.push(spell_id);
}
}
Ok(())
@@ -2408,10 +2409,12 @@ mod tests {
}
/// The `*NoQueue` pair takes a **spell id** where stock `CastSpell` takes a book slot — the
/// whole reason upstream has them — and reaches the same queue a bar press does, since there
/// is no spell queue here to bypass. `"mouseover"` routes to the mouseover queue.
/// whole reason upstream has them — and reaches the **no-queue** drain, so the app sends it
/// through the ladder entry that skips nampower's press queue (`benilla-app`'s
/// `spell/np_press.rs`). `"mouseover"` routes to the mouseover queue, which commits straight
/// at the hovered unit and so has no queue to bypass in the first place.
#[test]
fn the_no_queue_casts_push_a_spell_id_onto_the_ordinary_queue() {
fn the_no_queue_casts_push_a_spell_id_onto_the_no_queue_drain() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(crate::script::SpellBookState {
tabs: Vec::new(),
@@ -2424,30 +2427,35 @@ mod tests {
});
s.run("CastSpellNoQueue(133, 1)").unwrap();
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(s.take_no_queue_spell_casts(), vec![133]);
assert!(
s.take_spell_casts().is_empty(),
"and NOT the ordinary queue: a press the app would be free to hold is exactly what \
this verb exists to avoid"
);
// An id the book does not carry: `spell_ident` answers for ids directly, as upstream's
// "cast by spell ID" says, so this is a send and not a refusal.
s.run("CastSpellNoQueue(4242, 1)").unwrap();
assert_eq!(s.take_spell_casts(), vec![4242]);
assert_eq!(s.take_no_queue_spell_casts(), vec![4242]);
s.run(r#"CastSpellByNameNoQueue("Fireball")"#).unwrap();
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(s.take_no_queue_spell_casts(), vec![133]);
// The unit is third for the id form and second for the name form.
s.run(r#"CastSpellNoQueue(133, 1, "mouseover")"#).unwrap();
assert!(s.take_spell_casts().is_empty());
assert!(s.take_no_queue_spell_casts().is_empty());
assert_eq!(s.take_mouseover_spell_casts(), vec![133]);
s.run(r#"CastSpellByNameNoQueue("Fireball", "mouseover")"#)
.unwrap();
assert!(s.take_spell_casts().is_empty());
assert!(s.take_no_queue_spell_casts().is_empty());
assert_eq!(s.take_mouseover_spell_casts(), vec![133]);
// A name nothing answers for, and no argument at all, are silent.
s.run(r#"CastSpellByNameNoQueue("Nothing At All")"#)
.unwrap();
s.run("CastSpellNoQueue()").unwrap();
assert!(s.take_spell_casts().is_empty());
assert!(s.take_no_queue_spell_casts().is_empty());
}
/// The bag group — `FindPlayerItemSlot`, `GetBagItem`, `GetItemIdCooldown` — shares one
+7
View File
@@ -945,6 +945,12 @@ pub(crate) struct Model {
/// the current selection (`CastSpellByName`'s doc, `script/spellbook.rs`); a separate queue
/// so the stock `spell_casts` path (current-selection target) is untouched.
pub(crate) mouseover_spell_casts: Vec<u32>,
/// nampower's `CastSpellNoQueue` / `CastSpellByNameNoQueue` ids: the same send as
/// [`Self::spell_casts`] with the press queue bypassed, which is upstream's whole reason for
/// the pair. A separate queue rather than a flag per id, because the app's drain reaches the
/// two through different ladder entries (`send` and `send_now`) and a list it has to re-sort
/// is a list it can get wrong.
pub(crate) no_queue_spell_casts: Vec<u32>,
/// `CastSpell(id, "pet")` ids, sent as `CMSG_PET_ACTION` with a type-1 word (`0x4b34ce`).
pub(crate) pet_spell_casts: Vec<u32>,
/// `ToggleSpellAutocast` ids for `CMSG_PET_SPELL_AUTOCAST` (`0x2F3`), which names a spell.
@@ -1988,6 +1994,7 @@ impl Model {
macro_icon_buckets: (Vec::new(), Vec::new()),
macro_bound_spells: std::collections::HashMap::new(),
spell_casts: Vec::new(),
no_queue_spell_casts: Vec::new(),
mouseover_spell_casts: Vec::new(),
pet_spell_casts: Vec::new(),
pet_spell_autocasts: Vec::new(),
@@ -232,6 +232,12 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().spell_casts)
}
/// Drain nampower's `CastSpellNoQueue` / `CastSpellByNameNoQueue` ids: the ordinary send with
/// the press queue bypassed.
pub fn take_no_queue_spell_casts(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().no_queue_spell_casts)
}
/// Drain SuperWoW's `CastSpellByName(name, "CLICK")` ids: cast at `"mouseover"`, not the
/// current selection.
pub fn take_mouseover_spell_casts(&mut self) -> Vec<u32> {