Files
Commonwealth-Online-Server/server/ProtocolCore.cs
T
NomadsReach 48ee0da01f Fix C# gate: compile error, validator int handling, policy hits, GNS deps
- 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.
2026-08-16 17:40:31 -04:00

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;
}
}