From bdbda626b19759567f4eb05c518cd31d6c511538 Mon Sep 17 00:00:00 2001
From: VigersRay <vigersray@gmail.com>
Date: Fri, 7 Jun 2024 11:59:58 +0300
Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=20=D0=B2=D0=B0?=
 =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=20=D0=B2=20Tools=20?=
 =?UTF-8?q?=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?=
 =?UTF-8?q?=D0=B8=D1=82=D1=8C=20CLA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .github/workflows/validate-rgas.yml    |  10 +-
 Tools/Schemas/mapfile.yml              |  77 ++++++++++
 Tools/Schemas/mapfile_requirements.txt |   1 +
 Tools/Schemas/mapfile_validators.py    |   8 ++
 Tools/Schemas/rga.yml                  |  20 +++
 Tools/Schemas/rga_requirements.txt     |   1 +
 Tools/Schemas/rga_validators.py        |  29 ++++
 Tools/Schemas/rsi.json                 | 190 +++++++++++++++++++++++++
 Tools/Schemas/validate_rsis.py         | 165 +++++++++++++++++++++
 9 files changed, 494 insertions(+), 7 deletions(-)
 create mode 100644 Tools/Schemas/mapfile.yml
 create mode 100644 Tools/Schemas/mapfile_requirements.txt
 create mode 100644 Tools/Schemas/mapfile_validators.py
 create mode 100644 Tools/Schemas/rga.yml
 create mode 100644 Tools/Schemas/rga_requirements.txt
 create mode 100644 Tools/Schemas/rga_validators.py
 create mode 100644 Tools/Schemas/rsi.json
 create mode 100644 Tools/Schemas/validate_rsis.py

diff --git a/.github/workflows/validate-rgas.yml b/.github/workflows/validate-rgas.yml
index 2c4bb40fdf3..ffb643feea8 100644
--- a/.github/workflows/validate-rgas.yml
+++ b/.github/workflows/validate-rgas.yml
@@ -13,13 +13,9 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v3.6.0
-    - name: Setup Submodule
-      run: git submodule update --init
-    - name: Pull engine updates
-      uses: space-wizards/submodule-dependency@v0.1.5
     - uses: PaulRitter/yaml-schema-validator@v1
       with:
-        schema: RobustToolbox/Schemas/rga.yml
+        schema: Tools/Schemas/rga.yml
         path_pattern: .*attributions.ya?ml$
-        validators_path: RobustToolbox/Schemas/rga_validators.py
-        validators_requirements: RobustToolbox/Schemas/rga_requirements.txt
+        validators_path: Tools/Schemas/rga_validators.py
+        validators_requirements: Tools/Schemas/rga_requirements.txt
diff --git a/Tools/Schemas/mapfile.yml b/Tools/Schemas/mapfile.yml
new file mode 100644
index 00000000000..47a29d85da9
--- /dev/null
+++ b/Tools/Schemas/mapfile.yml
@@ -0,0 +1,77 @@
+# schema file for Yamale
+meta:
+  format: int()
+  postmapinit: bool()
+tilemap: map(str(), key=int())
+entities: list(include('proto'), min=1)
+---
+proto:
+  proto: str(required=True)
+  entities: list(include('entity'), min=1)
+---
+entity:
+  uid: int()
+  components: list(comp())
+  missingComponents: list(str(), required=False)
+
+# Example
+# meta:
+#   format: 3
+#   name: DemoStation
+#   author: Space-Wizards
+#   postmapinit: false
+# tilemap:
+#   0: space
+#   1: floor_asteroid_coarse_sand0
+#   2: floor_asteroid_coarse_sand1
+#   3: floor_asteroid_coarse_sand2
+#   4: floor_asteroid_coarse_sand_dug
+#   5: floor_asteroid_sand
+#   6: floor_asteroid_tile
+#   7: floor_blue
+#   8: floor_dark
+#   9: floor_elevator_shaft
+#   10: floor_freezer
+#   11: floor_glass
+#   12: floor_gold
+#   13: floor_green_circuit
+#   14: floor_hydro
+#   15: floor_lino
+#   16: floor_mono
+#   17: floor_reinforced
+#   18: floor_rglass
+#   19: floor_rock_vault
+#   20: floor_showroom
+#   21: floor_snow
+#   22: floor_steel
+#   23: floor_steel_dirty
+#   24: floor_techmaint
+#   25: floor_warning1
+#   26: floor_warning2
+#   27: floor_white
+#   28: floor_white_warning1
+#   29: floor_white_warning2
+#   30: floor_wood
+#   31: lattice
+#   32: plating
+#   33: plating
+# entities:
+# - uid: 0
+#   components:
+#   - parent: null
+#     type: Transform
+#   - index: 0
+#   chunks:
+#   - ind: "-1,-1"
+#     tiles: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgAAAA==
+#     type: MapGrid
+#   - linearDamping: 0.05
+#     fixtures: []
+#     bodyType: Dynamic
+#     type: Physics
+# - uid: 1
+#   type: SpawnPointLatejoin
+#   components:
+#   - parent: 0
+#     pos: 0,0
+#     type: Transform
diff --git a/Tools/Schemas/mapfile_requirements.txt b/Tools/Schemas/mapfile_requirements.txt
new file mode 100644
index 00000000000..4818cc54196
--- /dev/null
+++ b/Tools/Schemas/mapfile_requirements.txt
@@ -0,0 +1 @@
+pyyaml
\ No newline at end of file
diff --git a/Tools/Schemas/mapfile_validators.py b/Tools/Schemas/mapfile_validators.py
new file mode 100644
index 00000000000..be30000d374
--- /dev/null
+++ b/Tools/Schemas/mapfile_validators.py
@@ -0,0 +1,8 @@
+from yamale.validators import Validator
+import yaml
+
+class Component(Validator):
+    tag = "comp"
+
+    def _is_valid(self, value):
+        return 'type' in value
diff --git a/Tools/Schemas/rga.yml b/Tools/Schemas/rga.yml
new file mode 100644
index 00000000000..ae841b3348e
--- /dev/null
+++ b/Tools/Schemas/rga.yml
@@ -0,0 +1,20 @@
+# If this gets updated, make sure to also update https://github.com/space-wizards/RobustToolboxSpecifications
+
+list(include('attribution'), min=1)
+---
+attribution:
+  files: list(str())
+  license: license()
+  copyright: str()
+  source: url()
+
+# Example
+# - files: ["deprecated.png"]
+#   license: "MIT"
+#   copyright: "created by 20kdc"
+#   source: "https://github.com/ParadiseSS13/Paradise"
+# 
+# - files: ["arcadeblue2.png", "boxing.png", "carpetclown.png", "carpetoffice.png", "gym.png", "metaldiamond.png"]
+#   license: "CC-BY-NC-SA-3.0"
+#   copyright: "by WALPVRGIS for Goonstation, taken at commit 236551b95a5b24917c72f3069223026b2dc4e690 from floors.dmi"
+#   source: "https://github.com/goonstation/goonstation"
\ No newline at end of file
diff --git a/Tools/Schemas/rga_requirements.txt b/Tools/Schemas/rga_requirements.txt
new file mode 100644
index 00000000000..3feabebdea6
--- /dev/null
+++ b/Tools/Schemas/rga_requirements.txt
@@ -0,0 +1 @@
+validators
\ No newline at end of file
diff --git a/Tools/Schemas/rga_validators.py b/Tools/Schemas/rga_validators.py
new file mode 100644
index 00000000000..8dc54c033ad
--- /dev/null
+++ b/Tools/Schemas/rga_validators.py
@@ -0,0 +1,29 @@
+from yamale.validators import Validator
+import validators
+
+class License(Validator):
+    tag = "license"
+    licenses = [
+        "CC-BY-3.0",
+        "CC-BY-4.0",
+        "CC-BY-SA-3.0",
+        "CC-BY-SA-4.0",
+        "CC-BY-NC-3.0",
+        "CC-BY-NC-4.0",
+        "CC-BY-NC-SA-3.0",
+        "CC-BY-NC-SA-4.0",
+        "CC0-1.0",
+        "MIT",
+        "CLA",
+        "Custom" # implies that the license is described in the copyright field.
+        ]
+
+    def _is_valid(self, value):
+        return value in self.licenses
+
+class Url(Validator):
+    tag = "url"
+
+    def _is_valid(self, value):
+        # Source field is required to ensure its not neglected, but there may be no applicable URL
+        return (value == "NA") or validators.url(value)
\ No newline at end of file
diff --git a/Tools/Schemas/rsi.json b/Tools/Schemas/rsi.json
new file mode 100644
index 00000000000..56d52c7be1d
--- /dev/null
+++ b/Tools/Schemas/rsi.json
@@ -0,0 +1,190 @@
+{
+    "$schema": "http://json-schema.org/draft-07/schema",
+    "default": {},
+    "description": "JSON Schema for SS14 RSI validation.",
+    "examples": [
+        {
+            "version": 1,
+            "license": "CC-BY-SA-3.0",
+            "copyright": "Taken from CODEBASE at COMMIT PERMALINK",
+            "size": {
+                "x": 32,
+                "y": 32
+            },
+            "states": [
+                {
+                    "name": "basic"
+                },
+                {
+                    "name": "basic-directions",
+                    "directions": 4
+                },
+                {
+                    "name": "basic-delays",
+                    "delays": [
+                        [
+                            0.1,
+                            0.1
+                        ]
+                    ]
+                },
+                {
+                    "name": "basic-delays-directions",
+                    "directions": 4,
+                    "delays": [
+                        [
+                            0.1,
+                            0.1
+                        ],
+                        [
+                            0.1,
+                            0.1
+                        ],
+                        [
+                            0.1,
+                            0.1
+                        ],
+                        [
+                            0.1,
+                            0.1
+                        ]
+                    ]
+                }
+            ]
+        }
+    ],
+    "required": [
+        "version",
+        "license",
+        "copyright",
+        "size",
+        "states"
+    ],
+    "title": "RSI Schema",
+    "type": "object",
+    "properties": {
+        "version": {
+            "$id": "#/properties/version",
+            "default": "",
+            "description": "RSI version integer.",
+            "title": "The version schema",
+            "type": "integer"
+        },
+        "license": {
+            "$id": "#/properties/license",
+            "default": "",
+            "description": "The license for the associated icon states. Restricted to SS14-compatible asset licenses.",
+            "enum": [
+                "CC-BY-3.0",
+                "CC-BY-4.0",
+                "CC-BY-SA-3.0",
+                "CC-BY-SA-4.0",
+                "CC-BY-NC-3.0",
+                "CC-BY-NC-4.0",
+                "CC-BY-NC-SA-3.0",
+                "CC-BY-NC-SA-4.0",
+                "CC0-1.0"
+            ],
+            "examples": [
+                "CC-BY-SA-3.0"
+            ],
+            "title": "License",
+            "type": "string"
+        },
+        "copyright": {
+            "$id": "#/properties/copyright",
+            "type": "string",
+            "title": "Copyright Info",
+            "description": "The copyright holder. This is typically a link to the commit of the codebase that the icon is pulled from.",
+            "default": "",
+            "examples": [
+                "Taken from CODEBASE at COMMIT LINK"
+            ]
+        },
+        "size": {
+            "$id": "#/properties/size",
+            "default": {},
+            "description": "The dimensions of the sprites inside the RSI.  This is not the size of the PNG files that store the sprite sheet.",
+            "examples": [
+                {
+                    "x": 32,
+                    "y": 32
+                }
+            ],
+            "title": "Sprite Dimensions",
+            "required": [
+                "x",
+                "y"
+            ],
+            "type": "object",
+            "properties": {
+                "x": {
+                    "$id": "#/properties/size/properties/x",
+                    "type": "integer",
+                    "default": 32,
+                    "examples": [
+                        32
+                    ]
+                },
+                "y": {
+                    "$id": "#/properties/size/properties/y",
+                    "type": "integer",
+                    "default": 32,
+                    "examples": [
+                        32
+                    ]
+                }
+            },
+            "additionalProperties": true
+        },
+        "states": {
+            "$id": "#/properties/states",
+            "type": "array",
+            "title": "Icon States",
+            "description": "Metadata for icon states. Includes name, directions, delays, etc.",
+            "default": [],
+            "examples": [
+                [
+                    {
+                        "name": "basic"
+                    },
+                    {
+                        "name": "basic-directions",
+                        "directions": 4
+                    }
+                ]
+            ],
+            "additionalItems": true,
+            "items": {
+                "$id": "#/properties/states/items",
+                "type": "object",
+                "required": [
+                    "name"
+                ],
+                "properties": {
+                    "name": {
+                        "type": "string"
+                    },
+                    "directions": {
+                        "type": "integer",
+                        "enum": [
+                            1,
+                            4,
+                            8
+                        ]
+                    },
+                    "delays": {
+                        "type": "array",
+                        "items": {
+                            "type": "array",
+                            "items": {
+                                "type": "number"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "additionalProperties": true
+}
diff --git a/Tools/Schemas/validate_rsis.py b/Tools/Schemas/validate_rsis.py
new file mode 100644
index 00000000000..9d21608fef4
--- /dev/null
+++ b/Tools/Schemas/validate_rsis.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+from PIL import Image
+from glob import iglob
+from jsonschema import Draft7Validator, ValidationError
+from typing import Any, List, Optional
+
+ALLOWED_RSI_DIR_GARBAGE = {
+    "meta.json",
+    ".DS_Store",
+    "thumbs.db",
+    ".directory"
+}
+
+errors: List["RsiError"] = []
+
+def main() -> int:
+    parser = argparse.ArgumentParser("validate_rsis.py", description="Validates RSI file integrity for mistakes the engine does not catch while loading.")
+    parser.add_argument("directories", nargs="+", help="Directories to look for RSIs in")
+
+    args = parser.parse_args()
+    schema = load_schema()
+
+    for dir in args.directories:
+        check_dir(dir, schema)
+
+    for error in errors:
+        print(f"{error.path}: {error.message}")
+
+    return 1 if errors else 0
+
+
+def check_dir(dir: str, schema: Draft7Validator):
+    for rsi_rel in iglob("**/*.rsi", root_dir=dir, recursive=True):
+        rsi_path = os.path.join(dir, rsi_rel)
+        try:
+            check_rsi(rsi_path, schema)
+        except Exception as e:
+            add_error(rsi_path, f"Failed to validate RSI (script bug): {e}")
+
+
+def check_rsi(rsi: str, schema: Draft7Validator):
+    meta_path = os.path.join(rsi, "meta.json")
+
+    # Try to load meta.json
+    try:
+        meta_json = read_json(meta_path)
+    except Exception as e:
+        add_error(rsi, f"Failed to read meta.json: {e}")
+        return
+
+    # Check if meta.json passes schema.
+    schema_errors: List[ValidationError] = list(schema.iter_errors(meta_json))
+    if schema_errors:
+        for error in schema_errors:
+            add_error(rsi, f"meta.json: [{error.json_path}] {error.message}")
+        # meta.json may be corrupt, can't safely proceed.
+        return
+
+    state_names = {state["name"] for state in meta_json["states"]}
+
+    # Go over contents of RSI directory and ensure there is no extra garbage.
+    for name in os.listdir(rsi):
+        if name in ALLOWED_RSI_DIR_GARBAGE:
+            continue
+
+        if not name.endswith(".png"):
+            add_error(rsi, f"Illegal file inside RSI: {name}")
+            continue
+
+        # All PNGs must be defined in the meta.json
+        png_state_name = name[:-4]
+        if png_state_name not in state_names:
+            add_error(rsi, f"PNG not defined in metadata: {name}")
+
+
+    # Validate state delays.
+    for state in meta_json["states"]:
+        state_name: str = state["name"]
+
+        # Validate state delays.
+        delays: Optional[List[List[float]]] = state.get("delays")
+        if not delays:
+            continue
+
+        # Validate directions count in metadata and delays count matches.
+        directions: int = state.get("directions", 1)
+        if directions != len(delays):
+            add_error(rsi, f"{state_name}: direction count ({directions}) doesn't match delay set specified ({len(delays)})")
+            continue
+
+        # Validate that each direction array has the same length.
+        lengths: List[float] = []
+        for dir in delays:
+            # Robust rounds to millisecond precision.
+            lengths.append(round(sum(dir), 3))
+
+        if any(l != lengths[0] for l in lengths):
+            add_error(rsi, f"{state_name}: mismatching total durations between state directions: {', '.join(map(str, lengths))}")
+
+    frame_width = meta_json["size"]["x"]
+    frame_height = meta_json["size"]["y"]
+
+    # Validate state PNGs.
+    # We only check they're the correct size and that they actually exist and load.
+    for state in meta_json["states"]:
+        state_name: str = state["name"]
+
+        png_name = os.path.join(rsi, f"{state_name}.png")
+        try:
+            image = Image.open(png_name)
+        except Exception as e:
+            add_error(rsi, f"{state_name}: failed to open state {state_name}.png")
+            continue
+
+        # Check that size is a multiple of the metadata frame size.
+        size = image.size
+        if size[0] % frame_width != 0 or size[1] % frame_height != 0:
+            add_error(rsi, f"{state_name}: sprite sheet of {size[0]}x{size[1]} is not size multiple of RSI size ({frame_width}x{frame_height}).png")
+            continue
+
+        # Check that the sprite sheet is big enough to possibly fit all the frames listed in metadata.
+        frames_w = size[0] // frame_width
+        frames_h = size[1] // frame_height
+
+        directions: int = state.get("directions", 1)
+        delays: Optional[List[List[float]]] = state.get("delays", [[1]] * directions)
+        frame_count = sum(map(len, delays))
+        max_sheet_frames = frames_w * frames_h
+
+        if frame_count > max_sheet_frames:
+            add_error(rsi, f"{state_name}: sprite sheet of {size[0]}x{size[1]} is too small, metadata defines {frame_count} frames, but it can only fit {max_sheet_frames} at most")
+            continue
+
+    # We're good!
+    return
+
+
+def load_schema() -> Draft7Validator:
+    base_path = os.path.dirname(os.path.realpath(__file__))
+    schema_path = os.path.join(base_path, "rsi.json")
+    schema_json = read_json(schema_path)
+
+    return Draft7Validator(schema_json)
+
+
+def read_json(path: str) -> Any:
+    with open(path, "r", encoding="utf-8-sig") as f:
+        return json.load(f)
+
+
+def add_error(rsi: str, message: str):
+    errors.append(RsiError(rsi, message))
+
+
+class RsiError:
+    def __init__(self, path: str, message: str):
+        self.path = path
+        self.message = message
+
+
+exit(main())