{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://openspatials.com/developers/spec/0.5/openspatials-character.schema.json",
  "title": "OpenSpatials portable character file, version 0.2-draft",
  "description": "Spec: docs/package/04-INTERFACE-SPECS.md section 1. Models and images are referenced by URL, never embedded. Unknown fields are allowed and ignored. Each failure maps to a reason code through x-openspatials-reason (src/spec/validate.js): unknown-version > unsafe-file > too-large > invalid-file. The 256 KiB file limit is checked on the bytes before parsing (src/spec/documents.js). Runtime-only rules (real downloaded sizes, redirects, self-contained GLB, loopback http only when the game is on loopback, unknown item kinds refusing one item) are the connector's job.",
  "type": "object",
  "allOf": [
    {
      "$ref": "#/$defs/noCode"
    }
  ],
  "required": [
    "openspatials",
    "id",
    "name",
    "updated"
  ],
  "properties": {
    "openspatials": {
      "description": "Spec version. Exact known versions only while the major version is 0 (04 section 0).",
      "x-openspatials-reason": "unknown-version",
      "enum": [
        "0.1-draft",
        "0.2-draft"
      ]
    },
    "id": {
      "description": "The file's own URL, absolute or relative to the address the file was fetched from.",
      "$ref": "#/$defs/webUrl"
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "pattern": "\\S"
    },
    "updated": {
      "type": "string",
      "format": "date-time"
    },
    "color": {
      "type": "string",
      "pattern": "^#[0-9a-fA-F]{6}$"
    },
    "looks": {
      "type": "object",
      "properties": {
        "vrm": {
          "$ref": "#/$defs/characterModel"
        },
        "gltf": {
          "allOf": [
            {
              "$ref": "#/$defs/characterModel"
            }
          ],
          "properties": {
            "heightMeters": {
              "type": "number",
              "exclusiveMinimum": 0,
              "maximum": 100
            }
          }
        },
        "simple": {
          "$ref": "#/$defs/characterModel"
        },
        "sprite": {
          "type": "object",
          "required": [
            "url",
            "frameWidth",
            "frameHeight"
          ],
          "properties": {
            "url": {
              "$ref": "#/$defs/webUrl"
            },
            "bytes": {
              "$ref": "#/$defs/imageBytes"
            },
            "frameWidth": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1024,
              "description": "Frame width in pixels, 1 to 1024 (AVATAR-RECORD.md section 2). The whole image stays under 2 MiB."
            },
            "frameHeight": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1024,
              "description": "Frame height in pixels, 1 to 1024 (AVATAR-RECORD.md section 2). The whole image stays under 2 MiB."
            },
            "animations": {
              "description": "Named frame ranges [first, last], counted left to right, top to bottom. The five standard names are idle, walk, run, jump and fall (AVATAR-RECORD.md section 3.1); each is optional. Other names are allowed and ignored by games that do not know them.",
              "type": "object",
              "propertyNames": {
                "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"
              },
              "properties": {
                "idle": {
                  "$ref": "#/$defs/frameRange"
                },
                "walk": {
                  "$ref": "#/$defs/frameRange"
                },
                "run": {
                  "$ref": "#/$defs/frameRange"
                },
                "jump": {
                  "$ref": "#/$defs/frameRange"
                },
                "fall": {
                  "$ref": "#/$defs/frameRange"
                }
              },
              "additionalProperties": {
                "$ref": "#/$defs/frameRange"
              }
            },
            "frameRates": {
              "description": "Optional frames a second per animation name (AVATAR-RECORD.md section 2), so a sheet made from clips of different lengths plays at their speed. Each value is a number from 0.5 to 60. A game that does not read frameRates ignores it and plays the frames at its own speed.",
              "type": "object",
              "propertyNames": {
                "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"
              },
              "additionalProperties": {
                "$ref": "#/$defs/frameRate"
              }
            }
          }
        },
        "portrait": {
          "type": "object",
          "required": [
            "url"
          ],
          "properties": {
            "url": {
              "$ref": "#/$defs/webUrl"
            },
            "bytes": {
              "$ref": "#/$defs/imageBytes"
            }
          }
        }
      }
    },
    "items": {
      "type": "array",
      "maxItems": 32,
      "items": {
        "$ref": "#/$defs/item"
      }
    },
    "ownership": {
      "description": "URL of a signed Universal Manifest v0.4 ownership manifest (src/spec/openspatials-ownership.schema.json). No link means show-only.",
      "$ref": "#/$defs/webUrl"
    }
  },
  "$defs": {
    "webUrl": {
      "description": "An https address, an http address on a loopback host, or a relative path resolved against the character file's address. White space makes it invalid-file. Any other scheme, a private network address, user info in the address, or a protocol-relative address is unsafe-file (04 section 1).",
      "type": "string",
      "minLength": 1,
      "maxLength": 2048,
      "pattern": "^\\S+$",
      "if": {
        "type": "string",
        "pattern": "^\\S+$"
      },
      "then": {
        "$ref": "#/$defs/safeUrl"
      }
    },
    "safeUrl": {
      "x-openspatials-reason": "unsafe-file",
      "anyOf": [
        {
          "$ref": "#/$defs/httpsUrl"
        },
        {
          "$ref": "#/$defs/loopbackHttpUrl"
        },
        {
          "type": "string",
          "pattern": "^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\\\/][\\\\/])\\S+$"
        }
      ]
    },
    "httpsUrl": {
      "type": "string",
      "pattern": "^https://(?!(?:10|0)\\.\\d)(?!192\\.168\\.)(?!169\\.254\\.)(?!172\\.(?:1[6-9]|2\\d|3[01])\\.)(?!\\[[fF][cdeCDE])[^\\s/?#@\\\\]+(?:[/?#]\\S*)?$"
    },
    "loopbackHttpUrl": {
      "type": "string",
      "pattern": "^http://(?:127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|localhost|\\[::1\\])(?::\\d{1,5})?(?:[/?#]\\S*)?$"
    },
    "characterModel": {
      "description": "A self-contained glTF 2.0 binary (.glb) or VRM model. bytes is required on models. Limit 20 MiB (04 section 1). forward (optional, AVATAR-RECORD.md section 2.2) names the model-space axis the model's front faces: +Z (glTF's front, the default), -Z, +X or -X. animations (optional, AVATAR-RECORD.md section 2.2.1) lists the model's clip names, or maps the five standard names to the model's own clip names.",
      "type": "object",
      "required": [
        "url",
        "bytes"
      ],
      "properties": {
        "url": {
          "$ref": "#/$defs/webUrl"
        },
        "bytes": {
          "type": "integer",
          "minimum": 1,
          "allOf": [
            {
              "x-openspatials-reason": "too-large",
              "maximum": 20971520
            }
          ]
        },
        "forward": {
          "description": "The model's front in its own model space: +Z (glTF's front, the default when omitted), -Z, +X or -X. A runtime turns the model about its up axis so this axis points where the character moves, and a looks tool renders 2D frames from this side. A vrm look without forward follows its own VRM version (VRM 1.0 +Z, VRM 0.x -Z).",
          "enum": [
            "+Z",
            "-Z",
            "+X",
            "-X"
          ]
        },
        "animations": {
          "description": "Either a list of the clip names the model carries ([\"idle\", \"walk\", \"run\"]), or a clip-name map from the five standard names (idle, walk, run, jump, fall) to the model's own clip names ({ \"idle\": \"Animation_00\", \"walk\": \"Animation_04\" }), for a model whose clip names no standard name or alias matches. A runtime plays the mapped clip for that name when the model has it. Other names in a map are ignored.",
          "anyOf": [
            {
              "type": "array",
              "maxItems": 256,
              "items": {
                "$ref": "#/$defs/clipName"
              }
            },
            {
              "type": "object",
              "propertyNames": {
                "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"
              },
              "properties": {
                "idle": {
                  "$ref": "#/$defs/clipName"
                },
                "walk": {
                  "$ref": "#/$defs/clipName"
                },
                "run": {
                  "$ref": "#/$defs/clipName"
                },
                "jump": {
                  "$ref": "#/$defs/clipName"
                },
                "fall": {
                  "$ref": "#/$defs/clipName"
                }
              },
              "additionalProperties": {
                "$ref": "#/$defs/clipName"
              }
            }
          ]
        }
      }
    },
    "itemModel": {
      "description": "A self-contained glTF 2.0 binary item model. Limit 2 MiB (04 section 1).",
      "type": "object",
      "required": [
        "url",
        "bytes"
      ],
      "properties": {
        "url": {
          "$ref": "#/$defs/webUrl"
        },
        "bytes": {
          "type": "integer",
          "minimum": 1,
          "allOf": [
            {
              "x-openspatials-reason": "too-large",
              "maximum": 2097152
            }
          ]
        }
      }
    },
    "imageBytes": {
      "description": "Optional declared size of an image. Limit 2 MiB.",
      "type": "integer",
      "minimum": 1,
      "allOf": [
        {
          "x-openspatials-reason": "too-large",
          "maximum": 2097152
        }
      ]
    },
    "icon": {
      "description": "A URL string, or { url, bytes }.",
      "anyOf": [
        {
          "$ref": "#/$defs/webUrl"
        },
        {
          "type": "object",
          "required": [
            "url"
          ],
          "properties": {
            "url": {
              "$ref": "#/$defs/webUrl"
            },
            "bytes": {
              "$ref": "#/$defs/imageBytes"
            }
          }
        }
      ]
    },
    "item": {
      "type": "object",
      "required": [
        "id",
        "name",
        "kind"
      ],
      "anyOf": [
        {
          "required": [
            "model"
          ]
        },
        {
          "required": [
            "icon"
          ]
        }
      ],
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]{1,64}$"
        },
        "name": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "\\S"
        },
        "kind": {
          "description": "cosmetic, tool or collectible in 0.2-draft. Any other token is a valid file, but the connector refuses that item only with unknown-kind:<kind>.",
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]{0,31}$"
        },
        "slot": {
          "description": "Hint: head, hand, back or none. An unknown hint is ignored.",
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]{0,31}$"
        },
        "model": {
          "$ref": "#/$defs/itemModel"
        },
        "icon": {
          "$ref": "#/$defs/icon"
        }
      }
    },
    "frameRange": {
      "description": "A frame range [first, last] on a sprite sheet: two whole numbers, 0 or more.",
      "type": "array",
      "prefixItems": [
        {
          "type": "integer",
          "minimum": 0
        },
        {
          "type": "integer",
          "minimum": 0
        }
      ],
      "minItems": 2,
      "maxItems": 2
    },
    "frameRate": {
      "description": "Frames a second: a number from 0.5 to 60, the range the kit's 2D layer plays.",
      "type": "number",
      "minimum": 0.5,
      "maximum": 60
    },
    "clipName": {
      "description": "The name of a clip inside the model: 1 to 128 characters, no control characters.",
      "type": "string",
      "minLength": 1,
      "maxLength": 128,
      "pattern": "^[^\\x00-\\x1f\\x7f]+$"
    },
    "noCode": {
      "description": "04 section 1, closed list, at any depth: a key named script, scripts, eval or javascript; a key that is 'on' followed by a capital letter; a string value starting with javascript:, vbscript: or data:text/html (any case, after leading white space).",
      "x-openspatials-reason": "unsafe-file",
      "propertyNames": {
        "not": {
          "pattern": "^(?:script|scripts|eval|javascript|on[A-Z].*)$"
        }
      },
      "additionalProperties": {
        "$ref": "#/$defs/noCode"
      },
      "items": {
        "$ref": "#/$defs/noCode"
      },
      "not": {
        "type": "string",
        "pattern": "^\\s*(?:[Jj][Aa][Vv][Aa][Ss][Cc][Rr][Ii][Pp][Tt]:|[Vv][Bb][Ss][Cc][Rr][Ii][Pp][Tt]:|[Dd][Aa][Tt][Aa]:[Tt][Ee][Xx][Tt]/[Hh][Tt][Mm][Ll])"
      }
    }
  }
}
