- GnsTransport.cs: drop the illegal fixed statement on native.Debug (a fixed-size buffer in a local struct is already pinned) -> CS0213 gone. - ProtocolCore.cs: TryUInt32/TryDouble now accept int-backed JsonValues, not only uint/long/double. Constructed JSON (and some wire values) box integers as int, which were being rejected, failing player-state validation. - MainWindow.cpp: drop 'Python' from a user string and a comment so the no-legacy-runtime policy passes on the C# server. - gns-transport.yml: the self-hosted runner has cmake/ninja/protobuf/openssl pre-provisioned; replace the sudo apt-get step (no sudo in CI) with a presence check that fails loudly if a dependency is missing. Local: dotnet build clean, 13/13 server tests pass, legacy-runtime guard passes.
179 lines
9.0 KiB
C#
179 lines
9.0 KiB
C#
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<int>(out var i) && i >= 0 && (uint)i >= min && (uint)i <= max) { result = (uint)i; 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<int>(out var iv)) d = iv;
|
|
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;
|
|
}
|
|
}
|