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