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}."); } } }