Files
benilla-octo/crates/benilla/src/ui_cast.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

1005 lines
46 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 cast-bar feed: our own cast lifecycle, wire → FrameXML events (decision 0137 phase 1).
//!
//! The net bridge queues [`CastBarEdge`]s (self-casts only — the producers filter on the self
//! guid; the channel pair is self-only *on the wire*), and the drain fires the reference
//! client's FrameScript events into the script VM — `SPELLCAST_START` and family, the exact
//! contract `assets/ui/CastingBar.xml` (the extracted 1.12 `CastingBarFrame`) registers for.
//! The spell name rides the event (resolved here from the `Spell.dbc` catalog — the script VM
//! has no spell-catalog binding, deliberately: one lookup face, decision 0107).
use std::time::{Duration, Instant};
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq};
use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// One edge of our own cast's lifecycle, queued by the net bridge for the cast bar.
pub(crate) enum CastBarEdge {
/// `SMSG_SPELL_START` for the self guid with a nonzero timer (an instant shows no bar —
/// its GO follows at once; the reference bar would only flicker).
Start { spell_id: u32, cast_time_ms: u32 },
/// `SMSG_SPELL_GO` for the self guid — the cast completed (the bar fills green and fades).
Stop,
/// `SMSG_CAST_RESULT` failure — the bar (if shown) turns red "Failed" (the red error line
/// rides the same packet via `CastErrors`, independently).
Failed,
/// `SMSG_SPELL_FAILED_OTHER` for the self guid — our in-flight cast was interrupted.
Interrupted,
/// `SMSG_SPELL_DELAYED` — pushback: our cast took a hit and the server extended it by
/// `delay_ms`. The bar slides its window out (spark jumps back), it does NOT cancel.
Delayed { delay_ms: u32 },
/// `MSG_CHANNEL_START` (self-only on the wire) — a channel opened; the bar counts *down*.
ChannelStart { spell_id: u32, duration_ms: u32 },
/// `MSG_CHANNEL_UPDATE` (self-only): time left; `0` = the channel is over.
ChannelUpdate { remaining_ms: u32 },
}
/// The net bridge's cast-bar queue (the [`crate::ui_action::CastErrors`] pattern).
#[derive(Resource, Default)]
pub(crate) struct CastBarFeed(pub(crate) Vec<CastBarEdge>);
/// Provisional in-flight window armed at send, before `SMSG_SPELL_START` names the real cast time.
/// It only has to outlast the send→START round trip; an instant, whose `SMSG_SPELL_GO` clears the
/// guard first, never reaches it. Generous on purpose — the resolution packets do the real clearing.
const SEND_PROVISIONAL: Duration = Duration::from_secs(5);
/// Pushback slack added to the server's cast time at `SMSG_SPELL_START`, so a cast the server
/// delays (`SMSG_SPELL_DELAYED`) still guards past its stretched end.
const CAST_SLACK: Duration = Duration::from_secs(2);
/// Our own outstanding cast — the client's optimistic in-flight guard, and the fix for the
/// spam-cancel bug. wow-re `wave-cast.md`: `TryCast 0x6e4b60` refuses to send a second
/// `CMSG_CAST_SPELL` while `IsCasting 0x6e3d30` (the inflight-spell-id `0xceca88`) is set — the
/// same-spell press bails **silently** at `6e4d43`; a different spell hits the
/// `[SpellRec+0x18] & 0x404` gate at `6e4d97`: when the *inflight* rec lacks those bits (an
/// ordinary cast — Fireball is `0x10000`), it errors 0x61 without sending; when the inflight IS
/// on-next-swing (a queued Heroic Strike), the new cast passes and nests around it (mask and
/// operand §5-confirmed byte-exact, wow-re `combat-feel-law.md` @ c445713b — exactly `0x404`, on
/// the inflight rec, no channel leg: Slam mid-cast blocks Battle Shout). In our model
/// the on-next-swing class never occupies this guard at all — it arms [`QueuedMeleeSpell`]
/// instead — so this guard only ever holds ordinary casts and the gate needs no attribute test.
/// Ours was server-driven: every mashed key fired a *duplicate* cast, the
/// server rejected the dupe with `SMSG_CAST_RESULT` failure, and that turned the running cast's bar
/// red while the original completed later. This marker drops the duplicate at the source, exactly
/// as the client does.
///
/// It is **optimistic** — armed the instant we send, not when the server's `SMSG_SPELL_START`
/// echoes back — because the spam lands during that round trip. Cleared by the cast's resolution
/// (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` / `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like
/// the `Casting` reap — and by the local self-cancel directly ([`local_self_cancel`], the
/// client's `AbortCast 0x6e4940` mirrored since 0444). `deadline` is the safety net for a
/// resolution that goes missing on the wire: armed generously and tightened to the real cast
/// time once `SMSG_SPELL_START` names it.
#[derive(Resource, Default)]
pub(crate) struct PendingCast(Option<PendingCastState>);
struct PendingCastState {
spell_id: u32,
deadline: Instant,
}
impl PendingCast {
/// Whether a cast we sent is still outstanding (unresolved and inside its safety deadline).
pub(crate) fn in_flight(&self, now: Instant) -> bool {
self.0.as_ref().is_some_and(|p| now < p.deadline)
}
/// The outstanding cast's spell id, if one is in flight — the app-side mirror of the client's
/// current-cast read (`0xceca88`), which `IsCurrentAction`'s checked state keys on (decision
/// 0137 phase 4).
pub(crate) fn current(&self, now: Instant) -> Option<u32> {
self.0
.as_ref()
.filter(|p| now < p.deadline)
.map(|p| p.spell_id)
}
/// Arm the guard on a fresh send (the optimistic `0xceca88` write at the client's cast-send).
pub(crate) fn arm(&mut self, spell_id: u32, now: Instant) {
self.0 = Some(PendingCastState {
spell_id,
deadline: now + SEND_PROVISIONAL,
});
}
/// Tighten the deadline to the server's real cast time once `SMSG_SPELL_START` names it.
pub(crate) fn refine(&mut self, cast_time_ms: u32, now: Instant) {
if let Some(p) = &mut self.0 {
p.deadline = now + Duration::from_millis(u64::from(cast_time_ms)) + CAST_SLACK;
}
}
/// Push the deadline out by a pushback (`SMSG_SPELL_DELAYED`), so the guard keeps holding past
/// the stretched cast end. Extends from whichever is later (the current deadline or now), so a
/// deadline already lapsed from an under-estimate still re-arms.
pub(crate) fn delay(&mut self, delay_ms: u32, now: Instant) {
if let Some(p) = &mut self.0 {
p.deadline = p.deadline.max(now) + Duration::from_millis(u64::from(delay_ms));
}
}
/// Clear the guard when the resolving spell is our outstanding one (spell-id-keyed, like the
/// `Casting` reap — a triggered proc's `SMSG_SPELL_GO` mid-cast must not open the gate early).
pub(crate) fn clear_if(&mut self, spell_id: u32) {
if self.0.as_ref().is_some_and(|p| p.spell_id == spell_id) {
self.0 = None;
}
}
}
/// Our own queued **on-next-swing** spell (Heroic Strike, Cleave — `Attributes & 0x404`) — the
/// melee-slot half of the client's cast tracking. In the reference the queued spell *occupies*
/// the inflight id (`0xceca88`) until the swing fires it, and the already-casting refusal at
/// `6e4d97` exempts new casts because the inflight rec has the `0x404` bits — so a queued Heroic
/// Strike never blocks Rend, and the new cast nests around it (`PushPopNestedCast 0x6e4ad0`
/// restores the queued id when the nested cast resolves; wow-re `wave-cast.md`). We model the
/// same observable with a second slot instead of the push/pop pair: [`PendingCast`] keeps
/// ordinary casts, this keeps the melee queue, and the checked ring reads both (the ref's
/// `IsCurrentAction` C2 leg — spell == inflight — holds through the nesting either way, minus
/// the ref's one-RTT un-check blink while the nested cast displaces the slot).
///
/// **Deadline-less, wire-cleared** — like the ref's inflight id, which no timer touches. The
/// clear set is deliberately wider than the ref's: the §5 (`combat-feel-law.md` @ c445713b)
/// pinned that the ref clears inflight/saved ONLY on a matching `SMSG_CAST_RESULT` (`0x6e7330`;
/// the GO handler never touches `0xceca88`) — but vmangos never sends an OK `CAST_RESULT` at
/// all, so on our wire the resolution is `SMSG_SPELL_GO` when the swing fires the strike, and a
/// failing `CAST_RESULT` + `SPELL_FAILED_OTHER` when it dies (vmangos `Spell::cancel` on the
/// PREPARING melee slot sends both — target death, manual cancel, replacement). We clear on any
/// of the three, id-keyed: identical observable where `0x130` does arrive, correct where it
/// never does. Re-arming replaces silently: the server holds a single `CURRENT_MELEE_SPELL`
/// slot. Re-pressing the queued spell itself is the ref's silent same-spell bail — §5-CONFIRMED
/// (`6e4d43`: debug-log, `xor al,al`, no CMSG, no error): 1.12 has no re-press-to-unqueue. The
/// real un-queue is the StopAttack chain (`0x5ecac0` → `CancelQueuedCast 0x6e6f30`, sending
/// `CMSG_ATTACKSTOP` + `CMSG_CANCEL_CAST`), reached by /stopattack, the Attack-button toggle,
/// target change/death, mount, interact — never movement. benilla doesn't run that chain yet
/// (we have no attack-toggle-off or stop-attack-on-target-change law) — a named follow-up; the
/// wire still resolves the queue either way.
#[derive(Resource, Default)]
pub(crate) struct QueuedMeleeSpell(Option<u32>);
impl QueuedMeleeSpell {
/// The queued spell's id, if one is waiting on the next swing — what the checked ring and
/// the re-press bail read.
pub(crate) fn current(&self) -> Option<u32> {
self.0
}
/// Queue a fresh on-next-swing send (replacing any prior queue — the server's single slot).
pub(crate) fn arm(&mut self, spell_id: u32) {
self.0 = Some(spell_id);
}
/// Clear on the wire resolution (`SMSG_SPELL_GO` / a failing `SMSG_CAST_RESULT` /
/// `SMSG_SPELL_FAILED_OTHER`), spell-id-keyed like every reap.
pub(crate) fn clear_if(&mut self, spell_id: u32) {
if self.0 == Some(spell_id) {
self.0 = None;
}
}
}
/// Slack past the server-named channel end, so a lost/late `MSG_CHANNEL_UPDATE(0)` can't pin the
/// checked ring on forever — the natural end clears through the deadline instead.
const CHANNEL_SLACK: Duration = Duration::from_secs(2);
/// Our own running channel — the app-side mirror of the client's current-channel id (`0xceac58`,
/// read live-gated by the channeling word: the §5's `IsCurrentAction` channel leg,
/// `action-button-state-api.md` §3 — a channeled spell's button stays checked while its channel
/// runs). Set at `MSG_CHANNEL_START`, refreshed by nonzero `MSG_CHANNEL_UPDATE`s, cleared by the
/// `UPDATE(0)` that ends both the natural finish and the interrupt.
#[derive(Resource, Default)]
pub(crate) struct ActiveChannel(Option<(u32, Instant)>);
impl ActiveChannel {
/// The running channel's spell id, if one is live.
pub(crate) fn current(&self, now: Instant) -> Option<u32> {
self.0
.filter(|&(_, until)| now < until)
.map(|(spell, _)| spell)
}
/// `MSG_CHANNEL_START`: open the window for the named duration (+ slack).
pub(crate) fn start(&mut self, spell_id: u32, duration_ms: u32, now: Instant) {
self.0 = Some((
spell_id,
now + Duration::from_millis(u64::from(duration_ms)) + CHANNEL_SLACK,
));
}
/// `MSG_CHANNEL_UPDATE`: `0` ends the channel; anything else re-times the window (pushback
/// shortens a channel, so the deadline moves in as well as out).
pub(crate) fn update(&mut self, remaining_ms: u32, now: Instant) {
if remaining_ms == 0 {
self.0 = None;
} else if let Some((_, until)) = &mut self.0 {
*until = now + Duration::from_millis(u64::from(remaining_ms)) + CHANNEL_SLACK;
}
}
}
/// ── The local self-cancel (decisions 0256 open item 2 / 0444 / 0445): move/jump/Esc mid-cast
/// ends the cast **locally**, the same client tick — the app-side mirror of `AbortCast
/// 0x6e4940`. The whole trigger chain is **VERIFIED** (wow-re `move-selfcancel.md`, the
/// 2026-07-17 §5): the `Script::Move*` keybind handlers funnel into the shared dispatcher
/// `0x515090`, whose interrupt mask `0x10f0` = {forward, backward, strafe L/R, autorun-toggle}
/// — turn (`0x100/0x200`) and pitch (`0x400/0x800`) are OUTSIDE the mask and never cancel —
/// and `Script::Jump 0x513bd0` inlines the same gate. Both call `AbortCast(cl=0, dl=1,
/// reason=0x1c)`: event **0x152 SPELLCAST_STOP** (the silent close — `cl=0` selects it;
/// `0x153/0x154` need `cl!=0`) + `CMSG_CANCEL_CAST`. The server stays the safety net: vmangos's
/// own movement interrupt (`Spell::update`'s 0.5-yd position delta) still kills anything we
/// miss. The per-spell gates, byte-verified at the call sites (`f6 46 54 01` @`0x51511e`,
/// `f6 42 5c 08` @`0x515175`; identities corroborated by vmangos `SpellDefines.h`):
const SPELL_INTERRUPT_MOVEMENT: u32 = 0x1; // SpellRec+0x54 InterruptFlags (SPELL_INTERRUPT_FLAG_MOVEMENT)
const CHANNEL_INTERRUPT_MOVING: u32 = 0x8; // SpellRec+0x5c ChannelInterruptFlags (AURA_INTERRUPT_MOVING_CANCELS)
/// The controller's report that a cancel-worthy local move edge happened this frame — a
/// *directional* start (forward/back/strafe: our wire-axis mirror of the verified `0x10f0`
/// mask's flags) or a jump takeoff. The mask's fifth member, the autorun toggle, is dormant
/// until benilla grows an autorun (0445). Written by `player::control` at the same spot the
/// wire-axis transitions are computed, consumed (and cleared) by [`local_self_cancel`] next
/// frame — one engine frame (~16 ms) from key to bar-kill, vs the server round trip (~150 ms+:
/// the 0.5-yd delta has to accumulate before vmangos even notices).
#[derive(Resource, Default)]
pub(crate) struct LocalMoveStart(pub(crate) bool);
/// Move/jump/Esc mid-cast: run the client's local cancel. One system for both triggers so the
/// resolution can't drift between them:
///
/// - **the auto-repeat half (Esc only)** — the ref's branch order inside
/// `Script::SpellStopCasting 0x6e6e80` (§5-verified whole, wow-re `esc-stopcasting.md`): a
/// running auto-repeat dies FIRST — [`crate::creature_anim::cancel_auto_repeat_local`], the
/// byte-verified `0x6ea080` (key + idle gates + nocked ammo + `CMSG_CANCEL_AUTO_REPEAT_SPELL`)
/// — and SPENDS the press: one press stops one thing, the cast survives to the next.
/// Movement never touches auto-repeat.
/// - **the cast half** — clear [`PendingCast`], ship `CMSG_CANCEL_CAST`, reap the self
/// `Casting` state + fire the `Fail` cast event (the anim/visual teardown `AbortCast` does via
/// its stop-cast-anim tail), and push the **`Stop` + `Interrupted`** bar edges — the ref's
/// own two-step (its local STOP, then the echo's red repaint) at RTT→0: red "Interrupted",
/// held 1 s, the armed flash bursting white, then the fade (the transcribed `CastingBar.xml`
/// machinery runs the rest). Esc bypasses the movement flags gate (the ref's
/// `SpellStopCasting → AbortCast` has no `InterruptFlags` test — the gate belongs to the
/// movement path alone).
/// - **the channel half — MOVEMENT-ONLY** — ship `CMSG_CANCEL_CHANNELLING` and NOTHING else:
/// the verified asymmetry (0445). Esc never reaches a channel — `0x6e6e80`'s whole callee
/// closure never calls the channel canceler `0x6e9b70`, and its inflight gate (`0xceca88`)
/// is already 0 mid-channel (the launch `CAST_RESULT(OKAY)` clears it at `0x6e7408`): the
/// vanilla "/stopcasting can't stop a channel" quirk, kept faithfully (0454; wow-re
/// `esc-stopcasting.md`). The ref's `0x6e9b70` fires no FrameScript event and clears no
/// local state — the channel bar closes on the server's `SMSG_CHANNEL_UPDATE(0)`
/// (`0x6e75f0`), which also clears [`ActiveChannel`] through the normal wire path (its slack
/// deadline self-heals a lost packet).
///
/// Why `Interrupted` when the client's own event is VERIFIED as the silent `0x152` STOP (every
/// self-cancel caller passes `cl=0`)? Because the REF'S NET OBSERVABLE is the red bar — the
/// director's falsifier, decision 0449, now pinned end to end (0454): vmangos `Spell::cancel`
/// answers the cancel with `SMSG_SPELL_FAILED_OTHER` and then a failing `SMSG_CAST_RESULT`
/// reason 0x23 (`SPELL_FAILED_INTERRUPTED`, counted against the 1.12.1 build guards), the
/// client routes a failing result through `HandleCastFailed 0x6e1a00`, and reasons
/// {0x23,0x24} select FrameScript **0x154 SPELLCAST_INTERRUPTED** — red, "Interrupted"
/// (GlobalStrings-exact), `holdTime = now + 1 s`. Our bar-edge producers are KEYED to the
/// self `Casting` (the 0137/0256 law that stops a proc's failure repainting the bar), and
/// this cancel's reap removes it — so the echo can't do the repainting here. The local
/// `Stop` + `Interrupted` pair replays the ref's exact sequence at RTT→0 through our keyed
/// architecture; the reap then keeps the echo idempotent (re-clears open guards, shows the
/// red error line, touches no bar). On-screen: the 1 s hold + ~25 bar ticks of flash+fade —
/// ~1.9 s in the reference bottle (whose `Config.wtf` caps `maxfps` at 30, and the ref steps
/// alpha PER TICK), the director's observed "2–3 s"; our transcription normalizes those steps
/// to the same 30 Hz reference tick so the tail matches at any render rate (`CastingBar.xml`).
#[allow(clippy::too_many_arguments)] // one resolution's full input set (the reap needs the ECS half)
fn local_self_cancel(
script: Option<NonSendMut<UiScript>>,
mut moved: ResMut<LocalMoveStart>,
mut pending: ResMut<PendingCast>,
channel: Res<ActiveChannel>,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut feed: ResMut<CastBarFeed>,
spells: Option<Res<Spells>>,
net: Res<NetCommands>,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut play_seq: ResMut<PlaySeq>,
mut ecs: Commands,
) {
let esc = match script {
Some(mut s) => s.take_spell_stop(),
None => false,
};
let moved = std::mem::take(&mut moved.0);
if !esc && !moved {
return;
}
let now = Instant::now();
let self_e = self_guid.0.as_ref().and_then(|g| index.0.get(g)).copied();
// The Esc press stops ONE thing, auto-repeat first — the ref's branch order (`0x6e6e80`).
let esc = if esc && auto_repeat.0.is_some() {
if *crate::net::CAST_TRACE {
info!("cast-trace: LOCAL auto-repeat self-cancel (esc), CMSG_CANCEL_AUTO_REPEAT_SPELL");
}
crate::creature_anim::cancel_auto_repeat_local(self_e, &mut auto_repeat, &mut ecs, &net);
false // the press is spent
} else {
esc
};
if !esc && !moved {
return;
}
let flags_open = |pick: fn(&benilla_formats::SpellDisplay) -> bool, spell_id: u32| {
// An uncataloged spell cancels: the gate exists to spare the rare cast-while-moving
// spell, and failing open matches the server's own verdict for everything ordinary.
spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id))
.is_none_or(pick)
};
// The cast half. The inflight id is the ref's `0xceca88` union: our own send guard
// (pre-START, armed by the spell-cast send path only) OR the started self cast (`Casting`,
// set at `SMSG_SPELL_START` for ANY cast — an item use's cast, which never arms the guard,
// is still move/Esc-cancelable this way, exactly as the ref's one inflight id covers it).
let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id);
if let Some(spell_id) = pending.current(now).or(started) {
if esc
|| flags_open(
|d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0,
spell_id,
)
{
if *crate::net::CAST_TRACE {
info!(
"cast-trace: LOCAL self-cancel — spell {spell_id} ({}), CMSG_CANCEL_CAST",
if esc { "esc" } else { "moved" }
);
}
let _ = net.0.send(ClientCommand::CancelCast { spell_id });
pending.clear_if(spell_id);
// The ref's two-step at RTT→0: STOP arms the bar's flash overlay, the echo's
// INTERRUPTED repaints it red and starts the hold — so after the hold the flash
// BURSTS white over the red bar, then the fade runs. One edge alone misses the
// burst (which the enemy-interrupt path — no prior STOP — correctly lacks).
feed.0.push(CastBarEdge::Stop);
feed.0.push(CastBarEdge::Interrupted);
// The reap — `spell_failed_other`'s self arm, run early so the echo finds it done.
if let Some(e) = self_e {
if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) {
ecs.entity(e).remove::<Casting>();
}
cast_events.write(CastEvent {
entity: e,
spell_id,
kind: CastEventKind::Fail,
seq: play_seq.next(),
});
}
}
}
// The channel half — MOVEMENT-ONLY (Esc can't stop a channel: the doc above / 0454), and
// the send and nothing else (the verified asymmetry: the ref's `0x6e9b70` fires no event
// and clears no state; the bar and [`ActiveChannel`] both close on the server's
// `SMSG_CHANNEL_UPDATE(0)`, ~one localhost frame later).
if moved {
if let Some(spell_id) = channel.current(now) {
if flags_open(
|d| d.channel_interrupt_flags & CHANNEL_INTERRUPT_MOVING != 0,
spell_id,
) {
if *crate::net::CAST_TRACE {
info!("cast-trace: LOCAL channel self-cancel — spell {spell_id}, CMSG_CANCEL_CHANNELLING");
}
let _ = net.0.send(ClientCommand::CancelChannelling { spell_id });
}
}
}
}
/// Drain the queue into FrameScript events — the reference `CastingBarFrame` contract
/// (extracted 1.12 `CastingBarFrame.lua`): `SPELLCAST_START(name, ms)`,
/// `SPELLCAST_CHANNEL_START(ms, name)`, `SPELLCAST_CHANNEL_UPDATE(remaining_ms)`, and the
/// argless STOP / FAILED / INTERRUPTED / CHANNEL_STOP. A channel update of `0` fires
/// CHANNEL_STOP — the server ends both the natural finish and the interrupt that way.
///
/// Also pushes the frame's stoppable mirror (running auto-repeat OR cast in flight — NOT a
/// channel, which `SpellStopCasting()` answers nil for: wow-re `esc-stopcasting.md`) into the
/// VM **after** [`local_self_cancel`] resolved, so the ESC chain's `SpellStopCasting()` reads
/// post-cancel truth when `UiInput` runs later this frame.
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
fn feed_cast_bar(
script: Option<NonSendMut<UiScript>>,
mut feed: ResMut<CastBarFeed>,
spells: Option<Res<Spells>>,
pending: Res<PendingCast>,
auto_repeat: Res<crate::ui_action::AutoRepeatActive>,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
casting: Query<&Casting>,
) {
let Some(mut script) = script else {
return;
};
let now = Instant::now();
// The same pending ∪ started union as the cancel's (an item use's cast must answer
// `SpellStopCasting()` truthily too), plus the auto-repeat key (`0xceac30`) it reads first.
let started = self_guid
.0
.as_ref()
.and_then(|g| index.0.get(g))
.is_some_and(|&e| casting.get(e).is_ok());
script.set_casting(auto_repeat.0.is_some() || pending.current(now).is_some() || started);
let name = |id: u32| -> String {
spells
.as_ref()
.and_then(|s| s.catalog.get(id))
.map(|d| d.name.clone())
.unwrap_or_default()
};
for edge in feed.0.drain(..) {
let (event, args): (&str, Vec<ScriptValue>) = match edge {
CastBarEdge::Start {
spell_id,
cast_time_ms,
} => (
"SPELLCAST_START",
vec![
ScriptValue::Str(name(spell_id)),
ScriptValue::Int(i64::from(cast_time_ms)),
],
),
CastBarEdge::Stop => ("SPELLCAST_STOP", vec![]),
CastBarEdge::Failed => ("SPELLCAST_FAILED", vec![]),
CastBarEdge::Interrupted => ("SPELLCAST_INTERRUPTED", vec![]),
CastBarEdge::Delayed { delay_ms } => (
"SPELLCAST_DELAYED",
vec![ScriptValue::Int(i64::from(delay_ms))],
),
CastBarEdge::ChannelStart {
spell_id,
duration_ms,
} => (
"SPELLCAST_CHANNEL_START",
vec![
ScriptValue::Int(i64::from(duration_ms)),
ScriptValue::Str(name(spell_id)),
],
),
CastBarEdge::ChannelUpdate { remaining_ms: 0 } => ("SPELLCAST_CHANNEL_STOP", vec![]),
CastBarEdge::ChannelUpdate { remaining_ms } => (
"SPELLCAST_CHANNEL_UPDATE",
vec![ScriptValue::Int(i64::from(remaining_ms))],
),
};
script.fire_event(event, args);
}
}
/// The cast-bar UI seam: the queue + its drain (before the VM ticks, so an edge and its first
/// OnUpdate land the same frame), and the local self-cancel resolved just ahead of the drain —
/// a controller move edge or an ESC `SpellStopCasting()` from the previous frame kills the bar
/// on this frame's VM tick (one engine frame from input to bar-death).
pub(crate) struct UiCastPlugin;
impl Plugin for UiCastPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<ActiveChannel>()
.init_resource::<LocalMoveStart>()
.add_systems(
Update,
(local_self_cancel, feed_cast_bar)
.chain()
.in_set(UnitFeed)
.before(UiInput),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIREBALL: u32 = 133;
#[test]
fn a_fresh_guard_is_open() {
let g = PendingCast::default();
assert!(
!g.in_flight(Instant::now()),
"nothing sent yet — casting allowed"
);
}
#[test]
fn arming_closes_the_guard_and_a_matching_resolution_opens_it() {
let t0 = Instant::now();
let mut g = PendingCast::default();
g.arm(FIREBALL, t0);
assert!(
g.in_flight(t0),
"a cast is in flight — a second one is refused"
);
g.clear_if(FIREBALL); // SMSG_SPELL_GO / a failing SMSG_CAST_RESULT for our cast
assert!(
!g.in_flight(t0),
"our cast resolved — casting allowed again"
);
}
#[test]
fn a_different_spells_resolution_leaves_the_guard_closed() {
let t0 = Instant::now();
let mut g = PendingCast::default();
g.arm(FIREBALL, t0);
g.clear_if(FIREBALL + 1); // a triggered proc's GO for some other spell mid-cast
assert!(
g.in_flight(t0),
"a different spell's resolution must not open the guard on our cast"
);
}
#[test]
fn the_guard_opens_when_its_safety_deadline_lapses() {
// The net that saves us if a cast resolves with no packet (a local move-cancel we don't
// model): the guard cannot wedge casting shut forever.
let t0 = Instant::now();
let mut g = PendingCast::default();
g.arm(FIREBALL, t0);
assert!(g.in_flight(t0 + SEND_PROVISIONAL - Duration::from_secs(1)));
assert!(!g.in_flight(t0 + SEND_PROVISIONAL + Duration::from_secs(1)));
}
#[test]
fn a_pushback_extends_the_guard_so_it_holds_past_the_stretched_cast() {
let t0 = Instant::now();
let mut g = PendingCast::default();
g.arm(FIREBALL, t0);
g.refine(1_000, t0); // a 1 s cast: deadline t0 + 1s + 2s slack = t0 + 3s
g.delay(500, t0); // a hit at t0 pushes it +0.5s ⇒ t0 + 3.5s
assert!(
g.in_flight(t0 + Duration::from_millis(3_200)),
"the pushback kept the guard shut past the original refined deadline"
);
}
#[test]
fn spell_start_tightens_the_deadline_to_the_real_cast_time() {
let t0 = Instant::now();
let mut g = PendingCast::default();
g.arm(FIREBALL, t0); // provisional 5s window
g.refine(1_500, t0); // SMSG_SPELL_START: a 1.5s cast (+ 2s pushback slack ⇒ 3.5s)
let at_four = t0 + Duration::from_secs(4);
assert!(
!g.in_flight(at_four),
"refined to 1.5s + slack, the guard has opened by t+4s (it would still be shut at the \
5s provisional)"
);
}
/// The queued on-next-swing slot: deadline-less (no clock in its API at all), keyed clears
/// only, and a re-arm replaces — the server's single `CURRENT_MELEE_SPELL` slot.
#[test]
fn queued_melee_spell_is_wire_cleared_and_single_slot() {
const HEROIC_STRIKE: u32 = 78;
const CLEAVE: u32 = 845;
let mut q = QueuedMeleeSpell::default();
assert_eq!(q.current(), None);
q.arm(HEROIC_STRIKE);
assert_eq!(q.current(), Some(HEROIC_STRIKE), "queued and checked");
// A different spell's resolution (Rend's GO while HS waits) must not open the queue.
q.clear_if(772);
assert_eq!(q.current(), Some(HEROIC_STRIKE));
// A second on-next-swing cast replaces the queue (vmangos holds ONE melee slot).
q.arm(CLEAVE);
assert_eq!(q.current(), Some(CLEAVE));
// The swing fired it (SPELL_GO) — or the slot died (failing CAST_RESULT /
// SPELL_FAILED_OTHER): the matching id clears.
q.clear_if(CLEAVE);
assert_eq!(q.current(), None, "the wire resolution opened the queue");
}
/// The local self-cancel harness: a mini-App with the real [`local_self_cancel`] system, a
/// self player mid-cast/mid-channel, and the net channel's receive half to assert the wire.
/// The harness registers no `UiScript`, so the ESC trigger reads `false` by default; the
/// ESC-leg tests insert a real VM and drive the actual `SpellStopCasting()` binding (the
/// ladder's 1/nil semantics are pinned Lua-side in `ui_script::escape_tests`).
mod local_cancel {
use super::*;
use crate::creature_anim::PlaySeq;
use crate::net::{Guid, GuidIndex, NetCommands, SelfGuid, SelfPlayer};
use benilla_formats::{SpellCatalog, SpellDisplay};
use bevy::ecs::system::RunSystemOnce;
use std::collections::HashMap;
const ARCANE_MISSILES: u32 = 5143;
fn harness(
displays: HashMap<u32, SpellDisplay>,
) -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<CastEvent>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<ActiveChannel>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<LocalMoveStart>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<PlaySeq>()
.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(displays),
..crate::ui_action::Spells::empty_for_tests()
});
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(10, self_e);
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
(app, rx)
}
/// Mark the self player mid-cast on `spell_id` (the `SMSG_SPELL_START` echo's `Casting`).
fn mark_casting(app: &mut App, spell_id: u32) {
let self_e = app.world().resource::<GuidIndex>().0[&10];
app.world_mut().entity_mut(self_e).insert(Casting {
spell_id,
until: None,
});
}
fn run(app: &mut App, moved: bool) {
app.world_mut().resource_mut::<LocalMoveStart>().0 = moved;
app.world_mut().run_system_once(local_self_cancel).unwrap();
}
/// The headline feel fix: a directional start / jump mid-cast kills the cast locally —
/// wire cancel out, guard open, the STOP + INTERRUPTED bar-edge pair (the ref's own
/// two-step: its silent 0x152 STOP arms the flash, the server echo's repaint holds the
/// red "Interrupted"; our keyed reap suppresses the real echo, so the local pair
/// replays the sequence at RTT→0 — decisions 0449/0454), `Casting` reaped — instead of
/// waiting ~150 ms+ for the server's 0.5-yd position-delta interrupt.
#[test]
fn a_move_edge_mid_cast_cancels_locally() {
let (mut app, rx) = harness(HashMap::from([(
FIREBALL,
SpellDisplay {
interrupt_flags: 0xf, // the ordinary timed-cast mask (movement bit set)
..Default::default()
},
)]));
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
mark_casting(&mut app, FIREBALL);
run(&mut app, true);
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL })
),
"the move edge ships CMSG_CANCEL_CAST for the in-flight cast"
);
assert!(
!app.world()
.resource::<PendingCast>()
.in_flight(Instant::now()),
"the in-flight guard opens with the local cancel"
);
let feed = &app.world().resource::<CastBarFeed>().0;
assert_eq!(feed.len(), 2);
assert!(
matches!(
(&feed[0], &feed[1]),
(CastBarEdge::Stop, CastBarEdge::Interrupted)
),
"the bar edges are the ref's own two-step at RTT→0 — STOP (arms the flash \
overlay) then the red INTERRUPTED (the echo's repaint: hold, burst, fade); \
the keyed reap silences the real echo (0449/0454)"
);
let self_e = app.world().resource::<GuidIndex>().0[&10];
assert!(
app.world().entity(self_e).get::<Casting>().is_none(),
"the self `Casting` state is reaped, so the server's echo (FAILED_OTHER + \
CAST_RESULT) finds nothing to repaint"
);
assert!(
!app.world().resource::<LocalMoveStart>().0,
"the edge is consumed"
);
}
/// The per-spell gate (VERIFIED — `test [SpellRec+0x54],1` at both call sites): a spell
/// whose `InterruptFlags` lacks the movement bit keeps casting while we move — no wire
/// cancel, no bar edge.
#[test]
fn the_interrupt_flags_gate_spares_a_movement_castable_spell() {
let (mut app, rx) = harness(HashMap::from([(
FIREBALL,
SpellDisplay {
interrupt_flags: 0xe, // movement bit CLEAR (pushback/stun/combat only)
..Default::default()
},
)]));
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
run(&mut app, true);
assert!(rx.try_recv().is_err(), "no wire cancel for a spared spell");
assert!(
app.world()
.resource::<PendingCast>()
.in_flight(Instant::now()),
"the guard keeps holding — the cast runs on"
);
assert!(app.world().resource::<CastBarFeed>().0.is_empty());
}
/// A spell the catalog doesn't know fails OPEN (cancels): the gate exists to spare the
/// rare cast-while-moving spell, and the server's own verdict for everything ordinary is
/// to interrupt.
#[test]
fn an_uncataloged_spell_fails_open() {
let (mut app, rx) = harness(HashMap::new());
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
run(&mut app, true);
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL })
),
"an unknown spell's cast still cancels on movement"
);
}
/// The union's other half: an ITEM use's cast (a hearthstone) never arms the send
/// guard — the started self `Casting` (the `SMSG_SPELL_START` echo) alone makes it
/// move-cancelable, mirroring the ref's ONE inflight id (`0xceca88`), which item casts
/// set like any spell cast.
#[test]
fn a_started_item_cast_cancels_without_the_send_guard() {
let (mut app, rx) = harness(HashMap::from([(
FIREBALL,
SpellDisplay {
interrupt_flags: 0xf,
..Default::default()
},
)]));
mark_casting(&mut app, FIREBALL); // PendingCast stays empty — item casts never arm it
run(&mut app, true);
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL })
),
"the started cast cancels through the `Casting` half of the union"
);
let self_e = app.world().resource::<GuidIndex>().0[&10];
assert!(app.world().entity(self_e).get::<Casting>().is_none());
}
/// No move edge, cast in flight: nothing happens — the system is edge-triggered, never
/// level-triggered (holding W through a whole cast is one cancel at the start, not one
/// per frame; and standing still cancels nothing).
#[test]
fn no_edge_means_no_cancel() {
let (mut app, rx) = harness(HashMap::from([(
FIREBALL,
SpellDisplay {
interrupt_flags: 0xf,
..Default::default()
},
)]));
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
run(&mut app, false);
assert!(rx.try_recv().is_err());
assert!(app
.world()
.resource::<PendingCast>()
.in_flight(Instant::now()));
assert!(app.world().resource::<CastBarFeed>().0.is_empty());
}
/// Esc CANNOT stop a channel — the §5-verified `0x6e6e80` never reaches the channel
/// canceler `0x6e9b70`, and its inflight gate is already 0 mid-channel (wow-re
/// `esc-stopcasting.md`, 0454): the mirror answers `SpellStopCasting()` nil (the
/// ladder falls through to the next rung) and the drain ships nothing.
#[test]
fn esc_cannot_stop_a_channel() {
let (mut app, rx) = harness(HashMap::new());
app.insert_non_send_resource(UiScript::new().unwrap());
let now = Instant::now();
app.world_mut()
.resource_mut::<ActiveChannel>()
.start(ARCANE_MISSILES, 5_000, now);
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
None,
"mid-channel the binding answers nil — the vanilla /stopcasting quirk"
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(
rx.try_recv().is_err(),
"no wire cancel — channels break only on the movement path"
);
assert_eq!(
app.world().resource::<ActiveChannel>().current(now),
Some(ARCANE_MISSILES)
);
}
/// The ref's branch order inside `SpellStopCasting` (`0x6e6e80`): a running auto-repeat
/// dies FIRST — `CMSG_CANCEL_AUTO_REPEAT_SPELL`, no bar edge — and SPENDS the press;
/// the in-flight cast survives to the next one. One press stops one thing.
#[test]
fn esc_cancels_the_auto_repeat_first_and_the_cast_on_the_next_press() {
const AUTO_SHOT: u32 = 75;
let (mut app, rx) = harness(HashMap::new());
app.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut()
.resource_mut::<crate::ui_action::AutoRepeatActive>()
.0 = Some(AUTO_SHOT);
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
mark_casting(&mut app, FIREBALL);
// Press 1: truthy mirror (auto-repeat ∪ cast) → trigger queued → the drain stops
// the auto-repeat ONLY.
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
Some(1)
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelAutoRepeat)
),
"the auto-repeat cancel ships first — the ref's priority branch"
);
assert!(rx.try_recv().is_err(), "the cast survives the first press");
assert!(app
.world()
.resource::<crate::ui_action::AutoRepeatActive>()
.0
.is_none());
assert!(
app.world()
.resource::<PendingCast>()
.in_flight(Instant::now()),
"the send guard still holds — one press stopped one thing"
);
assert!(
app.world().resource::<CastBarFeed>().0.is_empty(),
"an auto-repeat cancel paints no bar edge"
);
// Press 2: the auto-repeat is gone — now the cast dies, the ordinary Esc cancel.
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
Some(1)
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL })
),
"the second press reaches the cast half"
);
}
/// The channel half is the SEND and nothing else — the verified asymmetry (0445): the
/// ref's `0x6e9b70` ships CMSG_CANCEL_CHANNELLING but fires no FrameScript event and
/// clears no state; the channel bar and the mirror both close on the server's
/// `SMSG_CHANNEL_UPDATE(0)` through the normal wire path.
#[test]
fn a_move_edge_mid_channel_sends_the_cancel_and_touches_nothing_local() {
let (mut app, rx) = harness(HashMap::from([(
ARCANE_MISSILES,
SpellDisplay {
channel_interrupt_flags: 0x7c0c, // the real 5143 mask (moving bit 0x8 set)
..Default::default()
},
)]));
let now = Instant::now();
app.world_mut()
.resource_mut::<ActiveChannel>()
.start(ARCANE_MISSILES, 5_000, now);
run(&mut app, true);
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelChannelling {
spell_id: ARCANE_MISSILES
})
),
"the move edge ships CMSG_CANCEL_CHANNELLING"
);
assert_eq!(
app.world().resource::<ActiveChannel>().current(now),
Some(ARCANE_MISSILES),
"the mirror stays live — the server's UPDATE(0) is what clears it"
);
assert!(
app.world().resource::<CastBarFeed>().0.is_empty(),
"no local bar edge — the bar closes on the wire's SPELLCAST_CHANNEL_STOP"
);
}
}
/// The channel mirror's lifecycle: START opens the window for the whole duration, a nonzero
/// UPDATE re-times it (pushback shortens channels), UPDATE(0) ends it — and the slack deadline
/// self-clears a channel whose closing packet never arrived.
#[test]
fn active_channel_tracks_the_wire_lifecycle() {
let t0 = Instant::now();
let mut ch = ActiveChannel::default();
assert_eq!(ch.current(t0), None);
ch.start(10, 5_000, t0); // Arcane Missiles-shaped: 5 s channel
assert_eq!(ch.current(t0), Some(10));
assert_eq!(
ch.current(t0 + Duration::from_secs(4)),
Some(10),
"still live near the end"
);
assert_eq!(
ch.current(t0 + Duration::from_secs(8)),
None,
"the slack deadline self-clears a lost UPDATE(0)"
);
// Pushback: an update naming LESS time moves the deadline in.
ch.start(10, 5_000, t0);
ch.update(1_000, t0);
assert_eq!(ch.current(t0 + Duration::from_secs(4)), None);
// The closing update ends it immediately.
ch.start(10, 5_000, t0);
ch.update(0, t0);
assert_eq!(ch.current(t0), None);
}
}