Compare commits

...
1 Commits
Author SHA1 Message Date
Brues b8d55e8dea Back UnitCastingInfo / UnitChannelInfo for remote units
Vanilla stores no cast record for other units, so UnitCastingInfo(other)
returned nil and remote channels had no timing. Co-hook the SMSG_SPELL_START
handler (0x006E7640, gated on opcode 0x131) -- the only packet carrying
another unit's cast with a server-authoritative cast time -- and cache it
per caster GUID (cast vs. channel split by AttributesEx channel bits).

- UnitCastingInfo(unit): full timing for any unit within its cast window;
  enemy/target/focus/nameplate cast bars now work, not just the player.
- UnitChannelInfo(unit): remote channels get real start/end times when the
  channel's begin was observed, validated against the live +0x228 field so a
  stale entry never sticks; falls back to the old no-times path otherwise.
- Player path unchanged (self-tracked); the cache skips caster==PlayerGuid.

Factor the CDataStore packet reader out of Aura::Source into a shared
Net::PacketReader header (vtable-correct) used by both packet hooks.

Best-effort: only casts begun while the unit was in range are seen, and an
interrupted remote cast lingers until its computed end (1.12 sends no
per-unit interrupt packet to hook).
2026-06-25 00:45:38 -05:00
5 changed files with 252 additions and 62 deletions
+22 -16
View File
@@ -7951,15 +7951,19 @@ C_Spell.UnitCastingInfo("player")
`startTimeMs`/`endTimeMs` share `GetTime()`'s epoch (`GetTime()*1000`),
so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> **Local player only.** Vanilla 1.12 stores no cast times anywhere
> readable (the cast bar is Lua-driven off `SPELLCAST_START`), and it
> tracks regular casts for nobody but you — other units' casts exist
> only as transient animation visuals. So `C_Spell.UnitCastingInfo(otherUnit)`
> returns `nil`. We self-track the player's cast on a per-frame tick
> (`VAR_CURRENT_CAST_SPELL`), stamping `startTimeMs = now` and
> `endTimeMs = now + base cast time`. Instant casts return `nil` (no
> cast bar). See [`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)
> for channels, which *are* visible on other units.
> **Works for any unit.** Vanilla 1.12 stores no cast times on the
> CGUnit (the cast bar is Lua-driven off `SPELLCAST_START`), so we
> source them two ways: the **local player** is self-tracked on a
> per-frame tick (`VAR_CURRENT_CAST_SPELL`, stamping `startTimeMs = now`,
> `endTimeMs = now + effective cast time`); **other units** come from a
> co-hook on `SMSG_SPELL_START` (`Spell::Cast`), the one packet carrying
> another unit's cast with a server-authoritative cast time, cached per
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
> cast bar). Best-effort for remote units: only casts that began while the
> unit was in range are seen, and — since 1.12 has no per-unit interrupt
> packet to hook — an *interrupted* remote cast lingers until its computed
> `endTimeMs` rather than clearing instantly.
>
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
> profession recipe casts (smelting, cooking/enchanting recipes, …)
@@ -7991,13 +7995,15 @@ C_Spell.ChannelInfo()
-- 907894314, 907902314, false, false, 10187
```
Unlike casts, the channeled spellID **is** broadcast for every unit
(`UNIT_FIELD_CHANNEL_SPELL`), so `C_Spell.UnitChannelInfo("target")`
works on other units — returning `name`/`displayName`/`textureID`/`spellID`
with **`nil` start/end times** (we only track the local player's channel
start). For the player, the full timing is present
(`endTimeMs - startTimeMs` = channel duration). Same placeholder fields
as `C_Spell.UnitCastingInfo`.
The channeled spellID **is** broadcast for every unit
(`UNIT_FIELD_CHANNEL_SPELL`), so `C_Spell.UnitChannelInfo("target")` works
on other units. Timing now comes from the same `SMSG_SPELL_START` co-hook
as casts: when we observed the channel begin, the remote unit returns full
**start/end times** (validated against the live `UNIT_FIELD_CHANNEL_SPELL`
so a stale cache entry never applies to a different/ended channel);
otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.
## SpellBook
+11
View File
@@ -695,6 +695,17 @@ enum Offsets {
// same Octo/Turtle 1.12.1 client). Per-cast frequency, not per-frame.
FUN_SPELL_GO = 0x006E7A70,
// SMSG packet handler that processes `SMSG_SPELL_START` (opcode
// `0x131`) among others — `int __fastcall(uint32_t unk, uint32_t
// opCode, uint32_t unk2, CDataStore *packet)`. For `0x131` the body is
// `itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16),
// castTime(u32), targetMask, …`. This is the only place the client sees
// *another* unit's cast with a server-authoritative cast time, so
// `Spell::Cast` co-hooks it (gated on `opCode == 0x131`) to back
// remote-unit `UnitCastingInfo` / `UnitChannelInfo`. Mirrored from
// nampower's SpellStartHandlerHook.
FUN_SPELL_START_HANDLER = 0x006E7640,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
+5 -36
View File
@@ -18,6 +18,7 @@
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -28,39 +29,7 @@ namespace Aura::Source {
namespace {
// Minimal view of the engine's CDataStore packet reader. Only the fields
// the read path touches; layout matches nampower's `CDataStore`. Incoming
// SMSG packets are a single contiguous buffer (`m_base == 0`,
// `m_alloc == size`), so the simple `m_buffer - m_base + m_read` read with
// an `m_size` bound is sufficient — same critical guard as the engine's
// `CDataStore::Get`.
//
// CDataStore is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
// so a vtable pointer occupies offset 0 and the data members start at
// +0x04. Omitting `vtable` here would shift every field by 4 and make
// `m_buffer` read the vptr — yielding a garbage read address (the cause of
// an early ACCESS_VIOLATION in the SpellGo hook).
struct CDataStore {
void *vtable; // +0x00
uint8_t *m_buffer; // +0x04
uint32_t m_base; // +0x08
uint32_t m_alloc; // +0x0C
uint32_t m_size; // +0x10
uint32_t m_read; // +0x14
};
template <typename T>
T ReadField(CDataStore *p) {
T val{};
const uint32_t end = p->m_read + sizeof(T);
if (end > p->m_size) {
p->m_read = p->m_size + 1; // poison: further reads short-circuit
return val;
}
val = *reinterpret_cast<const T *>(p->m_buffer - p->m_base + p->m_read);
p->m_read += sizeof(T);
return val;
}
using Net::CDataStore;
// A cast applies an aura iff any of its three effects names an aura. The
// EffectApplyAuraName[3] int32 array sits at `+0x16C` (already used by the
@@ -181,10 +150,10 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (packet != nullptr) {
const uint32_t savedRead = packet->m_read;
ReadField<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = ReadField<uint8_t>(packet);
Net::Read<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = Net::Read<uint8_t>(packet);
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = ReadField<uint64_t>(packet);
const uint64_t guid = Net::Read<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
#pragma once
#include <cstdint>
// Minimal reader for the engine's `CDataStore` packet buffer, shared by the
// packet co-hooks (`Aura::Source`'s SpellGo, `Spell::Cast`'s SpellStart).
//
// `CDataStore` is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
// so a vtable pointer sits at offset 0 and the data members start at +0x04.
// Omitting `vtable` shifts every field by 4 and makes `m_buffer` read the
// vptr — a garbage read address (this caused an early ACCESS_VIOLATION in
// the SpellGo hook before the slot was added).
//
// Incoming SMSG packets are a single contiguous buffer (`m_base == 0`,
// `m_alloc == size`), so the simple `m_buffer - m_base + m_read` read with
// an `m_size` bound is sufficient — the same critical guard as the engine's
// own `CDataStore::Get`. Callers should save `m_read` before reading and
// restore it before handing the packet to the original handler.
namespace Net {
struct CDataStore {
void *vtable; // +0x00
uint8_t *m_buffer; // +0x04
uint32_t m_base; // +0x08
uint32_t m_alloc; // +0x0C
uint32_t m_size; // +0x10
uint32_t m_read; // +0x14
};
// Reads a fixed-width field and advances the cursor. On overrun it poisons
// `m_read` (so subsequent reads short-circuit) and returns a zero value.
template <typename T>
T Read(CDataStore *p) {
T val{};
const uint32_t end = p->m_read + sizeof(T);
if (end > p->m_size) {
p->m_read = p->m_size + 1;
return val;
}
val = *reinterpret_cast<const T *>(p->m_buffer - p->m_base + p->m_read);
p->m_read += sizeof(T);
return val;
}
// Reads a WoW packed GUID: a 1-byte mask followed by one byte per set bit,
// each contributing the corresponding byte of the 64-bit value.
inline uint64_t ReadPackedGuid(CDataStore *p) {
uint64_t guid = 0;
const uint8_t mask = Read<uint8_t>(p);
for (int i = 0; i < 8; ++i) {
if (mask & (1u << i))
guid |= static_cast<uint64_t>(Read<uint8_t>(p)) << (i * 8);
}
return guid;
}
} // namespace Net
+146 -10
View File
@@ -30,13 +30,17 @@
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
//
// Scope (vanilla ceiling, confirmed against the binary — see TODO #68):
// - Player casts/channels: full timing.
// - Other units' CHANNELS: spellID/name/texture from the broadcast
// +0x228 field, but no times (we don't track per-remote-unit start).
// - Other units' regular CASTS: nil. Vanilla doesn't store them — the
// casting animation is driven by transient spell-visual objects, not
// a queryable cast record.
// Other units have no engine-stored cast record, so we capture their casts
// from the `SMSG_SPELL_START` packet (the only place the client sees
// another unit's cast + a server-authoritative cast time) into a per-caster
// cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
//
// Modern-signature fields vanilla can't fill are structurally-correct
// placeholders: castID / castBarID = nil, notInterruptible = false,
@@ -45,8 +49,10 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -204,6 +210,118 @@ bool ArgIsPlayer(void *L) {
return u != nullptr && u == Resolve("player");
}
// ---- Remote-unit cast tracking (SMSG_SPELL_START) ------------------------
// Vanilla stores no cast record for other units, so we capture their casts
// from SMSG_SPELL_START — the only packet carrying another unit's cast with
// a server-authoritative cast time. Keyed by caster GUID (a unit casts one
// thing at a time). Regular casts back `UnitCastingInfo`; channels add real
// times to `UnitChannelInfo` (validated against the live +0x228 field).
constexpr int OFF_ATTRIBUTES_EX = 0x1C; // Spell.dbc AttributesEx
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
bool IsChannelSpell(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
SPELL_ATTR_EX_CHANNELED) != 0;
}
// Channel duration for a non-player caster — base (skipMod=1), since we
// only have the local player's spell-mod tables.
int RemoteChannelDurationMs(const uint8_t *rec) {
const int ms = reinterpret_cast<GetDuration_t>(
Offsets::FUN_GET_SPELL_DURATION)(rec, 0, /*skipMod*/ 1);
return ms > 0 ? ms : 0;
}
struct RemoteCast {
uint64_t casterGuid;
int spellID;
int startMs;
int endMs;
bool isChannel;
bool used;
};
constexpr int kRemoteCastSlots = 64;
RemoteCast g_remoteCasts[kRemoteCastSlots];
int g_remoteCursor = 0;
void StoreRemoteCast(uint64_t caster, int spellID, int startMs, int endMs,
bool isChannel) {
const RemoteCast entry{caster, spellID, startMs, endMs, isChannel, true};
// One active cast per unit — replace any existing entry for this caster.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == caster) {
e = entry;
return;
}
}
const int now = NowMs();
for (auto &e : g_remoteCasts) {
if (!e.used || now >= e.endMs) {
e = entry;
return;
}
}
g_remoteCasts[g_remoteCursor] = entry;
g_remoteCursor = (g_remoteCursor + 1) % kRemoteCastSlots;
}
const RemoteCast *FindRemoteCast(uint64_t caster) {
if (caster == 0)
return nullptr;
for (const auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == caster)
return &e;
}
return nullptr;
}
void RecordRemoteStart(uint64_t caster, int spellID, uint32_t castTime) {
if (caster == 0 || spellID == 0)
return;
// The local player uses the self-tracked g_cast / g_channel path.
if (caster == Unit::Identity::PlayerGuid())
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
const int now = NowMs();
const bool channel = IsChannelSpell(rec);
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
// other packet this handler processes is a cheap early-out. Packet layout
// mirrors nampower's SpellStartHandlerHook.
using SpellStartHandler_t = int(__fastcall *)(uint32_t unk, uint32_t opCode,
uint32_t unk2,
Net::CDataStore *packet);
SpellStartHandler_t g_origSpellStart = nullptr;
int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
uint32_t unk2, Net::CDataStore *packet) {
if (opCode == 0x131 && packet != nullptr) {
const uint32_t saved = packet->m_read;
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
packet->m_read = saved; // restore before the engine re-parses
RecordRemoteStart(caster, spellID, castTime);
}
return g_origSpellStart(unk, opCode, unk2, packet);
}
const Game::HookAutoRegister _spellStartHook{
Offsets::FUN_SPELL_START_HANDLER,
reinterpret_cast<void *>(&SpellStartHandler_h),
reinterpret_cast<void **>(&g_origSpellStart)};
} // namespace
// Per-frame: detect the player's cast/channel start, stamp times.
@@ -237,15 +355,25 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// `CastingInfo()` — local player's cast, no token lookup.
static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cast); }
// `UnitCastingInfo(unit)` — only the local player has regular-cast data
// in vanilla; any other unit returns nil.
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
return 0;
}
if (ArgIsPlayer(L))
const char *token = Game::Lua::ToString(L, 1);
void *u = (token != nullptr) ? Resolve(token) : nullptr;
if (u == nullptr)
return 0;
if (u == Resolve("player"))
return PushCastInfo(L, g_cast);
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
if (rc != nullptr && !rc->isChannel && NowMs() < rc->endMs)
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs});
return 0;
}
@@ -272,7 +400,15 @@ static int __fastcall Script_UnitChannelInfo(void *L) {
static_cast<const uint8_t *>(u) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
// The live +0x228 field is authoritative for "is this unit channeling
// right now"; the SMSG_SPELL_START cache adds real start/end times when
// we observed the channel begin (and still matches the current spell).
const int spellID = *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (spellID == 0)
return 0;
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
if (rc != nullptr && rc->isChannel && rc->spellID == spellID && NowMs() < rc->endMs)
return PushChannelInfo(L, spellID, rc->startMs, rc->endMs, /*haveTimes*/ true);
return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false);
}