Files
benilla-octo/crates/benilla/src/cooldowns.rs
T
SamandClaude Fable 5 77dc8f8ead pets get their action bar, macros arrive, and hunters draw the bow
- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles
- the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar
- hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight
- the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:13 +07:00

1038 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The player's cooldown store — a mirror of the client's `SpellHistory` list (decision 0137
//! phase 4). Every law here is the byte-verified mechanism from wow-re `wave-cooldown.md` /
//! `wave-handlers.md` (the SPELLHISTORY node ops `0x6e12c0`/`0x6e13e0`/`0x6e1630`/`0x6e1790`,
//! `StartCooldown 0x6e2c60`, `StartGlobalCooldown 0x6e2de0`, and the SMSG handlers
//! `0x6e9460`/`0x6e95d0`/`0x6e9670`/`0x6e9730`), transcribed onto `Instant`/`Duration`:
//!
//! - A **record** carries three independent timer pairs, exactly the SPELLHISTORY fields: the
//! spell's own recovery, its category's shared recovery, and the global-cooldown pair
//! (`startRecoveryCategory`/`startRecoveryTime`). `on_hold` parks the first two until
//! `SMSG_COOLDOWN_EVENT` starts them (`SPELL_ATTR_COOLDOWN_ON_EVENT` — Stealth, Feign Death).
//! - The **read** ([`Cooldowns::info`], the client's `GetCooldownInfo 0x6e13e0`) resolves a
//! queried spell against all three: nodes matching its id (+ cast item), nodes matching its
//! category, and nodes whose GCD category matches its `startRecoveryCategory` — the mechanism
//! that spreads one cast's GCD onto every other button. The longest remaining wins.
//! - **Who starts what** (byte-VERIFIED, the 2026-07-10 wow-re §5 + follow-up,
//! `action-button-state-api.md` §7 / `wave-handlers.md` ADDENDUM): the GCD starts locally at
//! cast-send (`0x6e58fb`); the spell's own recovery is client-computed from `Spell.dbc` and
//! inserted when **our own `SMSG_SPELL_GO`** arrives (`HandleSpellGo`'s self-insert tail
//! `0x6e8498`/`0x6e8566`, anchored at the receive-time, onHold from Attributes bit 25);
//! `SMSG_SPELL_COOLDOWN` is the server *override/refresh* path (school lockouts, pet lists) —
//! vmangos sends no packet for a plain cast's cooldown. A failed cast (`SMSG_CAST_RESULT`)
//! never reached its GO, so the fail path clears only the GCD (`0x6e1d83 → 0x6e1630`).
//!
//! The store is generation-counted: every mutation bumps [`Cooldowns::generation`], and the UI
//! feed fires `ACTIONBAR_UPDATE_COOLDOWN` on the change — natural *expiry* bumps nothing (the
//! widget animates itself from `(start, duration)` and hides at the end, the reference
//! `Cooldown.lua` machine).
use std::time::{Duration, Instant};
use bevy::prelude::*;
use benilla_formats::SpellDisplay;
use benilla_protocol::messages::ItemUseSpell;
/// One timer pair: when it started and how long it runs. Zero-duration = not tracked.
#[derive(Clone, Copy, Debug)]
struct Timer {
start: Instant,
duration: Duration,
}
impl Timer {
fn none(now: Instant) -> Self {
Self {
start: now,
duration: Duration::ZERO,
}
}
fn remaining(&self, now: Instant) -> Duration {
(self.start + self.duration).saturating_duration_since(now)
}
}
/// One SPELLHISTORY record (wow-re `wave-cooldown.md` `0x6e12c0`'s node, byte-for-byte in
/// spirit: spellID/itemID/recovery pair/category+pair/onHold/GCD pair).
#[derive(Clone, Debug)]
struct Record {
spell_id: u32,
/// The cast item's template entry (`0` = a plain spell record) — item-use cooldowns key on
/// the pair, the client's `[eax+8]==spellId && [eax+0xc]==itemID` match.
item_id: u32,
recovery: Timer,
category: u32,
/// Whether [`Self::category`]'s SpellCategory row carries the flags-bit-`0x2` wildcard —
/// the category leg then contributes to EVERY query (`0x6e13e0` @ `6e1563`; wand Shoot's
/// 351 is the only 5875 carrier). Resolved at catalog load, copied here at insert.
category_wildcard: bool,
category_recovery: Timer,
/// Parked until `SMSG_COOLDOWN_EVENT` (`SPELL_ATTR_COOLDOWN_ON_EVENT`): the recovery pairs
/// hold their *durations* but their clocks haven't started.
on_hold: bool,
gcd_category: u32,
gcd: Timer,
}
/// What one queried action's cooldown reads (`GetActionCooldown`'s triple, app-side): the
/// winning timer's **absolute start** + full duration + time remaining, and whether it is
/// actually running (`enabled == false` = an on-hold record — the reference API's `enable == 0`,
/// which the `CooldownFrame_SetTimer` law hides). Carrying the start is the reference's own
/// convention (`GetCooldownInfo 0x6e13e0` returns the record's start, never a "remaining"), and
/// it is what makes the read re-arm-proof: two arms of a same-length cooldown can never alias,
/// because their starts differ.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CooldownInfo {
/// When the winning timer started (for an on-hold record: when it was inserted).
pub start: Instant,
pub remaining_ms: u32,
pub duration_ms: u32,
pub enabled: bool,
}
impl CooldownInfo {
/// The pushable UI triple `(start_ms on the GetTime clock, duration_ms, enabled)`, or `None`
/// when cold. `anchor`/`ui_now` are the frame's ATOMIC clock pair
/// ([`crate::ui_script::UiClock`]) — the `Instant` whose deltas the VM clock accumulates,
/// beside the value it accumulated to — so the subtraction maps the start across without
/// either side knowing the other's epoch, and derives the SAME whole-ms number every frame
/// for one arm (both legs advance in lockstep by construction). A RE-ARM always derives a
/// new one — the property the old `(remaining, duration)` shape lacked (two arms of the same
/// cooldown read byte-identical and the seam kept the first, elapsed, anchor: the
/// vanished-GCD-pie bug). Never convert through a locally sampled `Instant::now()`: that
/// re-measures the tick→caller scheduling gap every frame and wobbles the derived start by
/// the jitter (±12 ms observed live), turning a running cooldown into a per-frame "changed"
/// triple.
pub(crate) fn ui_triple(&self, anchor: Instant, ui_now: f64) -> Option<(i64, u32, bool)> {
(self.remaining_ms > 0).then(|| {
// Signed both ways: a timer armed AFTER the anchor sample (mid-frame, before the
// next tick) projects forward, so its first-frame derivation already equals every
// later frame's.
let start = match self.start.checked_duration_since(anchor) {
Some(ahead) => ui_now + ahead.as_secs_f64(),
None => ui_now - anchor.duration_since(self.start).as_secs_f64(),
};
#[allow(clippy::cast_possible_truncation)] // session-clock ms fit i64
(
(start * 1000.0).round() as i64,
self.duration_ms,
self.enabled,
)
})
}
}
/// The player's cooldown list (the client's self `SpellHistory` @0xcecaec; the pet list has no
/// benilla consumer). One resource, written by the net bridge + the cast-send path, read by the
/// action-bar feed.
#[derive(Resource, Default)]
pub(crate) struct Cooldowns {
records: Vec<Record>,
/// Bumped on every mutation — the UI feed's `ACTIONBAR_UPDATE_COOLDOWN` edge.
pub(crate) generation: u64,
}
impl Cooldowns {
fn bump(&mut self) {
self.generation = self.generation.wrapping_add(1);
}
/// The insert primitive (`AddCooldown 0x6e12c0`): nothing to track → no-op; else **append a
/// new record**. The client never matches-by-id here — its "reuse" scan is free-list node
/// recycling, an allocator detail (no `[node+8]==spellId` compare anywhere in `0x6e12c0`'s
/// body, wow-re `wave-cooldown.md`), where every other op in the family explicitly walks
/// "each node matching id". So one spell can hold SEVERAL records at once — and must: the
/// cast-send GCD arm (`StartGlobalCooldown`, a gcd-only node) and the GO self-insert
/// (`StartCooldown`, which passes `gcd=0,0`) are separate nodes. The find-and-replace this
/// used to do made the GO insert of any cooldown-carrying spell (Frost Nova) overwrite its
/// own running GCD node — the whole bar's pie flashed and died ~100 ms after the press,
/// while cooldown-less spells (Arcane Explosion) kept theirs (decision 0947).
#[allow(clippy::too_many_arguments)] // the SPELLHISTORY node's own field list
fn add(
&mut self,
spell_id: u32,
item_id: u32,
recovery: Timer,
category: u32,
category_wildcard: bool,
category_recovery: Timer,
on_hold: bool,
gcd_category: u32,
gcd: Timer,
) {
// The client's early-out: no recovery, no category recovery, not on hold, no GCD —
// nothing to track (6e12c3).
if recovery.duration.is_zero()
&& category_recovery.duration.is_zero()
&& !on_hold
&& gcd.duration.is_zero()
{
return;
}
self.records.push(Record {
spell_id,
item_id,
recovery,
category,
category_wildcard,
category_recovery,
on_hold,
gcd_category,
gcd,
});
self.bump();
}
/// Prune records with nothing left to say: every timer elapsed and not on hold. Behaviorally
/// invisible (an elapsed record contributes zero remaining) — bounds the list without the
/// client's event-driven sweep sites.
pub(crate) fn prune(&mut self, now: Instant) {
self.records.retain(|r| {
r.on_hold
|| !r.recovery.remaining(now).is_zero()
|| !r.category_recovery.remaining(now).is_zero()
|| !r.gcd.remaining(now).is_zero()
});
}
/// Start a spell's own cooldown (`StartCooldown 0x6e2c60`, spell-only path): recovery /
/// category from `Spell.dbc`, on-hold when the spell is `SPELL_ATTR_COOLDOWN_ON_EVENT`.
/// No GCD here — that's [`Self::start_gcd`]'s separate insert.
///
/// `ranged_attack_time_ms` is the ranged-shot pad (the category scaler `0x6e2b60`'s
/// `add [categoryRecoveryTime], [player+0x110]+0x1e8`, byte-verified — wow-re
/// `ranged-cooldown-sweep.md`, decision 0378): the caster's live `UNIT_FIELD_RANGEDATTACKTIME`
/// when [`SpellDisplay::ranged_speed_cooldown`], else 0. It folds into the CATEGORY timer —
/// the Throw/wand-Shoot sweep with all-zero DBC recovery — and rides the insert even for
/// category 0 (Auto Shot), where no read surfaces it (the client's SpellCategory[0]-is-NULL
/// asymmetry: Auto Shot never sweeps).
pub(crate) fn start_spell(
&mut self,
spell_id: u32,
spell: &SpellDisplay,
ranged_attack_time_ms: u32,
now: Instant,
) {
self.add(
spell_id,
0,
Timer {
start: now,
duration: Duration::from_millis(u64::from(spell.recovery_ms)),
},
spell.category,
spell.category_wildcard,
Timer {
start: now,
duration: Duration::from_millis(u64::from(
spell.category_recovery_ms + ranged_attack_time_ms,
)),
},
spell.cooldown_on_event(),
0,
Timer::none(now),
);
}
/// Start an item use's cooldown (`StartCooldown 0x6e2c60` with an item record): the wire's
/// server-resolved triple, each negative falling back to the spell's own `Spell.dbc` value
/// (the client's `>= 0` pick on the item slots).
pub(crate) fn start_item(
&mut self,
item_entry: u32,
use_spell: &ItemUseSpell,
spell: Option<&SpellDisplay>,
now: Instant,
) {
let recovery_ms = if use_spell.cooldown_ms >= 0 {
use_spell.cooldown_ms as u32
} else {
spell.map_or(0, |s| s.recovery_ms)
};
let category_ms = if use_spell.category_cooldown_ms >= 0 {
use_spell.category_cooldown_ms as u32
} else {
spell.map_or(0, |s| s.category_recovery_ms)
};
self.add(
use_spell.spell_id,
item_entry,
Timer {
start: now,
duration: Duration::from_millis(u64::from(recovery_ms)),
},
use_spell.category,
// The wildcard mark rides the CATEGORY; the wire's per-slot category resolves it
// through the spell only when the two agree (no 5875 item carries the one wildcard
// category, wand Shoot's 351 — a named corner, not a live one).
spell.is_some_and(|s| s.category == use_spell.category && s.category_wildcard),
Timer {
start: now,
duration: Duration::from_millis(u64::from(category_ms)),
},
spell.is_some_and(|s| s.cooldown_on_event()),
0,
Timer::none(now),
);
}
/// Arm the global cooldown at cast-send (`StartGlobalCooldown 0x6e2de0` ← the cast-send arm
/// `0x6e58fb`, shared by all three commit wires — CAST_SPELL, USE_ITEM, PET_CAST — keyed on
/// the cast SPELL): arms iff `startRecoveryTime != 0` (the byte predicate `6e2e0f–6e2e3d`:
/// enter on either pair field nonzero, then bail on a zero post-mod time — so a
/// `{cat≠0, time=0}` spell arms nothing; Attack, Auto Shot and wand Shoot carry (0,0)).
/// `onHold` rides Attributes bit 25 exactly as the ref passes it (`([rec+0x18]>>0x19)&1`).
pub(crate) fn start_gcd(&mut self, spell_id: u32, spell: &SpellDisplay, now: Instant) {
if spell.start_recovery_ms == 0 {
return;
}
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"cd",
&format!(
"arm-gcd spell={spell_id} gcdcat={} dur={}ms (cast-send)",
spell.start_recovery_category, spell.start_recovery_ms
),
);
}
self.add(
spell_id,
0,
Timer::none(now),
0,
false,
Timer::none(now),
spell.cooldown_on_event(),
spell.start_recovery_category,
Timer {
start: now,
duration: Duration::from_millis(u64::from(spell.start_recovery_ms)),
},
);
}
/// Clear only the GCD fields of a spell's record(s) (`0x6e1630` — the cast-fail path): a
/// rejected cast opens the global cooldown again immediately.
pub(crate) fn clear_gcd(&mut self, spell_id: u32, now: Instant) {
let mut touched = false;
for r in &mut self.records {
if r.spell_id == spell_id && !r.gcd.duration.is_zero() {
// `0x6e1630` zeroes BOTH fields (+0x28 startRecoveryCategory, +0x2c time).
r.gcd_category = 0;
r.gcd = Timer::none(now);
touched = true;
}
}
if touched {
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("cd", &format!("clear-gcd spell={spell_id} (cast-fail)"));
}
self.prune(now);
self.bump();
}
}
/// `SMSG_COOLDOWN_EVENT` (`0x6e1790`, force=0): an on-hold record's parked timers start
/// **now**; a running record is left alone.
pub(crate) fn cooldown_event(&mut self, spell_id: u32, now: Instant) {
let mut touched = false;
for r in &mut self.records {
if r.spell_id == spell_id && r.on_hold {
r.recovery.start = now;
r.category_recovery.start = now;
r.on_hold = false;
touched = true;
}
}
if touched {
self.bump();
}
}
/// `SMSG_CLEAR_COOLDOWN` / the cast-fail revert (`0x6e1790`, force=1): remove the spell's
/// record(s) outright.
pub(crate) fn clear_spell(&mut self, spell_id: u32) {
let before = self.records.len();
self.records.retain(|r| r.spell_id != spell_id);
if self.records.len() != before {
self.bump();
}
}
/// `SMSG_COOLDOWN_CHEAT` (`0x6e9700`): drain the whole list.
pub(crate) fn wipe(&mut self) {
if !self.records.is_empty() {
self.records.clear();
self.bump();
}
}
/// One `SMSG_SPELL_COOLDOWN` pair (`0x6e9460`'s per-entry law): a nonzero wire duration is
/// the spell recovery verbatim (category untracked); zero means "the spell's own Spell.dbc
/// recovery + category recovery". `SPELL_ATTR_COOLDOWN_ON_EVENT` parks it and suppresses the
/// GCD pair; otherwise the spell's GCD pair rides along.
pub(crate) fn apply_wire_cooldown(
&mut self,
spell_id: u32,
cooldown_ms: u32,
spell: Option<&SpellDisplay>,
now: Instant,
) {
let on_hold = spell.is_some_and(|s| s.cooldown_on_event());
let (recovery_ms, category, category_ms) = if cooldown_ms != 0 {
(cooldown_ms, spell.map_or(0, |s| s.category), 0)
} else {
match spell {
Some(s) => (s.recovery_ms, s.category, s.category_recovery_ms),
None => (0, 0, 0),
}
};
let (gcd_category, gcd_ms) = if on_hold {
(0, 0)
} else {
match spell {
Some(s) => (s.start_recovery_category, s.start_recovery_ms),
None => (0, 0),
}
};
self.add(
spell_id,
0,
Timer {
start: now,
duration: Duration::from_millis(u64::from(recovery_ms)),
},
category,
spell.is_some_and(|s| s.category == category && s.category_wildcard),
Timer {
start: now,
duration: Duration::from_millis(u64::from(category_ms)),
},
on_hold,
gcd_category,
Timer {
start: now,
duration: Duration::from_millis(u64::from(gcd_ms)),
},
);
}
/// `SMSG_ITEM_COOLDOWN` (`0x6e95d0`): the fixed 30 000 ms use cooldown on the item's on-use
/// spell — the 30 s is the client's hardcode, nothing else rides the wire.
pub(crate) fn apply_wire_item_cooldown(
&mut self,
item_entry: u32,
spell_id: u32,
now: Instant,
) {
self.add(
spell_id,
item_entry,
Timer {
start: now,
duration: Duration::from_millis(30_000),
},
0,
false,
Timer::none(now),
false,
0,
Timer::none(now),
);
}
/// One `SMSG_INITIAL_SPELLS` cooldown entry: the wire carries **remaining** ms (vmangos
/// computes them at send), so the record starts now and runs that remainder — the client
/// can't know the original start either. A *permanent* cooldown (`spell_cd_ms == 1`, the
/// category word's top bit) re-arms server-side; its 1 ms is carried verbatim (harmless — the
/// server refuses the cast regardless).
pub(crate) fn seed_initial(
&mut self,
cd: &benilla_protocol::messages::SpellCooldown,
now: Instant,
) {
self.add(
u32::from(cd.spell_id),
u32::from(cd.item_id),
Timer {
start: now,
duration: Duration::from_millis(u64::from(cd.spell_cd_ms)),
},
u32::from(cd.category & 0x7FFF),
// No display in reach here; the one wildcard category (wand Shoot's 351) never
// arrives via the initial-cooldowns list on 1.12 data — a named, dead corner.
false,
Timer {
start: now,
duration: Duration::from_millis(u64::from(cd.category_cd_ms)),
},
false,
0,
Timer::none(now),
);
}
/// One `SMSG_PET_SPELLS` cooldown entry (decision 0982) — [`Self::seed_initial`]'s pet twin,
/// separate because the pet block's ids are `u32` where the player's login list packs them
/// into `u16`, and because its category duration carries a marker bit the player's does not.
///
/// Both remainders are what is LEFT (vmangos computes them at send, `WritePetSpellsCooldown`),
/// so the record starts now and runs the remainder. The category word's
/// [`PET_COOLDOWN_PERMANENT`] bit marks a server-re-armed cooldown; it is stripped, because
/// carrying it would read as a ~37-hour sweep on the button.
pub(crate) fn seed_pet(
&mut self,
cd: &benilla_protocol::messages::PetSpellCooldown,
spell: Option<&SpellDisplay>,
now: Instant,
) {
use benilla_protocol::messages::PET_COOLDOWN_PERMANENT;
let category_ms = cd.category_cd_ms & !PET_COOLDOWN_PERMANENT;
self.add(
cd.spell_id,
0,
Timer {
start: now,
duration: Duration::from_millis(u64::from(cd.spell_cd_ms)),
},
u32::from(cd.category),
// The wildcard row's flag comes off the catalog when we have it — the same resolve
// `start_spell` does. Absent catalog ⇒ false, the non-wildcard reading.
spell.is_some_and(|s| s.category_wildcard),
Timer {
start: now,
duration: Duration::from_millis(u64::from(category_ms)),
},
false,
0,
Timer::none(now),
);
}
/// The client's `IsSpellOnCooldown 0x6e1690` — an **"has an on-hold (not-yet-started)
/// record"** predicate, NOT a general on-cooldown test (the corrected decode, wow-re
/// `gcd-power-gate.md` §3: both legs return 1 only when the matched node's onHold byte is
/// set; `+0x28`/`+0x2c` are never referenced and no time source is called). Its reference
/// consumers are all bit25/cooldown-on-event gates: the usable walk's grey-while-parked leg
/// (`0x6e3fb1`) — ours — and the cast-fail on-hold revert.
pub(crate) fn has_on_hold_record(&self, spell_id: u32, spell: Option<&SpellDisplay>) -> bool {
let category = spell.map_or(0, |s| s.category);
self.records.iter().any(|r| {
r.on_hold
&& ((r.spell_id == spell_id && r.item_id == 0)
|| (category != 0
&& r.category == category
&& !r.category_recovery.duration.is_zero()))
})
}
/// The cast validator's FIRST rung (`0x6094f0` @ `0x609565` → `0x6e2ea0` → the getter): a
/// press is refused "not ready" iff [`Self::info`] reads ANY remaining — the spell's own
/// pair, a category match, or the GCD leg, one query (wow-re `gcd-power-gate.md` §1.3/§2,
/// the §5 that closed 0379's INTERIM). The GCD refusal predicate is therefore the GETTER's:
/// `pressed.startRecoveryCategory == node.startRecoveryCategory && node.time != 0` — the
/// pressed spell's own `startRecoveryTime` is never consulted (a `{cat≠0, time=0}` press —
/// the scroll spells — IS refused during the GCD), and Attack / profession presses can never
/// be refused (the getter's head exclusion). The item fork (`0x60952b`) is the CALLER's:
/// an item press queries `(use_spell, item_entry)` and refuses 0x28; a spell press queries
/// `(spell, 0)` and refuses 0x3c. Refusing locally is what keeps the server's NOT_READY fail
/// — whose faithful revert [`Self::clear_gcd`] wipes the RUNNING GCD — off the wire (the
/// 0379 spam-press vanished-pie loop).
pub(crate) fn not_ready(
&self,
spell_id: u32,
item_entry: u32,
spell: Option<&SpellDisplay>,
now: Instant,
) -> bool {
self.info(spell_id, item_entry, spell, now).remaining_ms > 0
}
/// The per-spell read (`GetCooldownInfo 0x6e13e0`, the complete §5 match law — wow-re
/// `gcd-power-gate.md` §2): resolve `spell_id` (as cast from `item_entry`, `0` for a plain
/// spell) against EVERY record, three legs each, **longest remaining wins**:
///
/// - **head exclusion**: `Effect[0] ∈ {ATTACK, TRADE_SKILL}` reads cold unconditionally
/// (`6e1439`/`6e1442`) — no pie and no refusal for the Attack/profession buttons, ever;
/// - **spell-id leg**: id+item match; a parked record reads full duration with
/// `enabled == false` (the `CooldownFrame_SetTimer` law hides it);
/// - **category leg**: node-category equality — or ANY query when the node's category is a
/// flags-bit-`0x2` wildcard row (wand Shoot's 351: the whole-bar swing sweep). A parked
/// record contributes its full duration, disabled (ref: start=now while onHold);
/// - **GCD leg** (`6e15cc`): plain equality `node.gcd_category == queried
/// startRecoveryCategory` with the NODE's time nonzero — the queried spell's own
/// `startRecoveryTime` plays no role, and the leg ignores onHold and never disables.
pub(crate) fn info(
&self,
spell_id: u32,
item_entry: u32,
spell: Option<&SpellDisplay>,
now: Instant,
) -> CooldownInfo {
let mut best = CooldownInfo {
start: now,
remaining_ms: 0,
duration_ms: 0,
enabled: true,
};
if spell.is_some_and(|s| s.cooldown_query_excluded()) {
return best;
}
let category = spell.map_or(0, |s| s.category);
let start_recovery_category = spell.map_or(0, |s| s.start_recovery_category);
let mut consider = |timer: &Timer, remaining: Duration, enabled: bool| {
let remaining_ms = remaining.as_millis().min(u128::from(u32::MAX)) as u32;
if remaining_ms > best.remaining_ms {
best = CooldownInfo {
start: timer.start,
remaining_ms,
duration_ms: timer.duration.as_millis().min(u128::from(u32::MAX)) as u32,
enabled,
};
}
};
for r in &self.records {
if r.spell_id == spell_id && r.item_id == item_entry {
if r.on_hold {
// Parked: full duration remaining, not running (enable == 0 hides the sweep).
consider(&r.recovery, r.recovery.duration, false);
} else {
consider(&r.recovery, r.recovery.remaining(now), true);
}
}
if (r.category != 0 && r.category == category) || r.category_wildcard {
if r.on_hold {
consider(&r.category_recovery, r.category_recovery.duration, false);
} else {
consider(
&r.category_recovery,
r.category_recovery.remaining(now),
true,
);
}
}
if r.gcd_category == start_recovery_category && !r.gcd.duration.is_zero() {
consider(&r.gcd, r.gcd.remaining(now), true);
}
}
best
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spell(
category: u32,
recovery_ms: u32,
category_recovery_ms: u32,
gcd: (u32, u32),
attributes: u32,
) -> SpellDisplay {
SpellDisplay {
category,
recovery_ms,
category_recovery_ms,
start_recovery_category: gcd.0,
start_recovery_ms: gcd.1,
attributes,
..Default::default()
}
}
/// Fireball-shaped: no own cooldown, the ordinary 133/1500 GCD.
fn fireball() -> SpellDisplay {
spell(0, 0, 0, (133, 1500), 0x10000)
}
/// Charge-shaped: category 44, 15 s category cooldown, NO GCD pair.
fn charge() -> SpellDisplay {
spell(44, 0, 15_000, (0, 0), 0)
}
#[test]
fn the_gcd_spreads_to_every_spell_sharing_the_start_recovery_category() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
cds.start_gcd(133, &fireball(), t0);
// The cast spell itself and a DIFFERENT spell with the same startRecoveryCategory both
// read the GCD; Charge (no GCD pair) reads nothing.
let mid = t0 + Duration::from_millis(500);
let fb = cds.info(133, 0, Some(&fireball()), mid);
assert_eq!(
(fb.remaining_ms, fb.duration_ms, fb.enabled),
(1000, 1500, true)
);
let frostbolt = fireball(); // same shape, different id
let other = cds.info(116, 0, Some(&frostbolt), mid);
assert_eq!((other.remaining_ms, other.duration_ms), (1000, 1500));
let ch = cds.info(100, 0, Some(&charge()), mid);
assert_eq!(ch.remaining_ms, 0, "no startRecoveryCategory — no GCD read");
// …and the press gate reads it — the ONE getter is the refusal (0948's §5 closed
// 0379's INTERIM: there is no separate GCD site); the corrected `0x6e1690` on-hold
// predicate stays false (no parked record).
assert!(cds.not_ready(133, 0, Some(&fireball()), mid));
assert!(!cds.has_on_hold_record(133, Some(&fireball())));
}
/// The director's Frost Nova report (decision 0947): a cooldown-carrying spell's GO
/// self-insert (`StartCooldown` passes `gcd=0,0`) lands on its OWN node and must never eat
/// the cast-send GCD node — the bar's pie flashed and died ~100 ms after every Frost Nova
/// press while cooldown-less spells (Arcane Explosion) kept theirs, because `add` used to
/// find-and-replace by `(spell, item)`.
#[test]
fn a_go_self_insert_never_wipes_the_running_gcd() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// Frost-Nova-shaped: own 25 s recovery, category 35, the ordinary 133/1500 GCD pair.
let frost_nova = spell(35, 25_000, 0, (133, 1500), 0);
cds.start_gcd(122, &frost_nova, t0); // the cast-send arm
cds.start_spell(122, &frost_nova, 0, t0 + Duration::from_millis(100)); // the GO insert
let mid = t0 + Duration::from_millis(200);
// Every GCD-carrying sibling still reads the RUNNING GCD…
let fb = cds.info(133, 0, Some(&fireball()), mid);
assert_eq!(
(fb.remaining_ms, fb.duration_ms),
(1300, 1500),
"the GO self-insert must not eat the GCD node"
);
assert!(
cds.not_ready(133, 0, Some(&fireball()), mid),
"the local lock holds too"
);
// …and Frost Nova's own button reads its own cooldown (longest remaining wins).
let own = cds.info(122, 0, Some(&frost_nova), mid);
assert_eq!((own.remaining_ms, own.duration_ms), (24_900, 25_000));
}
#[test]
fn a_failed_cast_clears_the_gcd_and_the_optimistic_recovery() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
let fd = spell(0, 30_000, 0, (133, 1500), 0); // Feign-Death-shaped minus on-event
cds.start_gcd(5384, &fd, t0);
cds.start_spell(5384, &fd, 0, t0);
let mid = t0 + Duration::from_millis(100);
assert!(cds.not_ready(5384, 0, Some(&fd), mid));
// The 0x6e1a00 fail path: GCD cleared (0x6e1630) + the record force-removed (0x6e3050).
cds.clear_gcd(5384, mid);
cds.clear_spell(5384);
assert!(!cds.not_ready(5384, 0, Some(&fd), mid));
assert_eq!(cds.info(5384, 0, Some(&fd), mid).remaining_ms, 0);
}
#[test]
fn category_cooldowns_reach_category_siblings_but_not_others() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
cds.start_spell(100, &charge(), 0, t0); // Charge: category 44, 15 s
let mid = t0 + Duration::from_secs(5);
// A different spell in category 44 reads the shared remainder…
let sibling = spell(44, 0, 15_000, (0, 0), 0);
let s = cds.info(999, 0, Some(&sibling), mid);
assert_eq!((s.remaining_ms, s.duration_ms), (10_000, 15_000));
assert!(
cds.not_ready(999, 0, Some(&sibling), mid),
"category lock is a not-ready"
);
// …an unrelated spell reads nothing.
assert_eq!(cds.info(133, 0, Some(&fireball()), mid).remaining_ms, 0);
}
#[test]
fn an_on_event_cooldown_parks_until_the_event_starts_it() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// Feign Death: 30 s recovery, SPELL_ATTR_COOLDOWN_ON_EVENT (bit 25).
let fd = spell(0, 30_000, 0, (0, 0), 0x0200_0000);
cds.start_spell(5384, &fd, 0, t0);
// Parked: full duration, enabled == false (the sweep is hidden), but "not ready" holds.
let parked = cds.info(5384, 0, Some(&fd), t0 + Duration::from_secs(60));
assert_eq!(
(parked.remaining_ms, parked.enabled),
(30_000, false),
"an on-hold record never elapses on its own"
);
assert!(cds.not_ready(5384, 0, Some(&fd), t0 + Duration::from_secs(60)));
assert!(
cds.has_on_hold_record(5384, Some(&fd)),
"the corrected 0x6e1690: an on-hold record — the usable walk's grey-while-parked"
);
// SMSG_COOLDOWN_EVENT starts the clocks NOW.
let event_at = t0 + Duration::from_secs(60);
cds.cooldown_event(5384, event_at);
let running = cds.info(5384, 0, Some(&fd), event_at + Duration::from_secs(10));
assert_eq!(
(running.remaining_ms, running.duration_ms, running.enabled),
(20_000, 30_000, true)
);
}
#[test]
fn wire_cooldowns_take_the_server_duration_or_fall_back_to_the_dbc() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// A school lockout: nonzero wire ms is the recovery verbatim.
cds.apply_wire_cooldown(133, 8_000, Some(&fireball()), t0);
let locked = cds.info(133, 0, Some(&fireball()), t0 + Duration::from_secs(3));
assert_eq!((locked.remaining_ms, locked.duration_ms), (5_000, 8_000));
// Zero wire ms: the spell's own Spell.dbc recovery/category pair.
let mut cds = Cooldowns::default();
cds.apply_wire_cooldown(100, 0, Some(&charge()), t0);
let ch = cds.info(100, 0, Some(&charge()), t0 + Duration::from_secs(5));
assert_eq!((ch.remaining_ms, ch.duration_ms), (10_000, 15_000));
}
#[test]
fn item_use_cooldowns_key_on_the_item_and_respect_the_wire_triple() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// A potion: the wire triple resolved category 4 / 60 s, use-cooldown "the spell's own"
// (-1 → the spell has none).
let use_spell = ItemUseSpell {
spell_id: 439,
cooldown_ms: -1,
category: 4,
category_cooldown_ms: 60_000,
};
let potion_spell = spell(4, 0, 60_000, (133, 1500), 0);
cds.start_item(118, &use_spell, Some(&potion_spell), t0);
let mid = t0 + Duration::from_secs(15);
// The action-bar read for the potion action (spell 439 as cast from item 118) sees the
// category remainder…
let info = cds.info(439, 118, Some(&potion_spell), mid);
assert_eq!((info.remaining_ms, info.duration_ms), (45_000, 60_000));
// …and so does any other category-4 potion.
let other = cds.info(440, 929, Some(&potion_spell), mid);
assert_eq!(other.remaining_ms, 45_000);
}
#[test]
fn prune_drops_only_fully_elapsed_records() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
cds.start_gcd(133, &fireball(), t0);
cds.start_spell(100, &charge(), 0, t0);
assert_eq!(cds.records.len(), 2);
// After the GCD (1.5 s) but inside Charge's 15 s: only the GCD record goes.
cds.prune(t0 + Duration::from_secs(5));
assert_eq!(cds.records.len(), 1);
assert_eq!(cds.records[0].spell_id, 100);
cds.prune(t0 + Duration::from_secs(20));
assert!(cds.records.is_empty());
}
/// The vanished-GCD-pie regression (spam-press: fail-clear + re-arm inside one inter-feed
/// gap): the UI triple must be (a) frame-stable for one running cooldown — re-reading the
/// same arm later yields the same start — and (b) distinct across two arms, so the seam can
/// never mistake a fresh GCD for the elapsed one it replaced.
#[test]
fn the_ui_triple_is_stable_per_arm_and_distinct_across_arms() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
cds.start_gcd(772, &fireball(), t0); // press #1 arms the GCD
// Two reads of the SAME arm, frames apart, on both clocks in lockstep → identical triple.
let read1 = cds
.info(772, 0, Some(&fireball()), t0 + Duration::from_millis(16))
.ui_triple(t0 + Duration::from_millis(16), 10.016);
let read2 = cds
.info(772, 0, Some(&fireball()), t0 + Duration::from_millis(160))
.ui_triple(t0 + Duration::from_millis(160), 10.160);
assert_eq!(read1, Some((10_000, 1500, true)));
assert_eq!(read1, read2, "one arm reads one start, every frame");
// The spam cycle: the fail clears the GCD, the re-press re-arms 200 ms later — the feed
// never observes the cleared gap, but the fresh arm carries a fresh start regardless.
cds.clear_gcd(772, t0 + Duration::from_millis(200));
cds.start_gcd(772, &fireball(), t0 + Duration::from_millis(200));
let rearmed = cds
.info(772, 0, Some(&fireball()), t0 + Duration::from_millis(216))
.ui_triple(t0 + Duration::from_millis(216), 10.216);
assert_eq!(
rearmed,
Some((10_200, 1500, true)),
"a re-arm never aliases"
);
}
/// The conversion is signed both ways: a timer armed AFTER the frame's clock-pair sample
/// (mid-frame — the anchor predates the arm until the next tick) must project the SAME
/// start the next frame's pair re-derives, so frame one never pushes a value frame two
/// then "corrects" (a phantom re-arm at every cast).
#[test]
fn a_mid_frame_arm_derives_the_same_start_as_the_next_frames_pair() {
let anchor0 = Instant::now();
let mut cds = Cooldowns::default();
// Armed 4 ms after this frame's anchor sample.
cds.start_gcd(133, &fireball(), anchor0 + Duration::from_millis(4));
// Frame 1 converts through the pre-arm pair; frame 2 through the next tick's pair
// (both legs advanced by the same 16 ms). One arm — one start, both frames.
let read1 = cds
.info(
133,
0,
Some(&fireball()),
anchor0 + Duration::from_millis(4),
)
.ui_triple(anchor0, 10.000);
let read2 = cds
.info(
133,
0,
Some(&fireball()),
anchor0 + Duration::from_millis(20),
)
.ui_triple(anchor0 + Duration::from_millis(16), 10.016);
assert_eq!(read1, Some((10_004, 1500, true)));
assert_eq!(read1, read2, "the projected start IS the settled start");
}
/// The GCD refusal predicate, corrected by the 0948 §5 (`gcd-power-gate.md` §2.1): a press
/// is refused iff its `startRecoveryCategory` EQUALS the armed node's (node time ≠ 0) — the
/// pressed spell's own `startRecoveryTime` is never consulted. So a `{133, 0}` press (the
/// scroll spells) IS refused during the GCD (the pre-§5 predicate passed it), a `{0, 0}`
/// press (Attack shape, Charge) flows, and the lock lifts at expiry.
#[test]
fn a_running_gcd_locks_presses_by_category_equality_alone() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
assert!(
!cds.not_ready(133, 0, Some(&fireball()), t0),
"no GCD running — nothing locks"
);
cds.start_gcd(772, &fireball(), t0); // the successful cast arms the 133/1500 GCD
let mid = t0 + Duration::from_millis(200);
assert!(
cds.not_ready(133, 0, Some(&fireball()), mid),
"the spam press 200 ms later is locked — refused, never sent, the GCD lives"
);
// A {133, 0} press — Scroll of Armor's shape — is REFUSED: the node's category matches
// and only the NODE's time matters (the §5's corrected divergence).
let scroll = spell(0, 0, 0, (133, 0), 0x10000);
assert!(
cds.not_ready(8091, 0, Some(&scroll), mid),
"a zero-GCD press in the shared category is locked on the reference"
);
// Charge (no GCD pair at all) is untouched.
assert!(!cds.not_ready(100, 0, Some(&charge()), mid));
// The lock lifts exactly when the GCD elapses.
assert!(!cds.not_ready(133, 0, Some(&fireball()), t0 + Duration::from_millis(1_501)));
}
/// The getter's HEAD exclusion (`6e1439`/`6e1442`): Attack / profession presses can never
/// be cooldown-refused and their buttons never read a pie — whatever is running.
#[test]
fn attack_and_tradeskill_reads_are_always_cold() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
cds.start_gcd(772, &fireball(), t0);
let mid = t0 + Duration::from_millis(200);
let attack = SpellDisplay {
effect_1: 78, // SPELL_EFFECT_ATTACK
start_recovery_category: 133,
..Default::default()
};
assert_eq!(cds.info(6603, 0, Some(&attack), mid).remaining_ms, 0);
assert!(!cds.not_ready(6603, 0, Some(&attack), mid));
}
/// The category-wildcard leg (`6e1563`: a SpellCategory row with flags bit 0x2 matches EVERY
/// query — wand Shoot's 351, the only 5875 carrier): a running wildcard-category cooldown
/// sweeps and not-readies every button, the whole-bar wand-swing feel.
#[test]
fn a_wildcard_category_record_reaches_every_query() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
// Shoot-shaped: category 351 (wildcard), all-zero DBC recovery, the ranged pad supplies
// the category duration (decision 0378's insert shape).
let shoot = SpellDisplay {
category: 351,
category_wildcard: true,
attributes: 0x50012,
..Default::default()
};
cds.start_spell(5019, &shoot, 1500, t0);
let mid = t0 + Duration::from_millis(500);
// Fireball (category 0, unrelated) reads the wand swing…
let fb = cds.info(133, 0, Some(&fireball()), mid);
assert_eq!((fb.remaining_ms, fb.duration_ms), (1000, 1500));
// …and is locally not-ready for its duration.
assert!(cds.not_ready(133, 0, Some(&fireball()), mid));
assert!(!cds.not_ready(133, 0, Some(&fireball()), t0 + Duration::from_millis(1_501)));
}
#[test]
fn cheat_wipe_and_clear_bump_the_generation_only_on_change() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
let g0 = cds.generation;
cds.clear_spell(133); // nothing tracked — no bump
cds.wipe();
assert_eq!(cds.generation, g0);
cds.start_gcd(133, &fireball(), t0);
assert_ne!(cds.generation, g0);
let g1 = cds.generation;
cds.wipe();
assert_ne!(cds.generation, g1);
assert_eq!(cds.info(133, 0, Some(&fireball()), t0).remaining_ms, 0);
}
/// The ranged-shot pad (decision 0378, wow-re `ranged-cooldown-sweep.md`): a Throw-shaped
/// spell (category 76, all-zero DBC recovery) sweeps the weapon's attack time via its
/// CATEGORY timer, and refuses a recast within it.
#[test]
fn throw_sweeps_the_ranged_attack_time_via_its_category() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
let throw = spell(76, 0, 0, (0, 0), 0x410012);
cds.start_spell(2764, &throw, 2200, t0);
let mid = t0 + Duration::from_millis(1000);
let info = cds.info(2764, 0, Some(&throw), mid);
assert_eq!(info.duration_ms, 2200, "the sweep is the weapon speed");
assert_eq!(info.remaining_ms, 1200);
assert!(info.enabled, "running, not parked");
assert!(cds.not_ready(2764, 0, Some(&throw), mid));
assert!(
!cds.not_ready(2764, 0, Some(&throw), t0 + Duration::from_millis(2300)),
"free again after the weapon speed elapses"
);
}
/// Auto Shot (category 0) inserts the same padded timer but NO read surfaces it — the
/// client's `SpellCategory[0]`-is-NULL asymmetry: no sweep, no local recast refusal.
#[test]
fn auto_shot_category_zero_never_surfaces_its_pad() {
let t0 = Instant::now();
let mut cds = Cooldowns::default();
let auto_shot = spell(0, 0, 0, (0, 0), 0x50012);
cds.start_spell(75, &auto_shot, 3200, t0);
let mid = t0 + Duration::from_millis(100);
assert_eq!(
cds.info(75, 0, Some(&auto_shot), mid).remaining_ms,
0,
"no sweep for a category-0 record"
);
assert!(
!cds.not_ready(75, 0, Some(&auto_shot), mid),
"no local refusal either"
);
}
}