1717// Vanilla 1.12 stores NO cast start/end times anywhere readable (unlike
1818// 3.3.5+, which keeps them per-unit on the CGUnit). The cast bar is
1919// Lua-driven off `SPELLCAST_START(duration)` + `GetTime()`. So we
20- // self-track the LOCAL PLAYER's cast/channel on `WorldTick`:
21- // - cast: `VAR_CURRENT_CAST_SPELL` (the player cast-bar spellID
22- // global). On a 0->nonzero transition we stamp startMs = now and
23- // endMs = now + base cast time (SpellCastTimes.dbc). Instant casts
24- // (cast time 0) and channels are filtered out here — a channel has
25- // no cast time and surfaces through the channel field instead.
20+ // self-track the LOCAL PLAYER's cast/channel with a HYBRID of two sources:
21+ // - Normal casts (client path): co-hook the engine's cast-start writer
22+ // `FUN_CAST_START_SET` and stamp from its spellID argument the instant
23+ // the cast begins (startMs = now, endMs = now + effective cast time) —
24+ // zero latency. `g_castFromServer = false`; cleared on `WorldTick` when
25+ // `VAR_CURRENT_CAST_SPELL` returns to 0 (normal end / cancel / interrupt).
26+ // - Chained same-spell recasts (server path): the engine SHORT-CIRCUITS
27+ // `Spell_C_CastSpell` at `spellID == VAR_CURRENT_CAST_SPELL` — it never
28+ // runs the client cast path, never sets the global, never fires
29+ // SPELLCAST_START. So a chained recast is invisible client-side; the
30+ // ONLY witness is the server's `SMSG_SPELL_START` (= nampower's
31+ // SPELL_START_SELF). We stamp `g_cast` from that packet (server cast
32+ // time), dedup'd against a just-made client stamp so a normal cast's
33+ // confirming packet doesn't restart its bar. `g_castFromServer = true`;
34+ // since the global was never set for these, the WorldTick VAR==0 clear
35+ // must skip them — they expire on their computed endMs (and pfUI clears
36+ // on nampower's SPELL_FAILED_SELF for interrupts).
2637// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
27- // (descriptor +0x228). endMs = now + base channel duration
28- // (SpellDuration.dbc) .
38+ // (descriptor +0x228), polled on `WorldTick` . endMs = now + effective
39+ // channel duration .
2940// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
3041// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
3142// comparable to `GetTime()*1000` in addon progress math.
@@ -81,12 +92,17 @@ struct TrackedSpell {
8192 int spellID; // 0 = not casting / channeling
8293 int startMs;
8394 int endMs;
95+ int delayMs; // accumulated pushback (SMSG_SPELL_DELAYED), 0 = none
8496};
8597
86- TrackedSpell g_cast{0 , 0 , 0 };
87- TrackedSpell g_channel{0 , 0 , 0 };
88- int g_lastCast = 0 ;
89- int g_lastChannel = 0 ;
98+ TrackedSpell g_cast{0 , 0 , 0 , 0 };
99+ TrackedSpell g_channel{0 , 0 , 0 , 0 };
100+
101+ // True when g_cast was stamped from SMSG_SPELL_START (a chained same-spell
102+ // recast the client cast path bailed on) rather than the client-side
103+ // FUN_CAST_START_SET hook. Such casts never set VAR_CURRENT_CAST_SPELL, so
104+ // the WorldTick VAR==0 clear must not touch them.
105+ bool g_castFromServer = false ;
90106
91107int NowMs () { return static_cast <int >(reinterpret_cast <TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS )()); }
92108
@@ -116,18 +132,6 @@ int ChannelDurationMs(int spellID) {
116132 return ms > 0 ? ms : 0 ; // negative = "infinite"; channels are finite
117133}
118134
119- // The local player's currently-channeled spellID (broadcast +0x228), or 0.
120- int PlayerChannelSpell () {
121- void *p = Resolve (" player" );
122- if (p == nullptr )
123- return 0 ;
124- auto *desc = *reinterpret_cast <const uint8_t *const *>(
125- static_cast <const uint8_t *>(p) + Offsets::OFF_UNIT_DESCRIPTOR );
126- if (desc == nullptr )
127- return 0 ;
128- return *reinterpret_cast <const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL );
129- }
130-
131135const char *SpellName (const uint8_t *rec) {
132136 const int locale = *reinterpret_cast <int *>(Offsets::VAR_LOCALE_INDEX );
133137 return *reinterpret_cast <const char *const *>(rec + OFF_NAME + locale * 4 );
@@ -156,6 +160,12 @@ const char *SpellIconPath(const uint8_t *rec) {
156160int PushCastInfo (void *L, const TrackedSpell &c) {
157161 if (c.spellID == 0 )
158162 return 0 ;
163+ // Self-expire: once the cast window has elapsed, report nothing even if
164+ // the slot wasn't explicitly cleared. Keeps a stale cast (e.g. one whose
165+ // clear we missed) from lingering on an addon's cast bar, and lets a
166+ // fresh stamp for the next cast take over cleanly.
167+ if (NowMs () >= c.endMs )
168+ return 0 ;
159169 const uint8_t *rec = Spell::Lookup::RecordForID (c.spellID );
160170 if (rec == nullptr )
161171 return 0 ;
@@ -170,7 +180,7 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
170180 Game::Lua::PushBool (L, false ); // 8 notInterruptible
171181 Game::Lua::PushNumber (L, static_cast <double >(c.spellID )); // 9 castingSpellID
172182 Game::Lua::PushNil (L); // 10 castBarID
173- Game::Lua::PushNumber (L, 0 ); // 11 delayTimeMs
183+ Game::Lua::PushNumber (L, static_cast < double >(c. delayMs )); // 11 delayTimeMs
174184 return 11 ;
175185}
176186
@@ -179,6 +189,12 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
179189int PushChannelInfo (void *L, int spellID, int startMs, int endMs, bool haveTimes) {
180190 if (spellID == 0 )
181191 return 0 ;
192+ // Self-expire a timed channel once its window elapses (mirrors
193+ // PushCastInfo) — this is how the player's channel clears, since we no
194+ // longer poll the +0x228 field. Remote callers gate on endMs before
195+ // passing haveTimes, so this is a no-op for them.
196+ if (haveTimes && endMs != 0 && NowMs () >= endMs)
197+ return 0 ;
182198 const uint8_t *rec = Spell::Lookup::RecordForID (spellID);
183199 if (rec == nullptr )
184200 return 0 ;
@@ -199,17 +215,38 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
199215 return 8 ;
200216}
201217
202- // True if the unit at stack index 1 resolves to the local player.
203- bool ArgIsPlayer (void *L) {
204- if (!Game::Lua::IsString (L, 1 ))
205- return false ;
206- const char *token = Game::Lua::ToString (L, 1 );
207- if (token == nullptr )
208- return false ;
209- void *u = Resolve (token);
210- return u != nullptr && u == Resolve (" player" );
218+ // ---- Player cast detection (FUN_CAST_START_SET co-hook) ------------------
219+
220+ // The engine's cast-start writer — the client path for a NORMAL cast. We
221+ // stamp from its spellID argument the instant the cast begins (zero
222+ // latency), *before* the original so any SPELLCAST_START it triggers sees
223+ // fresh times. This does NOT fire for a chained same-spell recast — the
224+ // caller (Spell_C_CastSpell) bails at `spellID == VAR_CURRENT_CAST_SPELL`
225+ // before ever reaching this writer; those are caught by the SMSG_SPELL_START
226+ // path instead. The `spellID == 0` (queued-restore / clear) path is left to
227+ // the OnWorldTick VAR==0 poll so a restore doesn't prematurely wipe a bar.
228+ using CastStartSet_t = void (__fastcall *)(int spellID, int targetState);
229+ CastStartSet_t g_origCastStartSet = nullptr ;
230+
231+ void __fastcall CastStartSet_h (int spellID, int targetState) {
232+ if (spellID != 0 ) {
233+ const int dur = CastTimeMs (spellID);
234+ if (dur > 0 ) {
235+ const int now = NowMs ();
236+ g_cast = TrackedSpell{spellID, now, now + dur, 0 };
237+ g_castFromServer = false ; // client-tracked; VAR==0 clears it
238+ g_channel.spellID = 0 ; // a cast supersedes any channel
239+ }
240+ // dur == 0: instant (no bar) or channel (handled via +0x228 poll);
241+ // leave g_cast — don't clobber an unrelated active cast.
242+ }
243+ g_origCastStartSet (spellID, targetState);
211244}
212245
246+ const Game::HookAutoRegister _castStartHook{
247+ Offsets::FUN_CAST_START_SET , reinterpret_cast <void *>(&CastStartSet_h),
248+ reinterpret_cast <void **>(&g_origCastStartSet)};
249+
213250// ---- Remote-unit cast tracking (SMSG_SPELL_START) ------------------------
214251
215252// Vanilla stores no cast record for other units, so we capture their casts
@@ -278,17 +315,58 @@ const RemoteCast *FindRemoteCast(uint64_t caster) {
278315 return nullptr ;
279316}
280317
281- void RecordRemoteStart (uint64_t caster, int spellID, uint32_t castTime) {
318+ // How recently the client-side FUN_CAST_START_SET hook must have stamped the
319+ // same spell for an SMSG_SPELL_START to count as that cast's confirming
320+ // packet (skip — don't restart the bar) rather than a new chained recast
321+ // (stamp). Must exceed worst-case latency but stay under a GCD / min cast
322+ // time so genuine chained casts aren't wrongly deduped.
323+ constexpr int kCastStartDedupMs = 500 ;
324+
325+ void HandleSpellStart (uint64_t caster, int spellID, uint32_t castTime) {
282326 if (caster == 0 || spellID == 0 )
283327 return ;
284- // The local player uses the self-tracked g_cast / g_channel path.
285- if (caster == Unit::Identity::PlayerGuid ())
286- return ;
287328 const uint8_t *rec = Spell::Lookup::RecordForID (spellID);
288329 if (rec == nullptr )
289330 return ;
290331 const int now = NowMs ();
291332 const bool channel = IsChannelSpell (rec);
333+
334+ if (caster == Unit::Identity::PlayerGuid ()) {
335+ if (channel) {
336+ // Player channel start. The broadcast +0x228 field that signals
337+ // "channeling" is server-pushed and lands ~1 tick (~1s) after the
338+ // channel actually begins, so a +0x228 poll shows the bar far too
339+ // late — the addon already polled nil at SPELLCAST_CHANNEL_START
340+ // and cleared. This packet IS the channel start: stamp g_channel
341+ // now with the engine-computed duration. We run before the
342+ // original handler that fires SPELLCAST_CHANNEL_START, so the data
343+ // is fresh when addons poll. Cleared by self-expiry (PushChannel-
344+ // Info) at endMs.
345+ const int dur = ChannelDurationMs (spellID);
346+ if (dur > 0 ) {
347+ g_channel = TrackedSpell{spellID, now, now + dur, 0 };
348+ g_cast.spellID = 0 ; // a channel supersedes any cast
349+ }
350+ return ;
351+ }
352+ if (castTime == 0 )
353+ return ; // instant — no cast bar
354+ // Backstop for chained same-spell recasts: the engine short-circuits
355+ // Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
356+ // client path (FUN_CAST_START_SET hook) never fires for them — this
357+ // server packet is the only signal. Dedup against a fresh client stamp
358+ // (same spell, started within ~latency) so a normal cast's confirming
359+ // packet doesn't restart its bar; otherwise it's a chained recast the
360+ // client bailed on — take it, and flag it so the VAR==0 poll (which
361+ // never saw it) won't clear it.
362+ if (g_cast.spellID == spellID && now < g_cast.endMs &&
363+ now - g_cast.startMs < kCastStartDedupMs )
364+ return ;
365+ g_cast = TrackedSpell{spellID, now, now + static_cast <int >(castTime), 0 };
366+ g_castFromServer = true ;
367+ g_channel.spellID = 0 ; // a cast supersedes any channel
368+ return ;
369+ }
292370 const int endMs = channel ? now + RemoteChannelDurationMs (rec)
293371 : now + static_cast <int >(castTime);
294372 StoreRemoteCast (caster, spellID, now, endMs, channel);
@@ -312,7 +390,7 @@ int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
312390 Net::Read<uint16_t >(packet); // castFlags (unused)
313391 const uint32_t castTime = Net::Read<uint32_t >(packet);
314392 packet->m_read = saved; // restore before the engine re-parses
315- RecordRemoteStart (caster, spellID, castTime);
393+ HandleSpellStart (caster, spellID, castTime);
316394 }
317395 return g_origSpellStart (unk, opCode, unk2, packet);
318396}
@@ -322,32 +400,55 @@ const Game::HookAutoRegister _spellStartHook{
322400 reinterpret_cast <void *>(&SpellStartHandler_h),
323401 reinterpret_cast <void **>(&g_origSpellStart)};
324402
325- } // namespace
326-
327- // Per-frame: detect the player's cast/channel start, stamp times.
328- void OnWorldTick () {
329- const int now = NowMs ();
403+ // Co-hook on the SMSG_SPELL_DELAYED handler — cast pushback. The server
404+ // only sends it to the affected caster, so it's effectively always the
405+ // local player; extend the tracked cast's end time (and report the
406+ // accumulated delay as delayTimeMs) so an addon's bar stretches on damage.
407+ using SpellDelayed_t = int (__stdcall *)(uint32_t *opCode,
408+ Net::CDataStore *packet);
409+ SpellDelayed_t g_origSpellDelayed = nullptr ;
330410
331- const int cast = *reinterpret_cast <const int *>(Offsets::VAR_CURRENT_CAST_SPELL );
332- if (cast != g_lastCast) {
333- const int dur = (cast != 0 ) ? CastTimeMs (cast) : 0 ;
334- if (cast != 0 && dur > 0 ) {
335- g_cast = {cast, now, now + dur};
336- } else {
337- g_cast.spellID = 0 ; // 0, instant, or channel — no cast bar
411+ int __stdcall SpellDelayed_h (uint32_t *opCode, Net::CDataStore *packet) {
412+ if (packet != nullptr ) {
413+ const uint32_t saved = packet->m_read ;
414+ const uint64_t guid = Net::Read<uint64_t >(packet);
415+ const uint32_t delay = Net::Read<uint32_t >(packet);
416+ packet->m_read = saved;
417+ if (delay != 0 && g_cast.spellID != 0 &&
418+ guid == Unit::Identity::PlayerGuid ()) {
419+ g_cast.endMs += static_cast <int >(delay);
420+ g_cast.delayMs += static_cast <int >(delay);
338421 }
339- g_lastCast = cast;
340422 }
423+ return g_origSpellDelayed (opCode, packet);
424+ }
341425
342- const int chan = PlayerChannelSpell ();
343- if (chan != g_lastChannel) {
344- if (chan != 0 ) {
345- g_channel = {chan, now, now + ChannelDurationMs (chan)};
346- } else {
347- g_channel.spellID = 0 ;
348- }
349- g_lastChannel = chan;
350- }
426+ const Game::HookAutoRegister _spellDelayedHook{
427+ Offsets::FUN_SPELL_DELAYED , reinterpret_cast <void *>(&SpellDelayed_h),
428+ reinterpret_cast <void **>(&g_origSpellDelayed)};
429+
430+ } // namespace
431+
432+ // Per-frame upkeep for the player. The regular cast is stamped by the
433+ // FUN_CAST_START_SET hook; here we clear it when the engine's cast global
434+ // drops to 0, and poll the broadcast +0x228 field for the channel (which
435+ // has no comparable client-side start hook).
436+ void OnWorldTick () {
437+ // Clear a CLIENT-tracked cast when the cast global returns to 0 — covering
438+ // normal end, cancel, and interrupt. No grace window is needed: the
439+ // FUN_CAST_START_SET hook stamps g_cast in the same call that writes the
440+ // global non-zero, so it's never transiently 0 right after a stamp.
441+ // Server-stamped chained casts (g_castFromServer) never set the global,
442+ // so they're exempt — they expire on their computed endMs (self-expiry in
443+ // PushCastInfo).
444+ if (g_cast.spellID != 0 && !g_castFromServer &&
445+ *reinterpret_cast <const int *>(Offsets::VAR_CURRENT_CAST_SPELL ) == 0 )
446+ g_cast.spellID = 0 ;
447+
448+ // Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
449+ // on their computed endMs (self-expiry in PushChannelInfo). We don't poll
450+ // the broadcast +0x228 field for the player — it lags the channel start by
451+ // ~1 tick, so the bar would show ~1s late.
351452}
352453
353454static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
0 commit comments