diff --git a/.github/Schemas/mapfile.yml b/.github/Schemas/mapfile.yml new file mode 100644 index 00000000000..47a29d85da9 --- /dev/null +++ b/.github/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/.github/Schemas/mapfile_requirements.txt b/.github/Schemas/mapfile_requirements.txt new file mode 100644 index 00000000000..4818cc54196 --- /dev/null +++ b/.github/Schemas/mapfile_requirements.txt @@ -0,0 +1 @@ +pyyaml \ No newline at end of file diff --git a/.github/Schemas/mapfile_validators.py b/.github/Schemas/mapfile_validators.py new file mode 100644 index 00000000000..be30000d374 --- /dev/null +++ b/.github/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/.github/Schemas/rga.yml b/.github/Schemas/rga.yml new file mode 100644 index 00000000000..ae841b3348e --- /dev/null +++ b/.github/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/.github/Schemas/rga_requirements.txt b/.github/Schemas/rga_requirements.txt new file mode 100644 index 00000000000..3feabebdea6 --- /dev/null +++ b/.github/Schemas/rga_requirements.txt @@ -0,0 +1 @@ +validators \ No newline at end of file diff --git a/.github/Schemas/rga_validators.py b/.github/Schemas/rga_validators.py new file mode 100644 index 00000000000..a4734fb7f00 --- /dev/null +++ b/.github/Schemas/rga_validators.py @@ -0,0 +1,28 @@ +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", + "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/.github/Schemas/rsi.json b/.github/Schemas/rsi.json new file mode 100644 index 00000000000..45067ba635f --- /dev/null +++ b/.github/Schemas/rsi.json @@ -0,0 +1,191 @@ +{ + "$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", + "Custom" + ], + "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/.github/Schemas/validate_rsis.py b/.github/Schemas/validate_rsis.py new file mode 100644 index 00000000000..9d21608fef4 --- /dev/null +++ b/.github/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()) diff --git a/.github/workflows/validate-rsis.yml b/.github/workflows/validate-rsis.yml index b76df28e6af..a3586eb800e 100644 --- a/.github/workflows/validate-rsis.yml +++ b/.github/workflows/validate-rsis.yml @@ -23,4 +23,4 @@ jobs: pip3 install --ignore-installed --user pillow jsonschema - name: Validate RSIs run: | - python3 RobustToolbox/Schemas/validate_rsis.py Resources/ + python3 .github/Schemas/validate_rsis.py Resources/