using System; using System.Buffers; using MessagePack; namespace GameCult.Networking { /// /// Serializes and deserializes the explicit modern CultNet schema-v0 message family. /// public static class CultNetSchemaMessageSerialization { /// /// Gets the serializer options used for CultNet schema-v0 messages. /// public static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData); /// /// Serializes a typed CultNet schema message. /// public static byte[] Serialize(T message) where T : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); return MessagePackSerializer.Serialize(message, Options); } /// /// Deserializes a typed CultNet schema message. /// public static T Deserialize(byte[] payload) where T : ICultNetSchemaMessage { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload); return MessagePackSerializer.Deserialize(payload, Options); } /// /// Deserializes a CultNet schema message by inspecting its schema version. /// public static ICultNetSchemaMessage Deserialize(byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); var header = MessagePackSerializer.Deserialize(payload, Options); if (header == null || string.IsNullOrWhiteSpace(header.SchemaVersion)) { throw new MessagePackSerializationException("CultNet schema-v0 payload is missing schemaVersion."); } return header.SchemaVersion switch { CultNetSchemaVersions.Hello => Deserialize(payload), CultNetSchemaVersions.Login => Deserialize(payload), CultNetSchemaVersions.Register => Deserialize(payload), CultNetSchemaVersions.Verify => Deserialize(payload), CultNetSchemaVersions.LoginSuccess => Deserialize(payload), CultNetSchemaVersions.Error => Deserialize(payload), CultNetSchemaVersions.ErrorV1 => Deserialize(payload), CultNetSchemaVersions.WriteAdmitted => Deserialize(payload), CultNetSchemaVersions.SampleChangeName => Deserialize(payload), CultNetSchemaVersions.SampleChat => Deserialize(payload), CultNetSchemaVersions.OperationRequest => Deserialize(payload), CultNetSchemaVersions.OperationResponse => Deserialize(payload), CultNetSchemaVersions.DocumentDelete => Deserialize(payload), CultNetSchemaVersions.DocumentPutRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequest => Deserialize(payload), CultNetSchemaVersions.SnapshotResponseRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequestV1 => Deserialize(payload), CultNetSchemaVersions.DatabaseSubscribeV1 => Deserialize(payload), CultNetSchemaVersions.SnapshotResponseRawV1 => Deserialize(payload), CultNetSchemaVersions.DatabaseSubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseUnsubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseChangeRaw => Deserialize(payload), CultNetSchemaVersions.ShardCatalogRequest => Deserialize(payload), CultNetSchemaVersions.ShardCatalogResponse => Deserialize(payload), CultNetSchemaVersions.ShardLogRequest => Deserialize(payload), CultNetSchemaVersions.ShardLogResponse => Deserialize(payload), CultNetSchemaVersions.SimulationObservation => Deserialize(payload), CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize(payload), CultNetSchemaVersions.VerseCatalogRequest => Deserialize(payload), CultNetSchemaVersions.VerseCatalogResponse => Deserialize(payload), CultNetSchemaVersions.SessionOpen => Deserialize(payload), CultNetSchemaVersions.SessionAccepted => Deserialize(payload), CultNetSchemaVersions.PeerExchangeRequest => Deserialize(payload), CultNetSchemaVersions.PeerExchangeResponse => Deserialize(payload), CultNetSchemaVersions.ContentChunkRequest => Deserialize(payload), CultNetSchemaVersions.ContentChunkResponse => Deserialize(payload), CultNetSchemaVersions.BodyReadRequest => Deserialize(payload), CultNetSchemaVersions.BodyReadResponse => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogRequest => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogResponse => Deserialize(payload), _ => throw new MessagePackSerializationException( $"Unsupported CultNet schema-v0 message \"{header.SchemaVersion}\".") }; } [MessagePackObject(AllowPrivate = true)] internal sealed class CultNetSchemaHeader { [Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; } } /// /// The write_admitted messageId rule (write-receipt.md): a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no /// Default_Ignorable code point anywhere, as of Unicode 16.0, wire bytes that are valid UTF-8, compared raw (never trimmed). /// The tables are embedded (CultNetUnicodeMessageIdTables.cs, generated by scripts/gen-unicode-messageid-tables.py), not /// .NET's, so the running runtime's Unicode version changes no result; the other four runtimes embed the same tables. /// internal static class CultNetAdmittedMessageId { private static readonly System.Text.UTF8Encoding StrictUtf8 = new(false, true); /// Throws unless the frame's top-level messageId is a valid write_admitted id; other keys are left to the serializer. public static void Require(byte[] payload) { var reader = new MessagePackReader(payload); var entries = reader.ReadMapHeader(); for (var index = 0; index < entries; index++) { var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null; if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String) { string id; try { id = StrictUtf8.GetString(reader.ReadStringSequence()!.Value.ToArray()); } catch (System.Text.DecoderFallbackException) { throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId is not valid UTF-8."); } if (!IsValid(id)) throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point."); return; } if (key == null) reader.Skip(); reader.Skip(); } } public static bool IsValid(string id) { var hasLetterOrNumber = false; for (var at = 0; at < id.Length; at += char.IsSurrogatePair(id, at) ? 2 : 1) { var point = char.IsSurrogatePair(id, at) ? char.ConvertToUtf32(id, at) : id[at]; if (InRanges(CultNetUnicodeMessageIdTables.DefaultIgnorable, point)) return false; hasLetterOrNumber |= InRanges(CultNetUnicodeMessageIdTables.LetterOrNumber, point); } return hasLetterOrNumber; } private static bool InRanges(int[] ranges, int point) { int low = 0, high = ranges.Length / 2 - 1; while (low <= high) { var mid = (low + high) >> 1; if (point < ranges[2 * mid]) high = mid - 1; else if (point > ranges[2 * mid + 1]) low = mid + 1; else return true; } return false; } } }