From f24bd062ebc4a8069e10ebdd8ee983c6ef132528 Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:38:01 -0400 Subject: [PATCH] Add Protocol V2 player state validation --- server/player_state.py | 237 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 server/player_state.py diff --git a/server/player_state.py b/server/player_state.py new file mode 100644 index 0000000..f7fb210 --- /dev/null +++ b/server/player_state.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from typing import Any + +MAX_CHARACTER_NAME_CHARS = 128 +MAX_EQUIPPED_ITEMS = 32 +MAX_EQUIPMENT_SLOT_CHARS = 64 +MAX_HEAD_PARTS = 64 +MAX_MORPHS = 128 +MAX_MORPH_REGIONS = 128 +MAX_FACIAL_BONE_MORPHS = 128 +MAX_TINTS = 128 + + +def _is_int(value: Any, minimum: int = 0, maximum: int = 0xFFFFFFFF) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum + + +def _is_finite_number(value: Any, minimum: float, maximum: float) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + and minimum <= float(value) <= maximum + ) + + +def _normalize_form_id(value: Any, *, allow_empty: bool = True) -> str | None: + if not isinstance(value, str): + return None + if allow_empty and value == "": + return "" + if not 1 <= len(value) <= 8: + return None + if any(character not in "0123456789abcdefABCDEF" for character in value): + return None + return value.upper().zfill(8) + + +def _normalize_vec3(value: Any, minimum: float, maximum: float) -> list[float] | None: + if not isinstance(value, list) or len(value) != 3: + return None + if not all(_is_finite_number(component, minimum, maximum) for component in value): + return None + return [float(component) for component in value] + + +def _normalize_appearance(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + + clean: dict[str, Any] = {} + version = value.get("version", 4) + if not _is_int(version, 1, 1000): + return None + clean["version"] = int(version) + + for name in ("raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId"): + if name in value: + form_id = _normalize_form_id(value[name]) + if form_id is None: + return None + clean[name] = form_id + + if "height" in value: + if not _is_finite_number(value["height"], 0.25, 4.0): + return None + clean["height"] = float(value["height"]) + + if "isFemale" in value: + if not isinstance(value["isFemale"], bool): + return None + clean["isFemale"] = value["isFemale"] + + if "morphWeight" in value: + weight = value["morphWeight"] + if not isinstance(weight, dict): + return None + clean_weight: dict[str, float] = {} + for name in ("thin", "muscular", "large"): + if not _is_finite_number(weight.get(name), -100.0, 100.0): + return None + clean_weight[name] = float(weight[name]) + clean["morphWeight"] = clean_weight + + if "bodyTintColor" in value: + color = value["bodyTintColor"] + if not isinstance(color, dict): + return None + clean_color: dict[str, int] = {} + for name in ("r", "g", "b", "a"): + if not _is_int(color.get(name), 0, 255): + return None + clean_color[name] = int(color[name]) + clean["bodyTintColor"] = clean_color + + if "headParts" in value: + head_parts = value["headParts"] + if not isinstance(head_parts, list) or len(head_parts) > MAX_HEAD_PARTS: + return None + clean_head_parts: list[str] = [] + for form_id in head_parts: + normalized = _normalize_form_id(form_id, allow_empty=False) + if normalized is None: + return None + clean_head_parts.append(normalized) + clean["headParts"] = clean_head_parts + + if "morphs" in value: + morphs = value["morphs"] + if not isinstance(morphs, list) or len(morphs) > MAX_MORPHS: + return None + clean_morphs: list[dict[str, Any]] = [] + for morph in morphs: + if not isinstance(morph, dict): + return None + morph_id = _normalize_form_id(morph.get("id"), allow_empty=False) + if morph_id is None or not _is_finite_number(morph.get("value"), -1000.0, 1000.0): + return None + clean_morphs.append({"id": morph_id, "value": float(morph["value"])}) + clean["morphs"] = clean_morphs + + if "morphRegions" in value: + regions = value["morphRegions"] + if not isinstance(regions, list) or len(regions) > MAX_MORPH_REGIONS: + return None + if not all(_is_finite_number(region, -1000.0, 1000.0) for region in regions): + return None + clean["morphRegions"] = [float(region) for region in regions] + + if "facialBoneMorphs" in value: + morphs = value["facialBoneMorphs"] + if not isinstance(morphs, list) or len(morphs) > MAX_FACIAL_BONE_MORPHS: + return None + clean_bones: list[dict[str, Any]] = [] + for morph in morphs: + if not isinstance(morph, dict): + return None + morph_id = _normalize_form_id(morph.get("id"), allow_empty=False) + position = _normalize_vec3(morph.get("position"), -10000.0, 10000.0) + rotation = _normalize_vec3(morph.get("rotation"), -10000.0, 10000.0) + scale = _normalize_vec3(morph.get("scale"), -100.0, 100.0) + if morph_id is None or position is None or rotation is None or scale is None: + return None + clean_bones.append( + { + "id": morph_id, + "position": position, + "rotation": rotation, + "scale": scale, + } + ) + clean["facialBoneMorphs"] = clean_bones + + if "tints" in value: + tints = value["tints"] + if not isinstance(tints, list) or len(tints) > MAX_TINTS: + return None + clean_tints: list[dict[str, Any]] = [] + for tint in tints: + if not isinstance(tint, dict): + return None + if not _is_int(tint.get("id"), 0, 0xFFFF): + return None + if not _is_int(tint.get("type"), 0, 0xFFFFFFFF): + return None + if not _is_int(tint.get("value"), 0, 255): + return None + clean_tint: dict[str, Any] = { + "id": int(tint["id"]), + "type": int(tint["type"]), + "value": int(tint["value"]), + } + if "color" in tint: + color = _normalize_form_id(tint["color"], allow_empty=False) + if color is None or not _is_int(tint.get("swatch", 0), 0, 0xFFFF): + return None + clean_tint["color"] = color + clean_tint["swatch"] = int(tint.get("swatch", 0)) + clean_tints.append(clean_tint) + clean["tints"] = clean_tints + + return clean + + +def normalize_player_state_packet( + packet: dict[str, Any], + normalize_action_events: Callable[[Any], list[dict[str, Any]]], +) -> dict[str, Any] | None: + clean: dict[str, Any] = {"type": "playerState"} + has_state = False + + if "equippedItems" in packet: + items = packet["equippedItems"] + if not isinstance(items, list) or len(items) > MAX_EQUIPPED_ITEMS: + return None + clean_items: list[dict[str, str]] = [] + for item in items: + if not isinstance(item, dict): + return None + slot = item.get("slot") + if not isinstance(slot, str) or not 1 <= len(slot) <= MAX_EQUIPMENT_SLOT_CHARS: + return None + form_id = _normalize_form_id(item.get("formId", "")) + if form_id is None: + return None + clean_items.append({"slot": slot, "formId": form_id}) + clean["equippedItems"] = clean_items + has_state = True + + if "appearance" in packet: + appearance = _normalize_appearance(packet["appearance"]) + if appearance is None: + return None + clean["appearance"] = appearance + has_state = True + + if "actionEvents" in packet: + action_events = packet["actionEvents"] + if not isinstance(action_events, list): + return None + normalized_actions = normalize_action_events(action_events) + if len(normalized_actions) != len(action_events): + return None + clean["actionEvents"] = normalized_actions + has_state = True + + if "characterName" in packet: + name = packet["characterName"] + if not isinstance(name, str) or len(name) > MAX_CHARACTER_NAME_CHARS: + return None + clean["characterName"] = name + has_state = True + + return clean if has_state else None