Skip to content

Commit 2403ff7

Browse files
committed
Detect player cast/channel from packets, not just the cast global
The 1.12 engine short-circuits a chained same-spell recast: Spell_C_CastSpell bails at `spellID == VAR_CURRENT_CAST_SPELL` before setting the global or firing SPELLCAST_START, so the client never runs its cast path for the 2nd of a back-to-back same-spell cast (common with nampower spell queueing). And the player channel field (+0x228) is server-pushed, landing ~1 tick after the channel starts, so polling it shows the bar ~1s late. Rework the player UnitCastingInfo / UnitChannelInfo tracking into a hybrid: - Normal casts: co-hook FUN_CAST_START_SET, stamp g_cast from the spellID arg before the original (zero latency); cleared by the VAR==0 WorldTick poll. - Chained same-spell recasts: stamp g_cast from the SMSG_SPELL_START co-hook (server castTime), deduped against a fresh client stamp, exempt from the VAR clear, self-expiring on endMs. - Channels: stamp g_channel from the same packet (engine-computed duration) instead of the lagging +0x228 poll; self-expire on endMs. - Cast and channel are mutually exclusive for the player. - Co-hook SMSG_SPELL_DELAYED for cast pushback (extends endMs + delayMs). Add FUN_SPELL_DELAYED and FUN_CAST_START_SET offsets.
1 parent b8d55e8 commit 2403ff7

3 files changed

Lines changed: 194 additions & 65 deletions

File tree

docs/API.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7960,10 +7960,18 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
79607960
> another unit's cast with a server-authoritative cast time, cached per
79617961
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
79627962
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
7963-
> cast bar). Best-effort for remote units: only casts that began while the
7964-
> unit was in range are seen, and — since 1.12 has no per-unit interrupt
7965-
> packet to hook — an *interrupted* remote cast lingers until its computed
7966-
> `endTimeMs` rather than clearing instantly.
7963+
> cast bar). Best-effort for remote units, in three ways: only casts that
7964+
> began while the unit was in range are seen; since 1.12 has no per-unit
7965+
> interrupt packet to hook, an *interrupted* remote cast lingers until its
7966+
> computed `endTimeMs` rather than clearing instantly; and the remote
7967+
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
7968+
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
7969+
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
7970+
> because its packet carries *both* total and remaining cast time and
7971+
> back-dates `start = now − elapsed`; the 1.12 packet has no field to
7972+
> recover `elapsed` from.) The **local player is unaffected** by all of
7973+
> this — its cast is detected client-side at the moment of cast, no packet
7974+
> involved.
79677975
>
79687976
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
79697977
> profession recipe casts (smelting, cooking/enchanting recipes, …)

src/Offsets.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,14 @@ enum Offsets {
706706
// nampower's SpellStartHandlerHook.
707707
FUN_SPELL_START_HANDLER = 0x006E7640,
708708

709+
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
710+
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
711+
// only sends this to the affected caster, so in practice it's always
712+
// the local player's cast pushback (taking damage mid-cast). `Spell::
713+
// Cast` co-hooks it to extend the tracked cast's end time so the bar
714+
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
715+
FUN_SPELL_DELAYED = 0x006E74F0,
716+
709717
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
710718
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
711719
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -2365,6 +2373,18 @@ enum Offsets {
23652373
// `FUN_006E3D10`. Non-zero means "the cast bar is showing this
23662374
// spell"; cleared to 0 when the cast finishes or is cancelled.
23672375
VAR_CURRENT_CAST_SPELL = 0x00CECA88,
2376+
// The cast-start state writer — `__fastcall(int spellID, int
2377+
// targetState)`. Sets `VAR_CURRENT_CAST_SPELL` (pushing any current
2378+
// spell into `VAR_QUEUED_CAST_SPELL`; `spellID == 0` restores the queued
2379+
// one). `Spell::Cast` co-hooks it for lag-free, client-side detection of
2380+
// NORMAL player casts (stamps from the spellID arg before the original).
2381+
// It does NOT fire for a chained same-spell recast: the caller
2382+
// `Spell_C_CastSpell` (`0x006E4B60`) short-circuits at `spellID ==
2383+
// VAR_CURRENT_CAST_SPELL` *before* reaching here, so those never run the
2384+
// client cast path — they're caught by the `SMSG_SPELL_START` backstop
2385+
// instead. nampower hooks the higher Spell_C_CastSpell, not this inner
2386+
// writer, so contention is low.
2387+
FUN_CAST_START_SET = 0x006E4AD0,
23682388
// Previous / queued cast — holds the spellID that was active
23692389
// before the latest one superseded it (mid-GCD queueing in
23702390
// vanilla). When the current cast ends, `FUN_006E4AD0(0, ...)`

src/spell/Cast.cpp

Lines changed: 162 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,26 @@
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

91107
int 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-
131135
const 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) {
156160
int 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) {
179189
int 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

353454
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};

0 commit comments

Comments
 (0)