Skip to content

Packets and handlers

Moongate.Network.Packets defines wire formats independently of TCP and the game server. PacketRegistry describes frames and decodes incoming packets; IPacketHandler<TPacket> supplies synchronous game behavior, while IAsyncPacketHandler<TPacket> handles packets that need I/O. These are separate registrations. The initial built-in formats target ClassicUO 7.x.

Lengths include the opcode and, for variable packets, the length header. Directions are relative to the server. This is the default table, not the whole UO protocol or a claim that the login sequence is implemented:

Opcode Class Direction Length Default host handler
0x55 LoginCompletePacket Outgoing Fixed 1
0x73 PingPacket Both Fixed 2 PingPacketHandler
0x80 AccountLoginPacket Incoming Fixed 62 AccountLoginPacketHandler (partial login)
0x82 LoginDeniedPacket Outgoing Fixed 2
0x8C ServerRedirectPacket Outgoing Fixed 11
0x91 GameLoginPacket Incoming Fixed 65 None
0xA0 ServerSelectPacket Incoming Fixed 3 None
0xA8 ServerListPacket Outgoing Variable, minimum 6
0xB9 SupportFeaturesPacket Outgoing Fixed 5
0xBD ClientVersionPacket Incoming Variable, minimum 4 ClientVersionPacketHandler
0xBD ClientVersionRequestPacket Outgoing Fixed 3
0xEF LoginSeedPacket Incoming Fixed 21 LoginSeedPacketHandler

The same opcode can have different definitions in each direction, as with 0xBD. TryGetDescriptor(opCode, out descriptor) prefers incoming, then outgoing; descriptor.PacketType.Name gives its class name. The overload accepting PacketDirection selects one direction explicitly when needed.

registry.TryDecode(bytes, out packet, out opCode) always sets opCode to the first byte, even on failure. Empty input returns false with opcode zero; inspect input length to distinguish that case. Decoding needs one complete incoming frame, including its header. It does not buffer a TCP stream. Outgoing-only packets have metadata but no incoming parser.

This example uses a private illustrative opcode. It is not an addition to the ClassicUO protocol; use it only with a matching test client. Reference Moongate.Network.Packets and place this in ExamplePacket.cs:

using System.Diagnostics.CodeAnalysis;
using Moongate.Network.Packets.Attributes;
using Moongate.Network.Packets.Base;
using Moongate.Network.Packets.Interfaces;
using Moongate.Network.Packets.Spans;
using Moongate.Network.Packets.Types.Packets;
[PacketHandler(0xFE, PacketSizing.Fixed, Length = 3)]
public sealed class ExamplePacket : BaseFixedPacket<ExamplePacket>,
IIncomingPacket<ExamplePacket>, IOutgoingPacket
{
public ushort Value { get; }
public ExamplePacket(ushort value)
{
Value = value;
}
public static bool TryParse(ReadOnlySpan<byte> data,
[NotNullWhen(true)] out ExamplePacket? packet)
{
packet = null;
if (!HasValidHeader(data))
{
return false;
}
var reader = new PacketReader(data[1..]);
if (!reader.TryReadUInt16BigEndian(out var value))
{
return false;
}
packet = new ExamplePacket(value);
return true;
}
public void Write(ref PacketWriter writer)
{
writer.EnsureCapacity(Length);
writer.WriteByte(OpCode);
writer.WriteUInt16BigEndian(Value);
}
}

The metadata attribute is named PacketHandler, but describes the wire packet; it does not register the game handler class. Call RegisterIncoming<T>() for a packet that implements IIncomingPacket<T> (a bidirectional packet covers both directions in one call), or RegisterOutgoing<T>() for one that implements only IOutgoingPacket. Duplicate types or conflicting opcode/direction pairs fail registration. Freeze only after composing the table.

Run this Program.cs as a byte-level smoke test without a server or client:

using Moongate.Network.Packets.Registry;
using Moongate.Network.Packets.Serialization;
var registry = new PacketRegistry();
PacketTable.Register(registry); // Optional: include the built-in formats.
registry.RegisterIncoming<ExamplePacket>();
registry.Freeze();
var bytes = PacketCodec.Encode(new ExamplePacket(0x1234));
if (!bytes.AsSpan().SequenceEqual(new byte[] { 0xFE, 0x12, 0x34 }) ||
!registry.TryDecode(bytes, out var packet, out var opCode) ||
packet is not ExamplePacket { Value: 0x1234 } || opCode != 0xFE)
{
throw new InvalidOperationException("Packet round trip failed");
}
if (registry.TryDecode(new byte[] { 0xFE }, out _, out var failedOpCode) || failedOpCode != 0xFE)
{
throw new InvalidOperationException("Malformed-frame opcode was lost");
}
Console.WriteLine("Packet bytes and failure opcode verified");

For variable packets, use BasePacket<T> with [PacketHandler(opcode, PacketSizing.Variable, MinimumLength = ...)], validate the whole declared frame and write its complete length header. See the built-in ClientVersionPacket and ServerListPacket for examples of parsing and writing.

Add a reference to Moongate.Server.Core. ExamplePacketHandler.cs can echo the packet through the bounded sender without waiting for socket I/O:

using Moongate.Server.Core.Data.Sessions;
using Moongate.Server.Core.Interfaces.Packets;
using Moongate.Server.Core.Interfaces.Services;
public sealed class ExamplePacketHandler : IPacketHandler<ExamplePacket>
{
private readonly IPacketSendService _sender;
public ExamplePacketHandler(IPacketSendService sender)
{
_sender = sender;
}
public void Handle(GameSession session, ExamplePacket packet)
{
if (!_sender.TrySend(session.SessionId, new ExamplePacket(packet.Value)))
{
// The sender owns and observes connection cleanup.
_ = _sender.DisconnectAsync(session.SessionId);
}
}
}

In Program.cs service composition, or a plugin’s Register callback, import Moongate.Server.Core.Extensions and call:

container.RegisterPacketHandler<ExamplePacket, ExamplePacketHandler>();

Handlers are singletons. Keep per-player state in the session/world, not mutable handler fields. Handle executes on the game loop; keep it short and synchronous. TrySend snapshots encoded bytes and returns admission status, not delivery confirmation. Decide what to do when it returns false; the example disconnects.

For a handler that awaits database or network I/O, implement IAsyncPacketHandler<TPacket> and register it with RegisterAsyncPacketHandler<TPacket, THandler>(). Its HandleAsync runs off the game loop. The handler receives a PacketContext rather than a mutable GameSession. This example assumes an application-specific lookup service:

// Define this service in your plugin; keep its interface in a separate file.
public interface IExampleLookup
{
Task<ushort?> LoadAsync(ushort value, CancellationToken cancellationToken);
}
public sealed class ExampleAsyncPacketHandler : IAsyncPacketHandler<ExamplePacket>
{
private readonly IExampleLookup _lookup;
public ExampleAsyncPacketHandler(IExampleLookup lookup)
{
_lookup = lookup;
}
public async ValueTask HandleAsync(
PacketContext context,
ExamplePacket packet,
CancellationToken cancellationToken
)
{
var value = await _lookup.LoadAsync(packet.Value, cancellationToken);
if (value is not null)
{
context.TrySend(new ExamplePacket(value.Value));
}
}
}
container.RegisterAsyncPacketHandler<ExamplePacket, ExampleAsyncPacketHandler>();

When the result must change game state, return to the loop with await context.RunOnGameLoopAsync(session => { /* update session/world */ }, cancellationToken). It returns false if the original session disconnected before the action ran. An async handler must not mutate a session directly after an await.

Only one async packet may be in flight for a session. Until it finishes, the dispatcher rejects further packets from that session; its executor accepts at most 64 operations at once and runs at most four handlers concurrently. Admission stays nonblocking. Disconnect and server shutdown cancel the handler token; observe it in every awaited I/O call. Exceptions are logged without packet payloads and do not stop the game loop.

Host integration requires both registrations. PacketRegistry.Default is already frozen. The current host creates its UO framer and default game decoder with that registry. Registering only a handler in a plugin does not add a new opcode to the wire table. For a new built-in packet, extend PacketTable in the source and register its handler in host composition. A custom host may supply its own completed registry consistently to framing and decoding; changing just one side is insufficient.

The host also registers LoginSeed and an async AccountLogin handler. The latter checks credentials against IAccountService, sends 0x82 invalid credentials for unknown accounts, and records the account identity on success. It does not list realms or complete the login sequence (Implementation status). See Transport and game ownership for connection lifecycle, queue limits and overload policy, and Game loop and timers for thread ownership and completion.