Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forked RSI validator to add our CLA and OFL #126

Merged
merged 1 commit into from
Aug 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/validate-rsis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ jobs:
run: |
pip3 install --ignore-installed --user pillow jsonschema
- name: Validate RSIs
# Schema fork for SS220 CLA addition
run: |
python3 RobustToolbox/Schemas/validate_rsis.py Resources/
python3 Schemas/validate_rsis.py Resources/
192 changes: 192 additions & 0 deletions Schemas/rsi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
{
"$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",
"OFL",
"EULA/CLA with a hosting restriction, full text: https://raw.githubusercontent.com/SerbiaStrong-220/space-station-14/master/CLA.txt"
],
"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
}
165 changes: 165 additions & 0 deletions Schemas/validate_rsis.py
Original file line number Diff line number Diff line change
@@ -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())
Loading