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:
+93
-16
@@ -896,19 +896,16 @@ Three decisions at that mapping:
|
||||
**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.
|
||||
> **Superseded by "The press-interception half, built" below.** The paragraph that stood here said
|
||||
> the press half was deliberately unbuilt and its twenty CVars stored-only. It is built now, on
|
||||
> grom's say-so and with a live test to follow; the slots are still the ones described in this
|
||||
> section, because upstream has one queue.
|
||||
|
||||
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).
|
||||
**Upstream's queue has two halves, and both are built now.** The Lua surface — "remember this spell
|
||||
and send it when the client will accept it" — is `spell/queue.rs`, below. The half that intercepts
|
||||
**every ordinary press** is `spell/np_press.rs`, and the section after this one covers it. They
|
||||
share the three slots and the one `SPELL_QUEUE_EVENT`: upstream has one queue, and a second set of
|
||||
slots beside it would be a different feature wearing the same name.
|
||||
|
||||
**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
|
||||
@@ -966,9 +963,88 @@ comm -23 /tmp/np /tmp/ours
|
||||
| **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` |
|
||||
|
||||
|
||||
**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.
|
||||
**62 of 62.** The `comm -23` above is the check, and it prints nothing.
|
||||
|
||||
`CastSpellNoQueue` and `CastSpellByNameNoQueue` became meaningful with the press half: they take
|
||||
the ladder entry that skips the queue (`CastLadder::send_now`), so a macro that has already decided
|
||||
*when* to cast is not held by the client deciding to cast later instead. Before the press half they
|
||||
were the plain cast, which was the honest mapping at the time and is no longer the whole story.
|
||||
|
||||
### The press-interception half, built
|
||||
|
||||
`spell/np_press.rs`. A press that the client would refuse for a **timing** reason is held in the
|
||||
same slot an explicit `QueueSpellByName` would use, and the drain sends it the moment the guard
|
||||
clears. No packet, no global cooldown, no error line while it waits.
|
||||
|
||||
**Where the rung sits, and the one deviation that comes of it.** 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, and holding them
|
||||
would change the meaning. Everything below it is a real refusal the player should read: no reagent,
|
||||
no weapon, out of range, no power, stunned, moving, wrong form. Upstream hooks the client's cast
|
||||
entry and so holds presses that would fail for those reasons too, which means its error arrives
|
||||
when the entry pops, a second later, about something the player has stopped doing. Refusing them
|
||||
immediately is the deviation, and it is the better half of the trade.
|
||||
|
||||
**What a window measures.** Upstream publishes four and no sentence saying. Two readings fit the
|
||||
names: how long an entry survives, or how close to the guard's end a press must land. This takes
|
||||
the first, for three reasons — `spell/queue.rs` already reads `NP_SpellQueueWindowMs` that way, and
|
||||
one number meaning two things in one client is a trap nobody finds twice; upstream's own `Replacing
|
||||
queued nonGcd spell X with Y` means the last press wins, so holding every press costs nothing in
|
||||
staleness; and the admission reading would make the 250 ms cooldown window refuse almost every
|
||||
press made during a global cooldown, which is the exact complaint nampower exists to answer.
|
||||
|
||||
Two rows do read a window as a threshold on remaining time, and both say so themselves:
|
||||
`NP_InterruptChannelsOutsideQueueWindow`, whose name states one, and `NP_CooldownQueueWindowMs`,
|
||||
which has to be one. A cast or a global cooldown ends in a second or two; a spell's own cooldown can
|
||||
be half an hour. Holding that press means either firing it half an hour later or swallowing it with
|
||||
no error line, and a Hearthstone pressed on cooldown has to still say "Item is not ready yet" — so
|
||||
a quarter of a second, which is what the row defaults to, is an allowance for pressing a touch early
|
||||
rather than a queue.
|
||||
|
||||
**The guard reopens early, and that is the headline.** `PendingCast` now keeps two timestamps where
|
||||
it kept one. 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 `SMSG_SPELL_GO`
|
||||
or `SMSG_CAST_RESULT` arriving a round trip later is still recognised as the answer to that cast.
|
||||
Splitting them 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 about half a trip after the send; the server starts its cast then and
|
||||
finishes half a trip after our own cast time elapses. A next cast sent at exactly `cast_time`
|
||||
therefore arrives just as the server frees up, and the latency cancels out of the arithmetic
|
||||
entirely. The buffer is jitter margin on top. `NP_NonGcdBufferTimeMs` is wider (100 ms) because a
|
||||
non-GCD spell has nothing *but* the guard between two presses.
|
||||
|
||||
**`NP_RetryServerRejectedSpells` is the mitigation for that early guard**, and that is why upstream
|
||||
defaults it on. If the margin is ever wrong — jitter, or a server cast time that differs from ours —
|
||||
the server refuses the next cast and without a retry the press is simply gone. It goes back in its
|
||||
slot **once**, held a buffer's worth, on exactly two reason codes: `0x3c` not-ready and `0x61`
|
||||
another-action, the two an early guard can cause. Every other refusal is the server telling the
|
||||
player something true, and re-sending one would be a packet a second that never succeeds. Upstream's
|
||||
own set is not published and its log line (`" spell queued + ignored code "`) hints at a wider one;
|
||||
a narrower set is the safe direction to be wrong in.
|
||||
|
||||
| Row | State | Note |
|
||||
|---|---|---|
|
||||
| `NP_QueueCastTimeSpells` | **LIVE** | Holds a press of a spell with a cast bar. Also the addon's presence probe |
|
||||
| `NP_QueueInstantSpells` | **LIVE** | The same for an instant |
|
||||
| `NP_QueueChannelingSpells` | **LIVE** | Whether a press made while a channel runs is held |
|
||||
| `NP_QueueTargetingSpells` | **LIVE** | The terrain-cursor class, named by `cast_target::wants_terrain_click` — implicit target 16, or a location word with no unit bit. Deliberately *not* the item/locked/GameObject cursors, which want a click on a thing rather than a patch of ground |
|
||||
| `NP_QueueOnSwingSpells` | **LIVE** | Default 0, as upstream's is "changed with 1.17.2 due to changes to on swing spells" |
|
||||
| `NP_SpellQueueWindowMs` / `ChannelQueueWindowMs` / `CooldownQueueWindowMs` / `TargetingQueueWindowMs` | **LIVE** | See "What a window measures" above. The general one covers both a cast in flight and a global cooldown, which is what its unqualified name can mean |
|
||||
| `NP_MinBufferTimeMs`, `NP_NonGcdBufferTimeMs` | **LIVE** | The guard's reopening margin |
|
||||
| `NP_InterruptChannelsOutsideQueueWindow` | **LIVE** | A press while a channel has more than `NP_ChannelQueueWindowMs` left ends the channel instead of being held. Off by default |
|
||||
| `NP_ReplaceMatchingNonGcdCategory` | **LIVE** | On, a non-GCD press takes a held slot only when the two share a `Spell.dbc` category — and a category of 0 means "no shared cooldown", so two of those do not match. Off by default, which is the plain always-replace |
|
||||
| `NP_RetryServerRejectedSpells` | **LIVE** | Above |
|
||||
| `NP_MaxBufferIncreaseMs`, `NP_OptimizeBufferUsingPacketTimings` | stored | Upstream's *dynamic* tuning of the buffer from observed packet timings. **Off in its own defaults**, so nothing changes by not having it; building it means a model of this realm's jitter, which is measurement rather than a binding |
|
||||
| `NP_OnSwingBufferCooldownMs` | stored | A rate limit on re-queueing an on-swing strike. The slot is live; what is missing is anything to rate-limit, since a re-press of the queued strike already takes the reference's own silent bail (`6e4d43`) and so cannot queue twice |
|
||||
| `NP_ChannelLatencyReductionPercentage` | stored | Trims a percentage of latency off a channel's end. The channel's true end is now kept apart from its slack (`ActiveChannel::ends_at`), so the input exists; what it needs is the measured latency in the cast path, which lives behind `PingShared` and is read only by the net-stats feed today |
|
||||
|
||||
**One behaviour worth knowing before testing.** Pressing a spell during a long cast holds it for the
|
||||
whole cast and fires it at the end — the last press wins, so pressing three different spells during
|
||||
a Pyroblast fires the third. That is upstream's `Replacing queued nonGcd spell X with Y` applied to
|
||||
presses, and it is what "queue the next spell" means in practice. What it is *not* is a queue of
|
||||
several casts in a row.
|
||||
|
||||
### The event table, accounted for
|
||||
|
||||
@@ -1049,7 +1125,8 @@ reason is the same mistake the function list started from: the first pass took t
|
||||
| `NP_ChatBubblesWhisper` / `Raid` / `Battleground` | **LIVE** | Three kinds the reference bubbles never. `Raid` is the raid-**leader** and **warning** kinds, which is upstream's own wording — reading it as "raid chat" would bubble every `/raid` line |
|
||||
| `NP_EnableLocalSetRaidTarget` | **LIVE** | Solo, the wire leg marks nothing at all: the server answers by echoing to a group that is not there. This switch routes it to the local board instead — the path SuperWoW's `"local"` argument already built |
|
||||
| `NP_EnableSpellEnergizeEvents` | **LIVE** | The seventh `NP_Enable*`; nothing installed writes it, which is why it was missed |
|
||||
| `NP_SpamProtectionEnabled`, `NP_QuickcastOnDoubleCast` | stored | The press-interception half of the queue, which is a separate decision |
|
||||
| `NP_SpamProtectionEnabled` | **LIVE** | A repeat press of the cast already in flight is swallowed rather than held — which is also the reference's own silent bail (`6e4d43`), so the two agree. Off, such a press may replace itself in its slot |
|
||||
| `NP_QuickcastOnDoubleCast`, `NP_QuickcastTargetingSpells`, `NP_DoubleCastToEndChannelEarly` | stored | The double-press family, **all three off in upstream's own defaults**. Each needs the world point under the mouse *at press time*, which this client resolves in the click path (`spell/targeting/world.rs`) and not in the cast path, so wiring them means handing the ladder a camera ray — a real change whose feel wants its own test |
|
||||
| `NP_PreventRightClickPvPAttack` | stored | Needs a hook in the right-click interact path, which decides target, interact and attack in one pass; its sibling `NP_PreventRightClickTargetChange` is stored for the same reason |
|
||||
| `NP_PreventMountingWhenBuffCapped` | **live** | A mount press with all 32 positive aura slots taken is refused above the commit, so neither the packet nor the global cooldown is spent — `spell/np_press.rs`'s `mount_refusal`. The aura number (`SPELL_AURA_MOUNTED` = 78) is asserted against this realm's own `Spell.dbc` for three mount spells rather than taken from mangos' header, because a wrong one would make the refusal silently never fire. **One deliberate deviation, and upstream names the bug itself**: its settings label reads "Preventing mounting due to buff cap (breaks dismount)" — in 1.12 you dismount by re-casting the mount — so a press while `UNIT_FIELD_MOUNTDISPLAYID` is live is exempt here. Refused **silently**, as upstream's own report is a line in its debug log and 1.12's `SpellCastResult` has no code for "too many auras" |
|
||||
| `NP_EnableEnhancedTooltips` | stored | Authored content rather than a binding: the numbers are in `Spell.dbc` columns this client reads, but what the lines say and where they sit is upstream's design, and guessing it produces a tooltip that is neither ours nor theirs |
|
||||
|
||||
Reference in New Issue
Block a user