Add C# dedicated server replacement
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CommonwealthOnline.Server;
|
||||
|
||||
internal static class ProtocolConstants
|
||||
{
|
||||
public const int ProtocolVersion = 2;
|
||||
public const int LegacyProtocolVersion = 1;
|
||||
public const int MaxMessageBytes = 64 * 1024;
|
||||
public const double MaxAbsCoordinate = 10_000_000.0;
|
||||
public const double MaxMovementSpeed = 100_000.0;
|
||||
public const int MaxActionEvents = 16;
|
||||
public const int MaxNpcsPerPacket = 64;
|
||||
public const double ExteriorInterestRadius = 8192.0;
|
||||
public const double MaxNormalMovementSpeed = 2500.0;
|
||||
public const double MovementGraceDistance = 512.0;
|
||||
public const double MaxMovementValidationElapsedSeconds = 5.0;
|
||||
|
||||
public static readonly HashSet<string> MovementTransitionTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"teleport", "cell_change", "worldspace_change", "load", "spawn", "fast_travel"
|
||||
};
|
||||
|
||||
public static readonly HashSet<string> AllowedMovementTypes = new(MovementTransitionTypes, StringComparer.Ordinal) { "normal" };
|
||||
}
|
||||
|
||||
internal enum Delivery : uint { UnreliableSequenced = 0, ReliableOrdered = 1 }
|
||||
internal readonly record struct EncodedPacket(string PacketType, byte[] Payload, Delivery Delivery);
|
||||
|
||||
internal static class TransportPolicy
|
||||
{
|
||||
public static Delivery ForPacketType(string packetType) => packetType is "transform" or "npcState" ? Delivery.UnreliableSequenced : Delivery.ReliableOrdered;
|
||||
public static bool IsSnapshot(string packetType) => ForPacketType(packetType) == Delivery.UnreliableSequenced;
|
||||
}
|
||||
|
||||
internal sealed class PacketCodecException(string message) : Exception(message);
|
||||
|
||||
internal static class PacketCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
public static EncodedPacket Encode(JsonObject packet)
|
||||
{
|
||||
var type = JsonHelpers.String(packet["type"]);
|
||||
if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string");
|
||||
byte[] payload;
|
||||
try { payload = JsonSerializer.SerializeToUtf8Bytes(packet, Compact); }
|
||||
catch (Exception ex) when (ex is JsonException or NotSupportedException) { throw new PacketCodecException($"packet is not JSON serializable: {ex.Message}"); }
|
||||
if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size");
|
||||
return new EncodedPacket(type, payload, TransportPolicy.ForPacketType(type));
|
||||
}
|
||||
|
||||
public static JsonObject Decode(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size");
|
||||
JsonNode? node;
|
||||
try
|
||||
{
|
||||
node = JsonNode.Parse(payload, documentOptions: new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow });
|
||||
}
|
||||
catch (JsonException ex) { throw new PacketCodecException($"invalid JSON packet: {ex.Message}"); }
|
||||
if (node is not JsonObject packet) throw new PacketCodecException("packet must be a JSON object");
|
||||
var type = JsonHelpers.String(packet["type"]);
|
||||
if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string");
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JsonHelpers
|
||||
{
|
||||
public static string? String(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
public static bool? Boolean(JsonNode? node) => node is JsonValue value && value.TryGetValue<bool>(out var result) ? result : null;
|
||||
|
||||
public static bool TryUInt32(JsonNode? node, uint min, uint max, out uint result)
|
||||
{
|
||||
result = 0;
|
||||
if (node is not JsonValue value || value.TryGetValue<bool>(out _)) return false;
|
||||
if (value.TryGetValue<uint>(out var u) && u >= min && u <= max) { result = u; return true; }
|
||||
if (value.TryGetValue<long>(out var l) && l >= min && l <= max) { result = (uint)l; return true; }
|
||||
if (value.TryGetValue<double>(out var d) && double.IsFinite(d) && d == Math.Truncate(d) && d >= min && d <= max) { result = (uint)d; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryDouble(JsonNode? node, double min, double max, out double result)
|
||||
{
|
||||
result = 0;
|
||||
if (node is not JsonValue value || value.TryGetValue<bool>(out _)) return false;
|
||||
double d;
|
||||
if (value.TryGetValue<double>(out var direct)) d = direct;
|
||||
else if (value.TryGetValue<long>(out var l)) d = l;
|
||||
else if (value.TryGetValue<decimal>(out var m)) d = (double)m;
|
||||
else return false;
|
||||
if (!double.IsFinite(d) || d < min || d > max) return false;
|
||||
result = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsHexFormId(JsonNode? node, bool allowEmpty = false, bool allowZero = true) => String(node) is { } text && IsHexFormId(text, allowEmpty, allowZero);
|
||||
public static bool IsHexFormId(string text, bool allowEmpty = false, bool allowZero = true)
|
||||
{
|
||||
if (allowEmpty && text.Length == 0) return true;
|
||||
if (text.Length is < 1 or > 8) return false;
|
||||
if (!uint.TryParse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) return false;
|
||||
return allowZero || parsed != 0;
|
||||
}
|
||||
public static string NormalizeFormId(string text) => uint.Parse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture).ToString("X8", CultureInfo.InvariantCulture);
|
||||
public static JsonObject CloneObject(JsonObject source) => (JsonObject)source.DeepClone();
|
||||
public static double UnixTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
}
|
||||
|
||||
internal sealed class SequenceCounter
|
||||
{
|
||||
private uint _value;
|
||||
public uint Current => _value;
|
||||
public uint Advance() { unchecked { _value++; } if (_value == 0) _value = 1; return _value; }
|
||||
public void Reset() => _value = 0;
|
||||
}
|
||||
|
||||
internal sealed class SequenceWindow
|
||||
{
|
||||
private uint _lastAccepted;
|
||||
public uint LastAccepted => _lastAccepted;
|
||||
public bool Accept(uint candidate) { if (!IsNewer(candidate, _lastAccepted)) return false; _lastAccepted = candidate; return true; }
|
||||
public void Reset() => _lastAccepted = 0;
|
||||
public static bool IsNewer(uint candidate, uint baseline)
|
||||
{
|
||||
if (candidate == 0) return false;
|
||||
if (baseline == 0) return true;
|
||||
var delta = unchecked(candidate - baseline);
|
||||
return delta > 0 && delta < 0x80000000u;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct SnapshotEnvelope(string PacketType, uint Sequence, byte[] Payload);
|
||||
|
||||
internal static class GnsSnapshotEnvelope
|
||||
{
|
||||
private static ReadOnlySpan<byte> Magic => "COG2"u8;
|
||||
private const byte Version = 1;
|
||||
public const int HeaderSize = 12;
|
||||
|
||||
public static byte[] Encode(string packetType, ReadOnlySpan<byte> payload, uint sequence)
|
||||
{
|
||||
var family = packetType switch { "transform" => (byte)1, "npcState" => (byte)2, _ => throw new ArgumentException($"packet type '{packetType}' is not a GNS snapshot family", nameof(packetType)) };
|
||||
if (sequence == 0) throw new ArgumentOutOfRangeException(nameof(sequence));
|
||||
if (payload.IsEmpty) throw new ArgumentException("snapshot payload cannot be empty", nameof(payload));
|
||||
if (HeaderSize + payload.Length > ProtocolConstants.MaxMessageBytes) throw new ArgumentException("snapshot envelope exceeds maximum GNS message size");
|
||||
var output = new byte[HeaderSize + payload.Length];
|
||||
Magic.CopyTo(output);
|
||||
output[4] = Version;
|
||||
output[5] = family;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(6, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(output.AsSpan(8, 4), sequence);
|
||||
payload.CopyTo(output.AsSpan(HeaderSize));
|
||||
return output;
|
||||
}
|
||||
|
||||
public static bool TryDecode(ReadOnlySpan<byte> message, out SnapshotEnvelope envelope, out string? error)
|
||||
{
|
||||
envelope = default; error = null;
|
||||
if (message.Length < 4 || !message[..4].SequenceEqual(Magic)) return false;
|
||||
if (message.Length < HeaderSize) { error = "truncated GNS snapshot envelope"; return true; }
|
||||
if (message[4] != Version || BinaryPrimitives.ReadUInt16BigEndian(message.Slice(6, 2)) != 0) { error = "invalid GNS snapshot envelope header"; return true; }
|
||||
var type = message[5] switch { 1 => "transform", 2 => "npcState", _ => null };
|
||||
if (type is null) { error = "unknown GNS snapshot family"; return true; }
|
||||
var sequence = BinaryPrimitives.ReadUInt32BigEndian(message.Slice(8, 4));
|
||||
if (sequence == 0) { error = "snapshot sequence zero is reserved"; return true; }
|
||||
var payload = message[HeaderSize..].ToArray();
|
||||
if (payload.Length == 0) { error = "snapshot envelope payload is empty"; return true; }
|
||||
envelope = new SnapshotEnvelope(type, sequence, payload);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user