342 lines
20 KiB
C#
342 lines
20 KiB
C#
using System.Text.Json.Nodes;
|
|
|
|
namespace CommonwealthOnline.Server;
|
|
|
|
internal static class ProtocolValidation
|
|
{
|
|
private const int MaxCharacterNameChars = 128;
|
|
private const int MaxEquippedItems = 32;
|
|
private const int MaxEquipmentSlotChars = 64;
|
|
private const int MaxHeadParts = 64;
|
|
private const int MaxMorphs = 128;
|
|
private const int MaxMorphRegions = 128;
|
|
private const int MaxFacialBoneMorphs = 128;
|
|
private const int MaxTints = 128;
|
|
|
|
public static JsonArray NormalizeActionEvents(JsonNode? value, bool strict)
|
|
{
|
|
var output = new JsonArray();
|
|
if (value is not JsonArray input) return output;
|
|
var count = Math.Min(input.Count, ProtocolConstants.MaxActionEvents);
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
if (input[i] is not JsonObject item)
|
|
{
|
|
if (strict) return new JsonArray();
|
|
continue;
|
|
}
|
|
if (!JsonHelpers.TryUInt32(item["sequence"], 1, uint.MaxValue, out var sequence) ||
|
|
!JsonHelpers.TryUInt32(item["type"], 1, 3, out var actionType) ||
|
|
JsonHelpers.String(item["eventName"]) is not { } eventName ||
|
|
!((actionType is 1 or 2 && eventName == "meleeattackStart") || (actionType == 3 && eventName == "fireSingle")))
|
|
{
|
|
if (strict) return new JsonArray();
|
|
continue;
|
|
}
|
|
var clean = new JsonObject { ["sequence"] = sequence, ["type"] = actionType, ["eventName"] = eventName };
|
|
foreach (var name in new[] { "actorStateFlags1", "actorStateFlags2" })
|
|
if (JsonHelpers.TryUInt32(item[name], 0, uint.MaxValue, out var flags)) clean[name] = flags;
|
|
output.Add(clean);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
public static JsonObject? NormalizePlayerState(JsonObject packet)
|
|
{
|
|
var clean = new JsonObject { ["type"] = "playerState" };
|
|
var hasState = false;
|
|
if (packet.ContainsKey("equippedItems"))
|
|
{
|
|
if (packet["equippedItems"] is not JsonArray items || items.Count > MaxEquippedItems) return null;
|
|
var cleanItems = new JsonArray();
|
|
foreach (var node in items)
|
|
{
|
|
if (node is not JsonObject item) return null;
|
|
var slot = JsonHelpers.String(item["slot"]);
|
|
var form = JsonHelpers.String(item["formId"]) ?? string.Empty;
|
|
if (slot is null || slot.Length is < 1 or > MaxEquipmentSlotChars || !JsonHelpers.IsHexFormId(form, true, true)) return null;
|
|
cleanItems.Add(new JsonObject { ["slot"] = slot, ["formId"] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form) });
|
|
}
|
|
clean["equippedItems"] = cleanItems;
|
|
hasState = true;
|
|
}
|
|
if (packet.ContainsKey("appearance"))
|
|
{
|
|
if (packet["appearance"] is not JsonObject appearance) return null;
|
|
var normalizedAppearance = NormalizeAppearance(appearance);
|
|
if (normalizedAppearance is null) return null;
|
|
clean["appearance"] = normalizedAppearance;
|
|
hasState = true;
|
|
}
|
|
if (packet.ContainsKey("actionEvents"))
|
|
{
|
|
if (packet["actionEvents"] is not JsonArray actions) return null;
|
|
var normalized = NormalizeActionEvents(actions, true);
|
|
if (normalized.Count != actions.Count) return null;
|
|
clean["actionEvents"] = normalized;
|
|
hasState = true;
|
|
}
|
|
if (packet.ContainsKey("characterName"))
|
|
{
|
|
var name = JsonHelpers.String(packet["characterName"]);
|
|
if (name is null || name.Length > MaxCharacterNameChars) return null;
|
|
clean["characterName"] = name;
|
|
hasState = true;
|
|
}
|
|
return hasState ? clean : null;
|
|
}
|
|
|
|
private static JsonObject? NormalizeAppearance(JsonObject value)
|
|
{
|
|
var clean = new JsonObject();
|
|
uint version = 4;
|
|
if (value.ContainsKey("version") && !JsonHelpers.TryUInt32(value["version"], 1, 1000, out version)) return null;
|
|
clean["version"] = version;
|
|
foreach (var name in new[] { "raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId" })
|
|
{
|
|
if (!value.ContainsKey(name)) continue;
|
|
var form = JsonHelpers.String(value[name]);
|
|
if (form is null || !JsonHelpers.IsHexFormId(form, true, true)) return null;
|
|
clean[name] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form);
|
|
}
|
|
if (value.ContainsKey("height"))
|
|
{
|
|
if (!JsonHelpers.TryDouble(value["height"], 0.25, 4.0, out var height)) return null;
|
|
clean["height"] = height;
|
|
}
|
|
if (value.ContainsKey("isFemale"))
|
|
{
|
|
var female = JsonHelpers.Boolean(value["isFemale"]);
|
|
if (female is null) return null;
|
|
clean["isFemale"] = female.Value;
|
|
}
|
|
if (value.ContainsKey("morphWeight"))
|
|
{
|
|
if (value["morphWeight"] is not JsonObject weights) return null;
|
|
var normalized = new JsonObject();
|
|
foreach (var name in new[] { "thin", "muscular", "large" })
|
|
{
|
|
if (!JsonHelpers.TryDouble(weights[name], -100, 100, out var weight)) return null;
|
|
normalized[name] = weight;
|
|
}
|
|
clean["morphWeight"] = normalized;
|
|
}
|
|
if (value.ContainsKey("bodyTintColor"))
|
|
{
|
|
if (value["bodyTintColor"] is not JsonObject color) return null;
|
|
var normalized = new JsonObject();
|
|
foreach (var name in new[] { "r", "g", "b", "a" })
|
|
{
|
|
if (!JsonHelpers.TryUInt32(color[name], 0, 255, out var component)) return null;
|
|
normalized[name] = component;
|
|
}
|
|
clean["bodyTintColor"] = normalized;
|
|
}
|
|
if (value.ContainsKey("headParts"))
|
|
{
|
|
if (value["headParts"] is not JsonArray parts || parts.Count > MaxHeadParts) return null;
|
|
var normalized = new JsonArray();
|
|
foreach (var node in parts)
|
|
{
|
|
var form = JsonHelpers.String(node);
|
|
if (form is null || !JsonHelpers.IsHexFormId(form, false, true)) return null;
|
|
normalized.Add(JsonHelpers.NormalizeFormId(form));
|
|
}
|
|
clean["headParts"] = normalized;
|
|
}
|
|
if (value.ContainsKey("morphs"))
|
|
{
|
|
if (value["morphs"] is not JsonArray morphs || morphs.Count > MaxMorphs) return null;
|
|
var normalized = new JsonArray();
|
|
foreach (var node in morphs)
|
|
{
|
|
if (node is not JsonObject morph) return null;
|
|
var id = JsonHelpers.String(morph["id"]);
|
|
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || !JsonHelpers.TryDouble(morph["value"], -1000, 1000, out var amount)) return null;
|
|
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["value"] = amount });
|
|
}
|
|
clean["morphs"] = normalized;
|
|
}
|
|
if (value.ContainsKey("morphRegions"))
|
|
{
|
|
if (value["morphRegions"] is not JsonArray regions || regions.Count > MaxMorphRegions) return null;
|
|
var normalized = new JsonArray();
|
|
foreach (var node in regions)
|
|
{
|
|
if (!JsonHelpers.TryDouble(node, -1000, 1000, out var amount)) return null;
|
|
normalized.Add(amount);
|
|
}
|
|
clean["morphRegions"] = normalized;
|
|
}
|
|
if (value.ContainsKey("facialBoneMorphs"))
|
|
{
|
|
if (value["facialBoneMorphs"] is not JsonArray morphs || morphs.Count > MaxFacialBoneMorphs) return null;
|
|
var normalized = new JsonArray();
|
|
foreach (var node in morphs)
|
|
{
|
|
if (node is not JsonObject morph) return null;
|
|
var id = JsonHelpers.String(morph["id"]);
|
|
var position = NormalizeVec3(morph["position"], -10000, 10000);
|
|
var rotation = NormalizeVec3(morph["rotation"], -10000, 10000);
|
|
var scale = NormalizeVec3(morph["scale"], -100, 100);
|
|
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || position is null || rotation is null || scale is null) return null;
|
|
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["position"] = position, ["rotation"] = rotation, ["scale"] = scale });
|
|
}
|
|
clean["facialBoneMorphs"] = normalized;
|
|
}
|
|
if (value.ContainsKey("tints"))
|
|
{
|
|
if (value["tints"] is not JsonArray tints || tints.Count > MaxTints) return null;
|
|
var normalized = new JsonArray();
|
|
foreach (var node in tints)
|
|
{
|
|
if (node is not JsonObject tint || !JsonHelpers.TryUInt32(tint["id"], 0, ushort.MaxValue, out var id) ||
|
|
!JsonHelpers.TryUInt32(tint["type"], 0, uint.MaxValue, out var type) || !JsonHelpers.TryUInt32(tint["value"], 0, 255, out var amount)) return null;
|
|
var cleanTint = new JsonObject { ["id"] = id, ["type"] = type, ["value"] = amount };
|
|
if (tint.ContainsKey("color"))
|
|
{
|
|
var color = JsonHelpers.String(tint["color"]);
|
|
var swatchNode = tint["swatch"] ?? JsonValue.Create(0);
|
|
if (color is null || !JsonHelpers.IsHexFormId(color, false, true) || !JsonHelpers.TryUInt32(swatchNode, 0, ushort.MaxValue, out var swatch)) return null;
|
|
cleanTint["color"] = JsonHelpers.NormalizeFormId(color);
|
|
cleanTint["swatch"] = swatch;
|
|
}
|
|
normalized.Add(cleanTint);
|
|
}
|
|
clean["tints"] = normalized;
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
private static JsonArray? NormalizeVec3(JsonNode? node, double min, double max)
|
|
{
|
|
if (node is not JsonArray array || array.Count != 3) return null;
|
|
var output = new JsonArray();
|
|
foreach (var component in array)
|
|
{
|
|
if (!JsonHelpers.TryDouble(component, min, max, out var value)) return null;
|
|
output.Add(value);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
public static JsonObject? NormalizeTransform(JsonObject packet)
|
|
{
|
|
foreach (var field in new[] { "x", "y", "z", "angleZ" })
|
|
if (!JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
|
|
var cell = JsonHelpers.String(packet["cellId"]);
|
|
var world = JsonHelpers.String(packet["worldspaceId"]) ?? string.Empty;
|
|
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
|
var movementType = JsonHelpers.String(packet["movementType"]) ?? "normal";
|
|
if (!ProtocolConstants.AllowedMovementTypes.Contains(movementType)) return null;
|
|
var normalized = JsonHelpers.CloneObject(packet);
|
|
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); normalized[field] = v; }
|
|
normalized["cellId"] = JsonHelpers.NormalizeFormId(cell);
|
|
normalized["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
|
|
normalized["movementType"] = movementType;
|
|
foreach (var field in new[] { "movementSpeed", "animationGraphSpeed" })
|
|
{
|
|
if (!packet.ContainsKey(field)) continue;
|
|
var min = field == "animationGraphSpeed" ? -1.0 : 0.0;
|
|
if (JsonHelpers.TryDouble(packet[field], min, ProtocolConstants.MaxMovementSpeed, out var value)) normalized[field] = value; else normalized.Remove(field);
|
|
}
|
|
foreach (var (field, min, max) in new[] { ("animationDirection", -360.0, 360.0), ("aimPitch", -180.0, 180.0), ("turnDelta", -10000.0, 10000.0) })
|
|
{
|
|
if (!packet.ContainsKey(field)) continue;
|
|
if (JsonHelpers.TryDouble(packet[field], min, max, out var value)) normalized[field] = value; else normalized.Remove(field);
|
|
}
|
|
foreach (var field in new[] { "isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn" })
|
|
if (packet.ContainsKey(field) && JsonHelpers.Boolean(packet[field]) is null) normalized.Remove(field);
|
|
foreach (var field in new[] { "actorStateFlags1", "actorStateFlags2" })
|
|
if (packet.ContainsKey(field) && !JsonHelpers.TryUInt32(packet[field], 0, uint.MaxValue, out _)) normalized.Remove(field);
|
|
if (packet.ContainsKey("actionEvents")) normalized["actionEvents"] = NormalizeActionEvents(packet["actionEvents"], false);
|
|
return normalized;
|
|
}
|
|
|
|
public static (bool Accepted, string Reason) ValidateMovement(JsonObject? previous, double? previousMonotonic, JsonObject current, double nowMonotonic)
|
|
{
|
|
if (previous is null || previousMonotonic is null) return (true, "first transform");
|
|
var movementType = JsonHelpers.String(current["movementType"]) ?? "normal";
|
|
if (ProtocolConstants.MovementTransitionTypes.Contains(movementType)) return (true, $"explicit {movementType} transition");
|
|
if ((JsonHelpers.String(previous["cellId"]) ?? string.Empty) != (JsonHelpers.String(current["cellId"]) ?? string.Empty) ||
|
|
(JsonHelpers.String(previous["worldspaceId"]) ?? string.Empty) != (JsonHelpers.String(current["worldspaceId"]) ?? string.Empty))
|
|
return (false, "scope changed without an explicit movement transition");
|
|
var elapsed = nowMonotonic - previousMonotonic.Value;
|
|
if (!double.IsFinite(elapsed) || elapsed < 0) elapsed = 0;
|
|
elapsed = Math.Min(elapsed, ProtocolConstants.MaxMovementValidationElapsedSeconds);
|
|
JsonHelpers.TryDouble(current["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x);
|
|
JsonHelpers.TryDouble(current["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y);
|
|
JsonHelpers.TryDouble(current["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var z);
|
|
JsonHelpers.TryDouble(previous["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var px);
|
|
JsonHelpers.TryDouble(previous["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var py);
|
|
JsonHelpers.TryDouble(previous["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var pz);
|
|
var dx = x - px; var dy = y - py; var dz = z - pz;
|
|
var distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
|
var allowed = ProtocolConstants.MovementGraceDistance + ProtocolConstants.MaxNormalMovementSpeed * elapsed;
|
|
return double.IsFinite(distance) && distance <= allowed ? (true, "normal movement accepted") : (false, $"normal movement exceeded server envelope: distance={distance:F1}, allowed={allowed:F1}, elapsed={elapsed:F3}s");
|
|
}
|
|
|
|
public static JsonObject? NormalizeWorldState(JsonObject packet)
|
|
{
|
|
var normalized = JsonHelpers.CloneObject(packet);
|
|
if (packet.ContainsKey("gameHour")) { if (!JsonHelpers.TryDouble(packet["gameHour"], 0, 24, out var hour)) return null; normalized["gameHour"] = hour; }
|
|
if (packet.ContainsKey("gameDaysPassed")) { if (!JsonHelpers.TryDouble(packet["gameDaysPassed"], 0, 10_000_000, out var days)) return null; normalized["gameDaysPassed"] = days; }
|
|
if (packet.ContainsKey("weatherFormId"))
|
|
{
|
|
var weather = JsonHelpers.String(packet["weatherFormId"]);
|
|
if (weather is null || !JsonHelpers.IsHexFormId(weather, true, true)) return null;
|
|
normalized["weatherFormId"] = weather.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(weather);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
public static JsonObject? NormalizeNpcState(JsonObject packet)
|
|
{
|
|
if (packet["npcs"] is not JsonArray npcs || npcs.Count > ProtocolConstants.MaxNpcsPerPacket) return null;
|
|
var cleanNpcs = new JsonArray();
|
|
foreach (var node in npcs)
|
|
{
|
|
if (node is not JsonObject npc) return null;
|
|
var source = JsonHelpers.String(npc["sourceFormId"]); var cell = JsonHelpers.String(npc["cellId"]); var world = JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty;
|
|
if (source is null || cell is null || !JsonHelpers.IsHexFormId(source, false, false) || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
|
foreach (var field in new[] { "x", "y", "z", "angleZ" }) if (!JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
|
|
var clean = JsonHelpers.CloneObject(npc);
|
|
clean["sourceFormId"] = JsonHelpers.NormalizeFormId(source); clean["cellId"] = JsonHelpers.NormalizeFormId(cell); clean["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
|
|
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); clean[field] = v; }
|
|
cleanNpcs.Add(clean);
|
|
}
|
|
var normalized = JsonHelpers.CloneObject(packet); normalized["npcs"] = cleanNpcs;
|
|
if (packet.ContainsKey("authorityEpoch")) { if (!JsonHelpers.TryUInt32(packet["authorityEpoch"], 1, uint.MaxValue, out var epoch)) return null; normalized["authorityEpoch"] = epoch; }
|
|
if (packet.ContainsKey("authorityCellId")) { var cell = JsonHelpers.String(packet["authorityCellId"]); if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false)) return null; normalized["authorityCellId"] = JsonHelpers.NormalizeFormId(cell); }
|
|
if (packet.ContainsKey("authorityWorldspaceId")) { var world = JsonHelpers.String(packet["authorityWorldspaceId"]); if (world is null || !JsonHelpers.IsHexFormId(world, true, true)) return null; normalized["authorityWorldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); }
|
|
return normalized;
|
|
}
|
|
|
|
public static JsonObject? NormalizeCombatHit(JsonObject packet)
|
|
{
|
|
if (!JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var target) || !JsonHelpers.TryUInt32(packet["sequence"], 1, uint.MaxValue, out var sequence) || !JsonHelpers.TryDouble(packet["damage"], 0.000001, 10000, out var damage)) return null;
|
|
var normalized = JsonHelpers.CloneObject(packet); normalized["targetPlayerId"] = target; normalized["sequence"] = sequence; normalized["damage"] = damage;
|
|
if (packet.ContainsKey("weaponFormId")) { var weapon = JsonHelpers.String(packet["weaponFormId"]); if (weapon is null || !JsonHelpers.IsHexFormId(weapon, false, true)) return null; normalized["weaponFormId"] = JsonHelpers.NormalizeFormId(weapon); }
|
|
return normalized;
|
|
}
|
|
|
|
public static StateScope? ScopeFromState(JsonObject? state)
|
|
{
|
|
if (state is null) return null;
|
|
var cell = JsonHelpers.String(state["cellId"]); var world = JsonHelpers.String(state["worldspaceId"]) ?? string.Empty;
|
|
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
|
|
if (!JsonHelpers.TryDouble(state["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x) || !JsonHelpers.TryDouble(state["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y)) return null;
|
|
return new StateScope(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world), x, y);
|
|
}
|
|
|
|
public static bool StatesShareInterest(JsonObject? a, JsonObject? b)
|
|
{
|
|
var left = ScopeFromState(a); var right = ScopeFromState(b);
|
|
if (left is null || right is null) return true;
|
|
if (left.Value.CellId == right.Value.CellId) return true;
|
|
if (string.IsNullOrEmpty(left.Value.WorldspaceId) || left.Value.WorldspaceId != right.Value.WorldspaceId) return false;
|
|
var dx = left.Value.X - right.Value.X; var dy = left.Value.Y - right.Value.Y;
|
|
return Math.Sqrt(dx * dx + dy * dy) <= ProtocolConstants.ExteriorInterestRadius;
|
|
}
|
|
}
|