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:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user