503 lines
19 KiB
C#
503 lines
19 KiB
C#
using System.Text;
|
|
|
|
namespace HSchool.Protocol;
|
|
|
|
/// <summary>
|
|
/// The single place where the wire format is defined on the .NET side.
|
|
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
|
|
/// and documented in <c>docs/protocol.md</c>.
|
|
/// </summary>
|
|
public static class ProtocolCodec
|
|
{
|
|
/// <summary>
|
|
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots and
|
|
/// presence frames use <see cref="ProtocolConstants.MaxMessageSize"/> instead.
|
|
/// </summary>
|
|
public const int MaxFrameSize = 27;
|
|
|
|
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientHello);
|
|
writer.WriteByte(message.ProtocolVersion);
|
|
writer.WriteByte(message.Locale);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WritePing(Span<byte> destination, in ClientPingMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientPing);
|
|
writer.WriteInt64(message.ClientTimeMs);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteOpenSchool(Span<byte> destination, in ClientOpenSchoolMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientOpenSchool);
|
|
writer.WriteInt32(message.SchoolId);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteCloseSchool(Span<byte> destination)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientCloseSchool);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteSetRunning(Span<byte> destination, in ClientSetRunningMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientSetRunning);
|
|
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteSetSpeed(Span<byte> destination, in ClientSetSpeedMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientSetSpeed);
|
|
writer.WriteByte(message.SpeedIndex);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteSkipEmpty(Span<byte> destination)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientSkipEmpty);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteDismissNotice(Span<byte> destination, in ClientDismissNoticeMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ClientDismissNotice);
|
|
writer.WriteUInt32(message.Id);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerWelcome);
|
|
writer.WriteByte(message.ProtocolVersion);
|
|
writer.WriteByte(message.TickRate);
|
|
writer.WriteByte(message.MaxSchools);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerPong);
|
|
writer.WriteInt64(message.ClientTimeMs);
|
|
writer.WriteUInt32(message.ServerTick);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteClock(Span<byte> destination, in ServerClockMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerClock);
|
|
writer.WriteInt32(message.SchoolId);
|
|
writer.WriteInt64(message.GameTimeUnixMs);
|
|
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
|
|
writer.WriteByte(message.SpeedIndex);
|
|
writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0);
|
|
writer.WriteInt64(message.SkipTargetUnixMs);
|
|
writer.WriteInt16(message.TemperatureTenths);
|
|
writer.WriteByte(message.Precipitation);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int WriteSchoolGone(Span<byte> destination, in ServerSchoolGoneMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerSchoolGone);
|
|
writer.WriteInt32(message.SchoolId);
|
|
return writer.Position;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bytes <see cref="WriteMapSnapshot"/> needs for this message.
|
|
///
|
|
/// A school's map has no fixed size — the create editor lets a player add rooms — so a
|
|
/// snapshot can outgrow <see cref="ProtocolConstants.MaxMessageSize"/>. That limit guards
|
|
/// what the server *reads*; callers size an outbound snapshot from the message itself.
|
|
/// </summary>
|
|
public static int MapSnapshotSize(ServerMapSnapshotMessage message)
|
|
{
|
|
var size = sizeof(byte) + sizeof(int) + sizeof(ushort);
|
|
|
|
foreach (var node in message.Nodes)
|
|
{
|
|
size += sizeof(byte);
|
|
size += StringSize(node.Id) + StringSize(node.ParentId) + StringSize(node.Name);
|
|
size += sizeof(ushort);
|
|
|
|
size += sizeof(byte);
|
|
foreach (var item in node.Items)
|
|
{
|
|
size += StringSize(item.Name) + sizeof(byte);
|
|
}
|
|
|
|
size += sizeof(byte);
|
|
foreach (var position in node.Positions)
|
|
{
|
|
size += StringSize(position);
|
|
}
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
public static int WriteMapSnapshot(Span<byte> destination, ServerMapSnapshotMessage message)
|
|
{
|
|
if (message.Nodes.Count > ushort.MaxValue)
|
|
{
|
|
throw new ProtocolException($"Map snapshot has {message.Nodes.Count} nodes; u16 count cannot hold it.");
|
|
}
|
|
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerMapSnapshot);
|
|
writer.WriteInt32(message.SchoolId);
|
|
writer.WriteUInt16((ushort)message.Nodes.Count);
|
|
|
|
foreach (var node in message.Nodes)
|
|
{
|
|
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue)
|
|
{
|
|
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
|
|
}
|
|
|
|
writer.WriteByte(node.Kind);
|
|
writer.WriteString(node.Id);
|
|
writer.WriteString(node.ParentId);
|
|
writer.WriteString(node.Name);
|
|
writer.WriteUInt16(node.PupilSlots);
|
|
writer.WriteByte((byte)node.Items.Count);
|
|
foreach (var item in node.Items)
|
|
{
|
|
writer.WriteString(item.Name);
|
|
writer.WriteByte(item.Count);
|
|
}
|
|
|
|
writer.WriteByte((byte)node.Positions.Count);
|
|
foreach (var position in node.Positions)
|
|
{
|
|
writer.WriteString(position);
|
|
}
|
|
}
|
|
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int PresenceSize(ServerPresenceMessage message)
|
|
{
|
|
var size = sizeof(byte) + sizeof(int) + sizeof(ushort);
|
|
foreach (var node in message.Nodes)
|
|
{
|
|
size += StringSize(node.Id) + sizeof(ushort) + sizeof(byte);
|
|
if (HasPresenceActivity(node))
|
|
{
|
|
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
|
|
}
|
|
}
|
|
|
|
size += sizeof(ushort);
|
|
foreach (var person in message.People)
|
|
{
|
|
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte);
|
|
var members = person.TalkMemberIds ?? [];
|
|
size += sizeof(byte);
|
|
foreach (var memberId in members)
|
|
{
|
|
size += StringSize(memberId);
|
|
}
|
|
|
|
size += StringSize(person.TalkTopicId ?? "");
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
public static int WritePresence(Span<byte> destination, ServerPresenceMessage message)
|
|
{
|
|
if (message.Nodes.Count > ushort.MaxValue)
|
|
{
|
|
throw new ProtocolException($"Presence has {message.Nodes.Count} nodes; u16 count cannot hold it.");
|
|
}
|
|
|
|
if (message.People.Count > ushort.MaxValue)
|
|
{
|
|
throw new ProtocolException($"Presence has {message.People.Count} people; u16 count cannot hold it.");
|
|
}
|
|
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerPresence);
|
|
writer.WriteInt32(message.SchoolId);
|
|
writer.WriteUInt16((ushort)message.Nodes.Count);
|
|
foreach (var node in message.Nodes)
|
|
{
|
|
writer.WriteString(node.Id);
|
|
writer.WriteUInt16(node.Count);
|
|
if (HasPresenceActivity(node))
|
|
{
|
|
writer.WriteByte(1);
|
|
writer.WriteString(node.ActivitySubject);
|
|
writer.WriteString(node.ActivityClass);
|
|
}
|
|
else
|
|
{
|
|
writer.WriteByte(0);
|
|
}
|
|
}
|
|
|
|
writer.WriteUInt16((ushort)message.People.Count);
|
|
foreach (var person in message.People)
|
|
{
|
|
var members = person.TalkMemberIds ?? [];
|
|
if (members.Count > byte.MaxValue)
|
|
{
|
|
throw new ProtocolException($"Presence person {person.Id} has {members.Count} talk members; u8 count cannot hold it.");
|
|
}
|
|
|
|
writer.WriteString(person.Id);
|
|
writer.WriteString(person.NodeId);
|
|
writer.WriteByte(person.State);
|
|
writer.WriteByte((byte)members.Count);
|
|
foreach (var memberId in members)
|
|
{
|
|
writer.WriteString(memberId);
|
|
}
|
|
|
|
writer.WriteString(person.TalkTopicId ?? "");
|
|
}
|
|
|
|
return writer.Position;
|
|
}
|
|
|
|
public static int NoticeSize(in ServerNoticeMessage message) =>
|
|
sizeof(byte) + sizeof(uint) + StringSize(message.DefName) + sizeof(byte) + sizeof(byte) + sizeof(uint) + sizeof(uint);
|
|
|
|
public static int WriteNotice(Span<byte> destination, in ServerNoticeMessage message)
|
|
{
|
|
var writer = new PacketWriter(destination);
|
|
writer.WriteMessageType(MessageType.ServerNotice);
|
|
writer.WriteUInt32(message.Id);
|
|
writer.WriteString(message.DefName);
|
|
writer.WriteByte(message.Severity);
|
|
writer.WriteByte(message.Pause ? (byte)1 : (byte)0);
|
|
writer.WriteUInt32(message.TtlMs);
|
|
writer.WriteUInt32(message.PersonId);
|
|
return writer.Position;
|
|
}
|
|
|
|
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
|
|
source.IsEmpty ? MessageType.None : (MessageType)source[0];
|
|
|
|
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientHello);
|
|
var version = reader.ReadByte();
|
|
var locale = reader.ReadByte();
|
|
return new ClientHelloMessage(version, locale);
|
|
}
|
|
|
|
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientPing);
|
|
return new ClientPingMessage(reader.ReadInt64());
|
|
}
|
|
|
|
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientOpenSchool);
|
|
return new ClientOpenSchoolMessage(reader.ReadInt32());
|
|
}
|
|
|
|
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientSetRunning);
|
|
return new ClientSetRunningMessage(reader.ReadByte() != 0);
|
|
}
|
|
|
|
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientSetSpeed);
|
|
return new ClientSetSpeedMessage(reader.ReadByte());
|
|
}
|
|
|
|
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerWelcome);
|
|
var version = reader.ReadByte();
|
|
var tickRate = reader.ReadByte();
|
|
var maxSchools = reader.ReadByte();
|
|
return new ServerWelcomeMessage(version, tickRate, maxSchools);
|
|
}
|
|
|
|
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerPong);
|
|
var clientTime = reader.ReadInt64();
|
|
var serverTick = reader.ReadUInt32();
|
|
return new ServerPongMessage(clientTime, serverTick);
|
|
}
|
|
|
|
public static ServerClockMessage ReadClock(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerClock);
|
|
var schoolId = reader.ReadInt32();
|
|
var gameTime = reader.ReadInt64();
|
|
var running = reader.ReadByte() != 0;
|
|
var speedIndex = reader.ReadByte();
|
|
var skipAllowed = reader.ReadByte() != 0;
|
|
var skipTarget = reader.ReadInt64();
|
|
var temperatureTenths = reader.ReadInt16();
|
|
var precipitation = reader.ReadByte();
|
|
return new ServerClockMessage(
|
|
schoolId,
|
|
gameTime,
|
|
running,
|
|
speedIndex,
|
|
skipAllowed,
|
|
skipTarget,
|
|
temperatureTenths,
|
|
precipitation);
|
|
}
|
|
|
|
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerSchoolGone);
|
|
return new ServerSchoolGoneMessage(reader.ReadInt32());
|
|
}
|
|
|
|
public static ServerMapSnapshotMessage ReadMapSnapshot(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerMapSnapshot);
|
|
var schoolId = reader.ReadInt32();
|
|
var nodeCount = reader.ReadUInt16();
|
|
var nodes = new MapSnapshotNode[nodeCount];
|
|
|
|
for (var i = 0; i < nodeCount; i++)
|
|
{
|
|
var kind = reader.ReadByte();
|
|
var id = reader.ReadString();
|
|
var parentId = reader.ReadString();
|
|
var name = reader.ReadString();
|
|
var pupilSlots = reader.ReadUInt16();
|
|
var itemCount = reader.ReadByte();
|
|
var items = new MapSnapshotItem[itemCount];
|
|
for (var item = 0; item < itemCount; item++)
|
|
{
|
|
var itemName = reader.ReadString();
|
|
var count = reader.ReadByte();
|
|
items[item] = new MapSnapshotItem(itemName, count);
|
|
}
|
|
|
|
var positionCount = reader.ReadByte();
|
|
var positions = new string[positionCount];
|
|
for (var position = 0; position < positionCount; position++)
|
|
{
|
|
positions[position] = reader.ReadString();
|
|
}
|
|
|
|
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions);
|
|
}
|
|
|
|
return new ServerMapSnapshotMessage(schoolId, nodes);
|
|
}
|
|
|
|
public static ServerPresenceMessage ReadPresence(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerPresence);
|
|
var schoolId = reader.ReadInt32();
|
|
var nodeCount = reader.ReadUInt16();
|
|
var nodes = new PresenceNode[nodeCount];
|
|
for (var i = 0; i < nodeCount; i++)
|
|
{
|
|
var id = reader.ReadString();
|
|
var count = reader.ReadUInt16();
|
|
var hasActivity = reader.ReadByte() != 0;
|
|
var activitySubject = "";
|
|
var activityClass = "";
|
|
if (hasActivity)
|
|
{
|
|
activitySubject = reader.ReadString();
|
|
activityClass = reader.ReadString();
|
|
}
|
|
|
|
nodes[i] = new PresenceNode(id, count, activitySubject, activityClass);
|
|
}
|
|
|
|
var personCount = reader.ReadUInt16();
|
|
var people = new PresencePerson[personCount];
|
|
for (var i = 0; i < personCount; i++)
|
|
{
|
|
var id = reader.ReadString();
|
|
var nodeId = reader.ReadString();
|
|
var state = reader.ReadByte();
|
|
var memberCount = reader.ReadByte();
|
|
var members = new string[memberCount];
|
|
for (var member = 0; member < memberCount; member++)
|
|
{
|
|
members[member] = reader.ReadString();
|
|
}
|
|
|
|
var topicId = reader.ReadString();
|
|
people[i] = new PresencePerson(id, nodeId, state, members, topicId);
|
|
}
|
|
|
|
return new ServerPresenceMessage(schoolId, nodes, people);
|
|
}
|
|
|
|
public static ClientDismissNoticeMessage ReadDismissNotice(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ClientDismissNotice);
|
|
return new ClientDismissNoticeMessage(reader.ReadUInt32());
|
|
}
|
|
|
|
public static ServerNoticeMessage ReadNotice(ReadOnlySpan<byte> source)
|
|
{
|
|
var reader = new PacketReader(source);
|
|
Expect(ref reader, MessageType.ServerNotice);
|
|
var id = reader.ReadUInt32();
|
|
var defName = reader.ReadString();
|
|
var severity = reader.ReadByte();
|
|
var pause = reader.ReadByte() != 0;
|
|
var ttlMs = reader.ReadUInt32();
|
|
var personId = reader.ReadUInt32();
|
|
return new ServerNoticeMessage(id, defName, severity, pause, ttlMs, personId);
|
|
}
|
|
|
|
private static bool HasPresenceActivity(PresenceNode node) =>
|
|
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
|
|
|
|
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
|
|
private static int StringSize(string value) => sizeof(ushort) + Encoding.UTF8.GetByteCount(value);
|
|
|
|
private static void Expect(ref PacketReader reader, MessageType expected)
|
|
{
|
|
var actual = reader.ReadMessageType();
|
|
if (actual != expected)
|
|
{
|
|
throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}.");
|
|
}
|
|
}
|
|
}
|