Source-generated packet dispatch
Every inbound packet used to find its handler through runtime reflection. This release replaces
that with tables built at compile time by a Roslyn source generator — 827 opcodes, in both
directions: the modern client's CMSGs and the legacy emulator's SMSGs.
Nothing changes in how you run it. No new configuration, no behaviour change, no wire change.
If the proxy worked for you on v4.4.29 it works the same way here, and you do not need to touch
your config.
What you may notice
Per-packet allocation on the inbound path is gone or down to the minimum the data requires, which
means less garbage-collection pressure over a long session. In a battleground against bots,
client-to-server allocation fell from ~18.3 KB per packet to ~1.5 KB, and GC pressure at a matched
packet rate went from 23 gen0 collections per minute to 4-8, with pause time at 0.02-0.09%.
That live figure is not all down to dispatch — a pooled-buffer disposal fix in the same release
accounts for most of it. The dispatch conversion itself is worth about 360 bytes per packet.
Measured in isolation, on an idle Apple M4:
| packet shape | before | after |
|---|---|---|
| value-type fields | 121.90 ns, 368 B | 1.95 ns, 0 B |
| two strings | 163.36 ns, 552 B | 23.64 ns, 104 B |
| 40-element array | 239.21 ns, 1008 B | 40.93 ns, 216 B |
62-68x on packets made only of value types, with allocation reaching zero. Packets carrying
strings or lists reach 5.8-6.9x and stop at the floor the data itself costs — 104 B is the two
strings, 216 B is the list.
Two figures this release does not claim. Reflective dispatch was only 1.3% of CPU to begin
with, so this is an allocation and startup win rather than a throughput one. And on the legacy
(emulator-facing) side the per-packet cost was already effectively zero — 0.810 ns before against
0.767 ns after, which is noise. That half of the work bought a uniform dispatch shape and removed
a reflection scan that previously ran once per connection at startup, not faster packets.
Verified on
Played end to end on three client versions, each compared against a pre-change run on the same
backend, with no opcode newly unhandled and no handler exceptions:
| client | backend |
|---|---|
| 3.4.3 WotLK Classic | AzerothCore with playerbots, two full battlegrounds |
| 1.14.2 Classic Era | cMaNGOS vanilla |
| 2.5.3 TBC Classic | cMaNGOS TBC |
Plus 2471 automated tests, including byte-level equivalence for every converted packet against a
frozen copy of the reader it replaced.
If you build on this codebase
This is why the minor version moved rather than the patch. PacketHandlerAttribute and both
reflective handler registries are removed. Anything that discovered handlers by attribute, or
subclassed ClientPacket to add one, needs updating:
- Inbound client packets are now
readonly record structwith a separate codec, and handlers are
public staticmethods marked[HandlesCmsg(Opcode.X)]taking(in TPacket, in SessionContext). - Legacy emulator handlers stay instance methods on
WorldClientbut are marked[HandlesSmsg]. - Version differences are declared as attribute data (
AddedIn/RemovedIn) and resolved once
when the table is built, instead of being re-evaluated inside the handler on every packet. - A new generated reference,
docs/opcode-coverage.md, lists which opcodes are handled for which
build and where packet layouts diverge between client versions.
Forks tracking master should expect conflicts in World/Server/PacketHandlers/ and
World/Client/PacketHandlers/; handler bodies were moved rather than rewritten, so the conflicts
are mostly relocation rather than logic changes.
What's Changed
Performance
Full Changelog: v4.4.29...v4.5.0