using System.Text;
namespace HSchool.Protocol;
///
/// The single place where the wire format is defined on the .NET side.
/// Every change here must be mirrored in src/HSchool.Client/src/net/protocol.ts
/// and documented in docs/protocol.md.
///
public static class ProtocolCodec
{
///
/// Largest fixed-size frame this codec produces. Variable map snapshots and
/// presence frames use instead.
///
public const int MaxFrameSize = 27;
public static int WriteHello(Span 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 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 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 destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientCloseSchool);
return writer.Position;
}
public static int WriteSetRunning(Span 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 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 destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSkipEmpty);
return writer.Position;
}
public static int WriteDismissNotice(Span 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 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 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 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 destination, in ServerSchoolGoneMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSchoolGone);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
///
/// Bytes 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 . That limit guards
/// what the server *reads*; callers size an outbound snapshot from the message itself.
///
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 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 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 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 source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
public static ClientHelloMessage ReadHello(ReadOnlySpan 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 source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientPing);
return new ClientPingMessage(reader.ReadInt64());
}
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientOpenSchool);
return new ClientOpenSchoolMessage(reader.ReadInt32());
}
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetRunning);
return new ClientSetRunningMessage(reader.ReadByte() != 0);
}
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetSpeed);
return new ClientSetSpeedMessage(reader.ReadByte());
}
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan 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 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 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 source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSchoolGone);
return new ServerSchoolGoneMessage(reader.ReadInt32());
}
public static ServerMapSnapshotMessage ReadMapSnapshot(ReadOnlySpan 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 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 source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientDismissNotice);
return new ClientDismissNoticeMessage(reader.ReadUInt32());
}
public static ServerNoticeMessage ReadNotice(ReadOnlySpan 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;
/// Matches : a u16 length plus UTF-8.
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}.");
}
}
}