Skip to content

Commit 0677de7

Browse files
committed
spell: backport UNIT_SPELLCAST_* events for the local player
Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED, INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP) for the player, so cast-bar / rotation addons written against the modern signature work on 1.12. Each carries (unit, castGUID, spellID, spellName, rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's events so START pairs with STOP. arg1 is always "player" (player-only phase). The events are derived, not independently tracked: Spell::Cast already hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so Spell::CastEvents reuses that state. - START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick against the previous snapshot. - SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too). Deferred for channels so it lands after CHANNEL_START (modern order). - INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED (repeated per retry, reusing the cast guid); a pre-cast rejection fires FAILED. - SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID forward so all of a cast's events share one guid. - CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also re-anchors g_channel.endMs to the server's remaining time so channel pushback (which shortens vanilla channels) shows in UnitChannelInfo. Every fire is gated on Event::Custom::HasListeners (new), so the system costs one pointer-compare per transition when no addon registers. Supporting changes: - Net::SendObserver: shared co-hook on the NetClient send funnel with an AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point capture) both watch outgoing packets without owning the hook. Extracted from ComboDuration's private hook. - Cast.cpp: player channel now self-expires at its computed endMs in the poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP trailed the real end by ~1s); wrap-safe delta compare so a long-uptime ms tick past 2^31 can't misfire it. Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are not implemented.
1 parent 5d785ea commit 0677de7

12 files changed

Lines changed: 795 additions & 23 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ when launching with `-console`), not as Lua functions. See the
155155
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
156156
| `QUEST_REMOVED` | `questID` |
157157
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
158+
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
159+
| `UNIT_SPELLCAST_START` | `"player", castGUID, spellID, spellName, rank` |
160+
| `UNIT_SPELLCAST_STOP` | `"player", castGUID, spellID, spellName, rank` |
161+
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
162+
| `UNIT_SPELLCAST_SUCCEEDED` | `"player", castGUID, spellID, spellName, rank` |
163+
| `UNIT_SPELLCAST_INTERRUPTED` | `"player", castGUID, spellID, spellName, rank` |
164+
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
165+
| `UNIT_SPELLCAST_CHANNEL_START` | `"player", castGUID, spellID, spellName, rank` |
166+
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
167+
| `UNIT_SPELLCAST_CHANNEL_STOP` | `"player", castGUID, spellID, spellName, rank` |
158168
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
159169
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
160170
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |

docs/API.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ build instructions.
441441
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
442442
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
443443
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
444+
- [`UNIT_SPELLCAST_*` events (player)](#unit_spellcast_-events-player)
444445
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
445446
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
446447

@@ -10829,6 +10830,84 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
1082910830
**`nil` times**. The player path is unchanged (full timing). Same
1083010831
placeholder fields as `C_Spell.UnitCastingInfo`.
1083110832

10833+
### `UNIT_SPELLCAST_*` events (player)
10834+
10835+
Backport of the TBC+ cast/channel events to 1.12 for the **local player**.
10836+
Ported cast-bar / rotation addons (anything written against the modern
10837+
signature) register these instead of vanilla's arg-less `SPELLCAST_*`
10838+
events and read `unit, castGUID, spellID` directly. Ten events are
10839+
provided:
10840+
10841+
| Event | Fires when | Args |
10842+
|-------|-----------|------|
10843+
| `UNIT_SPELLCAST_SENT` | `CMSG_CAST_SPELL` leaves the client (earliest point) | `unit, target, castGUID, spellID, spellName, rank` |
10844+
| `UNIT_SPELLCAST_START` | a cast-time spell begins | `unit, castGUID, spellID, spellName, rank` |
10845+
| `UNIT_SPELLCAST_STOP` | a cast-time spell ends (any reason) | same |
10846+
| `UNIT_SPELLCAST_DELAYED` | pushback extends the cast | same |
10847+
| `UNIT_SPELLCAST_SUCCEEDED` | the spell goes off (`SMSG_SPELL_GO`) — incl. instants | same |
10848+
| `UNIT_SPELLCAST_INTERRUPTED` | a started cast is interrupted (kick, movement, LoS) | same |
10849+
| `UNIT_SPELLCAST_FAILED` | a cast is rejected before it starts (range, mana, cooldown) | same |
10850+
| `UNIT_SPELLCAST_CHANNEL_START` | a channel begins | same |
10851+
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | pushback shortens a channel | same |
10852+
| `UNIT_SPELLCAST_CHANNEL_STOP` | a channel ends | same |
10853+
10854+
`unit` (arg1) is always `"player"` — this is the player-only phase; other
10855+
units aren't fanned out yet. `spellName` / `rank` are ClassicAPI tail
10856+
extensions (modern stops at `spellID`); addons reading only the first three
10857+
positional args are unaffected.
10858+
10859+
**castGUID.** A synthesized string in the modern shape
10860+
`Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`. Vanilla
10861+
can't know server / instance / zone, so those three fields are `0`; the
10862+
load-bearing parts are the `spellID` (field 6, which addons `strsplit("-")`
10863+
out) and a unique-per-cast `castUID` (field 7). **Every event of one cast
10864+
carries the same castGUID**, so `SENT` → `START`/`CHANNEL_START` →
10865+
`SUCCEEDED` → `STOP`/`CHANNEL_STOP` all pair up — a chained same-spell
10866+
recast gets its own castUID.
10867+
10868+
**Ordering** matches modern:
10869+
10870+
- Cast-time spell: `SENT → START → SUCCEEDED → STOP`.
10871+
- Channel: `SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP` (CHANNEL_START
10872+
before SUCCEEDED, as on retail).
10873+
- Instant: `SENT → SUCCEEDED`.
10874+
10875+
**INTERRUPTED vs FAILED** follow modern's split: a spell that never started
10876+
(out of range, not enough mana, on cooldown, LoS to a target) fires
10877+
`FAILED`; a spell that was *already casting* and gets stopped (an enemy
10878+
kick, moving to cancel, breaking LoS mid-cast) fires `INTERRUPTED`. Holding
10879+
the cast key while running fires `INTERRUPTED` repeatedly (once per retry),
10880+
each reusing the interrupted cast's castGUID — matching retail.
10881+
10882+
**Channel pushback.** Taking damage while channeling shortens the channel in
10883+
vanilla; `CHANNEL_UPDATE` fires on each hit and
10884+
[`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)'s
10885+
`endTimeMs` re-anchors to the server's new remaining time, so cast bars
10886+
shrink correctly. (The event carries no time — like retail it's a "re-read
10887+
now" trigger; timing is read back from `UnitChannelInfo`.)
10888+
10889+
Every fire is gated on whether any frame is registered for that event, so
10890+
the whole system costs one pointer-compare per state transition when no
10891+
addon uses it (no arg synthesis, no DBC lookups).
10892+
10893+
```lua
10894+
local f = CreateFrame("Frame")
10895+
for _, e in ipairs({
10896+
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
10897+
"UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
10898+
}) do f:RegisterEvent(e) end
10899+
f:SetScript("OnEvent", function()
10900+
-- vanilla passes event/arg1/... as globals, not function params
10901+
if arg1 == "player" then print(event, arg3) end -- arg3 = spellID
10902+
end)
10903+
```
10904+
10905+
> **Player-only, and additive to the vanilla `SPELLCAST_*` events.** The
10906+
> engine's own arg-less `SPELLCAST_START` / `SPELLCAST_CHANNEL_UPDATE` / …
10907+
> still fire as before; these `UNIT_`-prefixed events are the modern layer
10908+
> on top. Other units, `UNIT_SPELLCAST_FAILED_QUIET`, and the empowered-cast
10909+
> events are not implemented.
10910+
1083210911
### `C_Spell.GetSpellLevelInfo(spellID)`
1083310912

1083410913
Returns the raw `Spell.dbc` level fields for a spell:

src/Offsets.h

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,20 @@ enum Offsets {
12161216
// collision, per-channel-start frequency).
12171217
FUN_SPELL_CHANNEL_START = 0x006E7550,
12181218

1219+
// `MSG_CHANNEL_UPDATE` handler (`FUN_006e75f0`) — same `int __stdcall(
1220+
// uint32_t *opCode, CDataStore *packet)` shape. Body: a single u32 =
1221+
// the channel's new REMAINING time (ms). Sent to the caster on damage
1222+
// pushback (which shortens a vanilla channel) and once more at the
1223+
// channel's end (remaining == 0). Verified at 0x006e75f0: reads the u32,
1224+
// then fires vanilla's `SPELLCAST_CHANNEL_UPDATE(remaining)` (event
1225+
// 0x157) on pushback or `SPELLCAST_CHANNEL_STOP` (0x158) at end — but
1226+
// stores the new end NOWHERE, so `Spell::Cast`'s g_channel.endMs
1227+
// (computed once at start) never reflects pushback. Co-hooked to
1228+
// re-anchor endMs (and fire UNIT_SPELLCAST_CHANNEL_UPDATE). nampower
1229+
// hooks it too (SpellChannelUpdateHandlerHook — accepted co-hook, per-
1230+
// pushback frequency, same as the START sibling).
1231+
FUN_SPELL_CHANNEL_UPDATE = 0x006E75F0,
1232+
12191233
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
12201234
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
12211235
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
@@ -1245,6 +1259,21 @@ enum Offsets {
12451259
// hook it.
12461260
FUN_SPELL_FAILURE = 0x006E8D80,
12471261

1262+
// `Spell_C_SpellFailed` — the CLIENT-side cast-failure entry, distinct
1263+
// from the two SMSG_SPELL_FAILURE/_OTHER packet handlers above. Fires
1264+
// for the local player's own cast rejections (out of range, no mana,
1265+
// "spell not ready", LoS, interrupted, …). `__fastcall(uint32_t
1266+
// spellId, uint8_t spellResult /*edx*/, int unk1, int unk2, bool
1267+
// failedByServer)` — signature per nampower's Spell_C_SpellFailedT.
1268+
// Backs `UNIT_SPELLCAST_FAILED` (`Spell::CastEvents`). Not hooked
1269+
// elsewhere in this project (we hook the packet handlers, not this).
1270+
FUN_SPELL_C_SPELL_FAILED = 0x006E1A00,
1271+
1272+
// `Spell_C_SpellFailed` result code for a fake/suppressed failure
1273+
// (Unleashed Potential and similar) — the engine's SPELL_FAILED_DONT_
1274+
// REPORT. nampower filters it out of its failure event; so do we.
1275+
SPELL_FAILED_DONT_REPORT = 23,
1276+
12481277
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
12491278
// int spellID, char notify, char cleanup)` (nampower names the offset
12501279
// but never hooks it). THE engine choke point for "this unit stopped

src/aura/ComboDuration.cpp

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "Offsets.h"
2222
#include "dbc/Lookup.h"
2323
#include "net/PacketReader.h"
24+
#include "net/SendObserver.h"
2425
#include "spell/Mod.h"
2526
#include "unit/Identity.h"
2627

@@ -86,28 +87,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
8687
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
8788
// Snapshot the combo points at the moment the cast request leaves: the
8889
// server consumes them before SMSG_SPELL_GO arrives, so reading at
89-
// SpellGo time is too late.
90-
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
91-
CDataStore *packet);
92-
NetSend_t g_origNetSend = nullptr;
93-
94-
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
95-
if (packet != nullptr) {
96-
const uint32_t saved = packet->m_read;
97-
const uint32_t opcode = Net::Read<uint32_t>(packet);
98-
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
99-
const uint32_t spellId = Net::Read<uint32_t>(packet);
100-
if (spellId != 0)
101-
g_capture = {spellId, CurrentComboPoints(), NowMs()};
102-
}
103-
packet->m_read = saved;
104-
}
105-
g_origNetSend(conn, edx, packet);
90+
// SpellGo time is too late. We watch sends through the shared
91+
// `Net::SendObserver` rather than owning the funnel hook ourselves.
92+
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
93+
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
94+
return;
95+
const uint32_t spellId = Net::Read<uint32_t>(packet);
96+
if (spellId != 0)
97+
g_capture = {spellId, CurrentComboPoints(), NowMs()};
10698
}
10799

108-
const Game::HookAutoRegister _sendHook{
109-
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
110-
reinterpret_cast<void **>(&g_origNetSend)};
100+
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
111101

112102
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
113103

src/aura/Source.cpp

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "Offsets.h"
2222
#include "net/PacketReader.h"
2323
#include "player/StatSignal.h"
24+
#include "spell/CastEvents.h"
2425
#include "spell/Lookup.h"
2526
#include "tick/WorldTick.h"
2627
#include "totem/Tracker.h"
@@ -581,10 +582,14 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
581582
if (caster == 0 || spellId == 0)
582583
return;
583584

584-
// Feed the totem tracker BEFORE the aura gate below — a totem summon
585-
// applies no aura, so it would otherwise be dropped. Player casts only.
586-
if (caster == Unit::Identity::PlayerGuid())
585+
// Feed the totem tracker + fire UNIT_SPELLCAST_SUCCEEDED BEFORE the aura
586+
// gate below — a totem summon (and any non-aura spell) applies no aura,
587+
// so it would otherwise be dropped. SPELL_GO is "the spell went off", so
588+
// this is the succeeded signal for instants too. Player casts only.
589+
if (caster == Unit::Identity::PlayerGuid()) {
587590
Totem::Tracker::OnPlayerSpellGo(spellId);
591+
Spell::CastEvents::OnPlayerSucceeded(static_cast<int>(spellId));
592+
}
588593

589594
// Mirror server-side duration edits the client is never told about
590595
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs

src/event/Custom.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,22 @@ int LookupByName(const char *name) {
282282
return -1;
283283
}
284284

285+
bool HasListeners(int slot) {
286+
if (slot < 0)
287+
return false;
288+
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
289+
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
290+
if (base == nullptr || slot >= count)
291+
return false;
292+
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
293+
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
294+
// value is the empty self-sentinel — same test the grow-safety scan
295+
// uses. So this is true iff at least one frame registered for the event.
296+
const uint32_t head = *reinterpret_cast<const uint32_t *>(
297+
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
298+
return head != 0 && (head & 1) == 0;
299+
}
300+
285301
void RetryClaims() {
286302
// Grow the table first if the low gap pool is short (dormant valve).
287303
// Runs at most once per table build; on the first call every chain is

src/event/Custom.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ int Lookup(const char *name);
6464
// fire ourselves (e.g., polyfilling missing event dispatches).
6565
int LookupByName(const char *name);
6666

67+
// True iff at least one frame is currently registered for the event in
68+
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
69+
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
70+
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
71+
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
72+
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
73+
// past the live table.
74+
bool HasListeners(int slot);
75+
6776
// Dispatches a custom event via the engine's printf-style event
6877
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
6978
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`

src/net/SendObserver.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// This file is part of ClassicAPI.
2+
//
3+
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
4+
// of the GNU General Public License as published by the Free Software Foundation, either
5+
// version 3 of the License, or (at your option) any later version.
6+
//
7+
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
8+
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
9+
// PURPOSE. See the GNU General Public License for more details.
10+
//
11+
// You should have received a copy of the GNU General Public License along with
12+
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
13+
14+
#include "SendObserver.h"
15+
16+
#include "Game.h"
17+
#include "Offsets.h"
18+
19+
namespace Net::SendObserver {
20+
21+
namespace {
22+
23+
AutoSubscribe *g_head = nullptr;
24+
25+
// `FUN_NET_SEND` — `__thiscall(conn, CDataStore*)`, rendered here as
26+
// `__fastcall(conn /*ecx*/, edx, packet)`. The leading u32 of the buffer is
27+
// the opcode.
28+
using NetSend_t = void(__fastcall *)(void *conn, void *edx, CDataStore *packet);
29+
NetSend_t g_origNetSend = nullptr;
30+
31+
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
32+
if (packet != nullptr && g_head != nullptr) {
33+
const uint32_t saved = packet->m_read;
34+
const uint32_t opcode = Net::Read<uint32_t>(packet);
35+
const uint32_t afterOpcode = packet->m_read;
36+
for (auto *node = g_head; node != nullptr; node = node->next) {
37+
packet->m_read = afterOpcode; // each subscriber reads independently
38+
node->cb(opcode, packet);
39+
}
40+
packet->m_read = saved; // hand the engine an untouched cursor
41+
}
42+
g_origNetSend(conn, edx, packet);
43+
}
44+
45+
} // namespace
46+
47+
AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_head) {
48+
g_head = this;
49+
}
50+
51+
static const Game::HookAutoRegister _hook{
52+
Offsets::FUN_NET_SEND,
53+
reinterpret_cast<void *>(&NetSend_h),
54+
reinterpret_cast<void **>(&g_origNetSend)};
55+
56+
} // namespace Net::SendObserver

src/net/SendObserver.h

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// This file is part of ClassicAPI.
2+
//
3+
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
4+
// of the GNU General Public License as published by the Free Software Foundation, either
5+
// version 3 of the License, or (at your option) any later version.
6+
//
7+
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
8+
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
9+
// PURPOSE. See the GNU General Public License for more details.
10+
11+
#pragma once
12+
13+
#include "net/PacketReader.h"
14+
15+
#include <cstdint>
16+
17+
// Shared observer for outgoing packets. The engine's NetClient send funnel
18+
// (`FUN_NET_SEND`) is a general chokepoint every dispatched CMSG passes
19+
// through — not something a single feature should own. MinHook permits one
20+
// hook per target, so any module that wants to watch sent packets subscribes
21+
// here instead of hooking the funnel itself.
22+
//
23+
// Mirrors `Tick::WorldTick`: declare a file-scope
24+
// `static const Net::SendObserver::AutoSubscribe _sub{&callback};`. The
25+
// constructor chains onto the internal list at static-init time.
26+
//
27+
// Each subscriber's callback receives the leading `opcode` (u32) already
28+
// read, and the `CDataStore` positioned **right after the opcode** so it can
29+
// read the message body directly. The observer resets that cursor before
30+
// every subscriber (so they read independently) and restores the original
31+
// `m_read` before handing the packet to the engine. Callbacks must only
32+
// READ — never mutate the packet.
33+
34+
namespace Net::SendObserver {
35+
36+
using Callback = void (*)(uint32_t opcode, CDataStore *packet);
37+
38+
struct AutoSubscribe {
39+
explicit AutoSubscribe(Callback cb);
40+
Callback cb;
41+
AutoSubscribe *next;
42+
};
43+
44+
} // namespace Net::SendObserver

0 commit comments

Comments
 (0)