diff --git a/.gitattributes b/.gitattributes index 9832387..6c709e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ third_party/UO_Model3D_v13/model/*.blend filter=lfs diff=lfs merge=lfs -text third_party/UO_Model3D_v13/model/*.fbx filter=lfs diff=lfs merge=lfs -text third_party/UO_Model3D_v13/model/*.glb filter=lfs diff=lfs merge=lfs -text +tests/fixtures/transfer/** -text diff --git a/common/schemas/__init__.py b/common/schemas/__init__.py index 9f1b6ef..8d04b88 100644 --- a/common/schemas/__init__.py +++ b/common/schemas/__init__.py @@ -22,6 +22,7 @@ "spritemotion.pose-annotations": "pose-annotations.schema.json", "spritemotion.equipment-slots": "equipment-slots.schema.json", "spritemotion.asset-pack": "asset-pack.schema.json", + "spritemotion.transfer-artifact": "transfer-artifact.schema.json", } diff --git a/common/schemas/transfer-artifact.schema.json b/common/schemas/transfer-artifact.schema.json new file mode 100644 index 0000000..baa426a --- /dev/null +++ b/common/schemas/transfer-artifact.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spritemotion.local/schemas/transfer-artifact.schema.json", + "title": "SpriteMotion transfer artifact (a finished export that another tool imports)", + "type": "object", + "required": ["schema", "schema_version", "identity", "reproducibility", "animation", "pixels", "frames", "equipment", "acceptance", "provenance"], + "additionalProperties": false, + "$defs": { + "relpath": { + "description": "Path relative to the artifact directory, forward slashes, no '..', not absolute. The reader enforces this too.", + "type": "string", + "minLength": 1, + "pattern": "^(?![A-Za-z]:)(?!/)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$))[^\\\\]+$" + }, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "file_ref": { + "type": "object", "additionalProperties": false, "required": ["path", "sha256"], + "properties": {"path": {"$ref": "#/$defs/relpath"}, "sha256": {"$ref": "#/$defs/sha256"}} + }, + "point": { + "type": "object", "additionalProperties": false, "required": ["x", "y"], + "properties": {"x": {"type": "integer"}, "y": {"type": "integer"}} + }, + "crop": { + "description": "Pixel bounds on the canvas: left/top inclusive, right/bottom exclusive (a PIL bounding box).", + "type": "object", "additionalProperties": false, "required": ["left", "top", "right", "bottom"], + "properties": { + "left": {"type": "integer", "minimum": 0, "maximum": 255}, + "top": {"type": "integer", "minimum": 0, "maximum": 255}, + "right": {"type": "integer", "minimum": 1, "maximum": 256}, + "bottom": {"type": "integer", "minimum": 1, "maximum": 256} + } + }, + "redistribution": {"enum": ["public", "private", "restricted", "unknown"]}, + "provenance_side": { + "type": "object", "additionalProperties": false, "required": ["redistribution"], + "properties": { + "redistribution": {"$ref": "#/$defs/redistribution"}, + "license": {"type": "string"}, + "attribution": {"type": "string"}, + "origin": {"type": "string"}, + "notes": {"type": "string"} + } + } + }, + "properties": { + "schema": {"const": "spritemotion.transfer-artifact"}, + "schema_version": {"const": 1}, + "identity": { + "type": "object", "additionalProperties": false, + "required": ["project_id", "item_id", "slot"], + "properties": { + "project_id": {"type": "string", "minLength": 1}, + "item_id": {"type": "string", "minLength": 1}, + "source_job": {"type": "string"}, + "source_revision": {"type": "string"}, + "slot": {"type": "string", "minLength": 1}, + "body_profile": {"type": "string"} + } + }, + "reproducibility": { + "type": "object", "additionalProperties": false, + "properties": { + "model_fingerprint": {"type": "string"}, + "renderer_fingerprint": {"type": "string"}, + "input_hashes": {"type": "object", "additionalProperties": {"$ref": "#/$defs/sha256"}}, + "fit": {"$ref": "#/$defs/file_ref"}, + "fit_hash": {"$ref": "#/$defs/sha256"}, + "tool_versions": {"type": "object", "additionalProperties": {"type": "string"}} + } + }, + "animation": { + "type": "object", "additionalProperties": false, + "required": ["mirror_map", "coverage", "actions"], + "properties": { + "mirror_map": { + "description": "Directions 5-7 are not stored; each is the horizontal mirror of a stored direction.", + "type": "object", "additionalProperties": false, "required": ["5", "6", "7"], + "properties": {"5": {"const": 3}, "6": {"const": 2}, "7": {"const": 1}} + }, + "coverage": {"enum": ["preview", "current-action", "full"]}, + "sampling": { + "description": "How Blender timeline frames were chosen. Not playback timing.", + "type": "object", "additionalProperties": false, + "properties": { + "first_scene_frame": {"type": "integer", "minimum": 0}, + "scene_frame_step": {"type": "integer", "minimum": 1} + } + }, + "actions": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["action", "frame_count", "directions"], + "properties": { + "action": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}, + "frame_count": {"type": "integer", "minimum": 1}, + "directions": { + "description": "Stored directions present for this action.", + "type": "array", "minItems": 1, "uniqueItems": true, + "items": {"type": "integer", "minimum": 0, "maximum": 4} + }, + "playback": { + "description": "Playback timing, separate from sampling.", + "type": "object", "additionalProperties": false, + "properties": {"frame_delay_ms": {"type": "integer", "minimum": 1}} + } + } + } + } + } + }, + "pixels": { + "type": "object", "additionalProperties": false, + "required": ["canvas", "anchor", "alpha", "quantization"], + "properties": { + "canvas": { + "type": "object", "additionalProperties": false, "required": ["width", "height"], + "properties": {"width": {"const": 256}, "height": {"const": 256}} + }, + "anchor": { + "type": "object", "additionalProperties": false, "required": ["x", "y"], + "properties": {"x": {"const": 128}, "y": {"const": 192}} + }, + "alpha": {"enum": ["straight", "premultiplied", "binary"]}, + "quantization": { + "type": "object", "additionalProperties": false, "required": ["policy"], + "properties": { + "policy": {"description": "per-animation-group: one palette of at most palette_size (max 256) colours per animation group, where a group is one (action, stored direction). That is what anim.mul stores per entry, so GUO encodes without requantizing.", "enum": ["none", "per-animation-group"]}, + "palette_size": {"type": "integer", "minimum": 2, "maximum": 256} + } + } + } + }, + "frames": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["action", "direction", "index", "empty"], + "properties": { + "action": {"type": "integer", "minimum": 0}, + "direction": {"type": "integer", "minimum": 0, "maximum": 4}, + "index": {"type": "integer", "minimum": 0}, + "empty": {"type": "boolean"}, + "png": {"$ref": "#/$defs/relpath"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "crop": {"$ref": "#/$defs/crop"}, + "centre": {"description": "(128 - left, 192 - bottom); optional, checked against the crop when present.", "$ref": "#/$defs/point"} + }, + "if": {"properties": {"empty": {"const": false}}}, + "then": {"required": ["png", "sha256", "crop"]}, + "else": {"not": {"anyOf": [{"required": ["png"]}, {"required": ["sha256"]}, {"required": ["crop"]}, {"required": ["centre"]}]}} + } + }, + "equipment": { + "type": "object", "additionalProperties": false, + "properties": { + "layer": {"type": "integer", "minimum": 1, "maximum": 25}, + "item_art": {"$ref": "#/$defs/file_ref"}, + "paperdoll": { + "description": "Declared support, not inferred. A missing key means undeclared.", + "type": "object", "additionalProperties": false, + "properties": { + "male": {"enum": ["supported", "unsupported", "unknown"]}, + "female": {"enum": ["supported", "unsupported", "unknown"]} + } + }, + "tiledata": { + "description": "GUO tiledata-item keys only; GUO assigns anim. layer, when present, must equal equipment.layer.", + "type": "object", "additionalProperties": false, + "properties": { + "flags": {"description": "Integer bit field (tiledata flags).", "type": "integer", "minimum": 0}, + "weight": {"type": "integer", "minimum": 0}, + "layer": {"type": "integer", "minimum": 1, "maximum": 25}, + "count": {"type": "integer", "minimum": 0}, + "hue": {"type": "integer", "minimum": 0}, + "light": {"type": "integer", "minimum": 0}, + "height": {"type": "integer", "minimum": 0}, + "name": {"type": "string"} + } + }, + "notes": {"type": "string"} + } + }, + "acceptance": { + "type": "object", "additionalProperties": false, "required": ["manual_review"], + "properties": { + "manual_review": {"enum": ["none", "pending", "approved", "rejected"]}, + "validation_report": {"$ref": "#/$defs/file_ref"}, + "known_failures": {"type": "array", "items": {"type": "string"}}, + "compatibility": { + "type": "object", "additionalProperties": false, + "properties": {"client": {"type": "string"}, "shard": {"type": "string"}} + } + } + }, + "provenance": { + "type": "object", "additionalProperties": false, "required": ["source", "rendered"], + "properties": { + "source": {"$ref": "#/$defs/provenance_side"}, + "rendered": {"$ref": "#/$defs/provenance_side"} + } + } + } +} diff --git a/common/transfer.py b/common/transfer.py new file mode 100644 index 0000000..2e5b363 --- /dev/null +++ b/common/transfer.py @@ -0,0 +1,300 @@ +"""Read a SpriteMotion transfer artifact: a finished export another tool imports. + +Standard library only (no numpy, no Pillow). The format is described in docs/transfer-artifact.md and +common/schemas/transfer-artifact.schema.json. ``read(directory)`` returns a ``TransferArtifact`` or raises +``TransferError`` with a message that names the file or field at fault. +""" +from __future__ import annotations + +import hashlib +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from . import schemas +from .jsonio import read_json + +MANIFEST = "transfer.json" +KIND = "spritemotion.transfer-artifact" +CANVAS = (256, 256) +ANCHOR = (128, 192) +MIRROR_MAP = {5: 3, 6: 2, 7: 1} +STORED_DIRECTIONS = (0, 1, 2, 3, 4) +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +class TransferError(ValueError): + """The artifact is malformed, incomplete or fails verification.""" + + +@dataclass(frozen=True) +class Crop: + left: int + top: int + right: int + bottom: int + + @property + def width(self) -> int: + return self.right - self.left + + @property + def height(self) -> int: + return self.bottom - self.top + + def mirrored(self) -> "Crop": + """The crop after a horizontal flip about the anchor column (canvas x = 128).""" + return Crop(2 * ANCHOR[0] - self.right, self.top, 2 * ANCHOR[0] - self.left, self.bottom) + + +def centre(crop: Crop) -> tuple[int, int]: + """Sprite centre for a crop: (128 - left, 192 - bottom). Either value can be negative.""" + return ANCHOR[0] - crop.left, ANCHOR[1] - crop.bottom + + +@dataclass(frozen=True) +class Frame: + action: int + direction: int # stored direction, 0-4 + index: int + path: Path | None # absolute path of the PNG; None for an empty frame + sha256: str | None + crop: Crop | None # None for an empty frame + + @property + def empty(self) -> bool: + return self.crop is None + + @property + def centre(self) -> tuple[int, int] | None: + return None if self.crop is None else centre(self.crop) + + +@dataclass(frozen=True) +class ResolvedFrame: + """A frame as seen for a requested direction (0-7).""" + frame: Frame + direction: int # the direction that was asked for + mirrored: bool # True when the stored pixels must be flipped horizontally about x = 128 + + @property + def crop(self) -> Crop | None: + if self.frame.crop is None: + return None + return self.frame.crop.mirrored() if self.mirrored else self.frame.crop + + @property + def centre(self) -> tuple[int, int] | None: + crop = self.crop + return None if crop is None else centre(crop) + + +class TransferArtifact: + def __init__(self, root: Path, manifest: dict, frames: list[Frame]): + self.root = root + self.manifest = manifest + self.frames = frames + self._by_key = {(f.action, f.direction, f.index): f for f in frames} + self.mirror_map = {int(k): v for k, v in manifest["animation"]["mirror_map"].items()} + + @property + def identity(self) -> dict: + return self.manifest["identity"] + + @property + def actions(self) -> list[int]: + return [a["action"] for a in self.manifest["animation"]["actions"]] + + def frame_count(self, action: int) -> int: + for entry in self.manifest["animation"]["actions"]: + if entry["action"] == action: + return entry["frame_count"] + raise KeyError(f"No action {action} in this artifact.") + + def frame(self, action: int, direction: int, index: int) -> Frame: + """The stored frame for a stored direction (0-4).""" + try: + return self._by_key[(action, direction, index)] + except KeyError: + raise KeyError(f"No stored frame for action {action}, direction {direction}, index {index}.") from None + + def resolve(self, action: int, direction: int, index: int) -> ResolvedFrame: + """The frame for any facing 0-7; directions 5-7 come from the mirror map.""" + if direction in self.mirror_map: + return ResolvedFrame(self.frame(action, self.mirror_map[direction], index), direction, True) + return ResolvedFrame(self.frame(action, direction, index), direction, False) + + +def read(directory: str | Path) -> TransferArtifact: + root = Path(directory) + manifest_path = root / MANIFEST + if not manifest_path.is_file(): + raise TransferError(f"No {MANIFEST} in {root}.") + try: + manifest = read_json(manifest_path) + except ValueError as error: + raise TransferError(f"{MANIFEST} is not valid JSON: {error}") from error + if not isinstance(manifest, dict): + raise TransferError(f"{MANIFEST} is not a JSON object.") + if manifest.get("schema") != KIND: + raise TransferError(f"{MANIFEST}: expected schema {KIND!r}, found {manifest.get('schema')!r}.") + if manifest.get("schema_version") != 1: + raise TransferError(f"{MANIFEST}: unsupported schema_version {manifest.get('schema_version')!r}.") + # The reader's own checks run first so their messages are specific; jsonschema, when present, checks the rest. + _check_structure(manifest) + frames = _read_frames(root, manifest) + errors = schemas.validate(manifest, KIND) + if errors: + raise TransferError(f"{MANIFEST} does not match its schema: " + "; ".join(errors[:5])) + return TransferArtifact(root, manifest, frames) + + +def _check_structure(manifest: dict) -> None: + for section in ("identity", "animation", "pixels", "frames"): + if section not in manifest: + raise TransferError(f"{MANIFEST}: missing section {section!r}.") + animation, pixels = manifest["animation"], manifest["pixels"] + mirror = animation.get("mirror_map") if isinstance(animation, dict) else None + if not isinstance(mirror, dict) or {str(k): v for k, v in mirror.items()} != {str(k): v for k, v in MIRROR_MAP.items()}: + raise TransferError(f"animation.mirror_map must be exactly {MIRROR_MAP}, found {mirror!r}.") + if not isinstance(pixels, dict): + raise TransferError("pixels must be an object.") + canvas, anchor = pixels.get("canvas"), pixels.get("anchor") + if not isinstance(canvas, dict) or (canvas.get("width"), canvas.get("height")) != CANVAS: + raise TransferError(f"pixels.canvas must be {CANVAS[0]}x{CANVAS[1]}, found {canvas!r}.") + if not isinstance(anchor, dict) or (anchor.get("x"), anchor.get("y")) != ANCHOR: + raise TransferError(f"pixels.anchor must be {ANCHOR}, found {anchor!r}.") + if not isinstance(animation.get("actions"), list) or not animation["actions"] or not isinstance(manifest["frames"], list): + raise TransferError("animation.actions and frames must be non-empty lists.") + equipment = manifest.get("equipment") + tiledata = equipment.get("tiledata") if isinstance(equipment, dict) else None + if isinstance(tiledata, dict) and "layer" in tiledata and "layer" in equipment and tiledata["layer"] != equipment["layer"]: + raise TransferError(f"equipment.layer {equipment['layer']!r} and equipment.tiledata.layer {tiledata['layer']!r} differ.") + + +def safe_path(root: Path, rel: Any, what: str) -> Path: + """Resolve a manifest path inside root; reject absolute paths, traversal and escapes.""" + if not isinstance(rel, str) or not rel: + raise TransferError(f"{what}: path must be a non-empty string.") + if rel.startswith("/") or "\\" in rel or (len(rel) > 1 and rel[1] == ":"): + raise TransferError(f"{what}: path {rel!r} is absolute or uses backslashes; use a relative forward-slash path.") + if any(part in ("..", "") for part in rel.split("/")): + raise TransferError(f"{what}: path {rel!r} leaves the artifact directory or has an empty segment.") + root_resolved = root.resolve() + target = (root / rel).resolve() + if root_resolved != target and root_resolved not in target.parents: + raise TransferError(f"{what}: path {rel!r} resolves outside the artifact directory.") + return target + + +def sha256_of(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1 << 16), b""): + digest.update(block) + return digest.hexdigest() + + +def png_size(path: Path) -> tuple[int, int]: + """Width and height from the PNG header (the IHDR chunk), without decoding.""" + with open(path, "rb") as handle: + head = handle.read(24) + if len(head) < 24 or head[:8] != PNG_SIGNATURE or head[12:16] != b"IHDR": + raise TransferError(f"{path.name} is not a PNG file.") + return struct.unpack(">II", head[16:24]) + + +def _verify_file(root: Path, ref: Any, what: str) -> Path: + if not isinstance(ref, dict): + raise TransferError(f"{what}: expected an object with path and sha256.") + path = safe_path(root, ref.get("path"), what) + if not path.is_file(): + raise TransferError(f"{what}: file {ref.get('path')!r} is missing.") + expected = ref.get("sha256") + if not isinstance(expected, str) or sha256_of(path) != expected: + raise TransferError(f"{what}: sha256 mismatch for {ref.get('path')!r}.") + return path + + +def _read_frames(root: Path, manifest: dict) -> list[Frame]: + actions = {} + for entry in manifest["animation"]["actions"]: + action = entry.get("action") + if not isinstance(action, int) or action in actions: + raise TransferError(f"animation.actions: duplicate or invalid action {action!r}.") + directions = entry.get("directions") + if not isinstance(directions, list) or not directions or any(d not in STORED_DIRECTIONS for d in directions): + raise TransferError(f"action {action}: directions must be a list drawn from 0-4 (5-7 are mirrored).") + if not isinstance(entry.get("frame_count"), int) or entry["frame_count"] < 1: + raise TransferError(f"action {action}: frame_count must be a positive integer.") + actions[action] = entry + + # Files other than frames: checked when declared. + for label, ref in (("reproducibility.fit", manifest.get("reproducibility", {}).get("fit")), + ("equipment.item_art", manifest.get("equipment", {}).get("item_art")), + ("acceptance.validation_report", manifest.get("acceptance", {}).get("validation_report"))): + if ref is not None: + _verify_file(root, ref, label) + + frames: list[Frame] = [] + seen: set[tuple[int, int, int]] = set() + for number, item in enumerate(manifest["frames"]): + what = f"frames[{number}]" + if not isinstance(item, dict): + raise TransferError(f"{what}: not an object.") + try: + action, direction, index = item["action"], item["direction"], item["index"] + except KeyError as error: + raise TransferError(f"{what}: missing {error.args[0]!r}.") from None + if action not in actions: + raise TransferError(f"{what}: action {action} is not declared in animation.actions.") + entry = actions[action] + if direction not in entry["directions"]: + raise TransferError(f"{what}: direction {direction} is not a stored direction of action {action} " + f"(5-7 are mirrored, never stored).") + if not isinstance(index, int) or not 0 <= index < entry["frame_count"]: + raise TransferError(f"{what}: index {index!r} is outside 0..{entry['frame_count'] - 1} for action {action}.") + key = (action, direction, index) + if key in seen: + raise TransferError(f"{what}: duplicate frame {key}.") + seen.add(key) + if item.get("empty") is True: + if any(k in item for k in ("png", "sha256", "crop", "centre")): + raise TransferError(f"{what}: an empty frame carries no png, sha256, crop or centre.") + frames.append(Frame(action, direction, index, None, None, None)) + continue + if item.get("empty") is not False: + raise TransferError(f"{what}: 'empty' must be true or false.") + path = _verify_file(root, {"path": item.get("png"), "sha256": item.get("sha256")}, what) + crop = _read_crop(item.get("crop"), what) + width, height = png_size(path) + if (width, height) != (crop.width, crop.height): + raise TransferError(f"{what}: PNG is {width}x{height} but the crop is {crop.width}x{crop.height}.") + stated = item.get("centre") + if stated is not None and not isinstance(stated, dict): + raise TransferError(f"{what}: centre must be an object with x and y, found {stated!r}.") + if stated is not None and (stated.get("x"), stated.get("y")) != centre(crop): + raise TransferError(f"{what}: centre {stated} does not match the crop (expected {centre(crop)}).") + frames.append(Frame(action, direction, index, path, item["sha256"], crop)) + + missing = [(a, d, i) for a, entry in actions.items() for d in entry["directions"] + for i in range(entry["frame_count"]) if (a, d, i) not in seen] + if missing: + raise TransferError(f"frames: {len(missing)} declared frame(s) missing, first {missing[0]} (action, direction, index).") + return frames + + +def _read_crop(raw: Any, what: str) -> Crop: + if not isinstance(raw, dict): + raise TransferError(f"{what}: crop must be an object with left, top, right, bottom.") + try: + values = [raw[k] for k in ("left", "top", "right", "bottom")] + except KeyError as error: + raise TransferError(f"{what}: crop is missing {error.args[0]!r}.") from None + if not all(isinstance(v, int) and not isinstance(v, bool) for v in values): + raise TransferError(f"{what}: crop values must be integers.") + crop = Crop(*values) + if not (0 <= crop.left < crop.right <= CANVAS[0] and 0 <= crop.top < crop.bottom <= CANVAS[1]): + raise TransferError(f"{what}: crop {values} is empty or outside the {CANVAS[0]}x{CANVAS[1]} canvas.") + return crop diff --git a/docs/guo-integration-plan.md b/docs/guo-integration-plan.md index 47bff66..a33999c 100644 --- a/docs/guo-integration-plan.md +++ b/docs/guo-integration-plan.md @@ -70,6 +70,8 @@ Create schemas and a short ADR before implementing the bridge. Everything in thi | Acceptance | Validation report, known failures, manual-review status and intended client/shard compatibility | | Provenance | Source attribution and redistribution classification, separate for editable source and rendered output | +The schema, reader and a synthetic fixture are described in [transfer-artifact.md](transfer-artifact.md). + Use relative artifact paths and reject traversal or missing/hash-mismatched files. Local dependency paths belong in private configuration. Continue to use `SPRITEMOTION_SIDECAR`; GUO should not ingest or publish the sidecar. **Pixel contract.** Preserve UO's 256×256 authoring canvas and anchor (128,192). For a crop with bounds `(left, top, right, bottom)`, the current GUO bridge uses `center_x = 128 - left`, `center_y = 192 - bottom`. Prove this with an asymmetric synthetic sprite, empty frames and negative centres. Preserve stored directions 0–4 and the explicit 5→3, 6→2, 7→1 mirror mapping. Do not inherit the legacy outfit atlas's facing-label convention accidentally. Quantize once per animation group's chosen palette; read back decoded pixels and centres. Keep playback timing separate from the Blender sampling interval. diff --git a/docs/transfer-artifact.md b/docs/transfer-artifact.md new file mode 100644 index 0000000..25553b4 --- /dev/null +++ b/docs/transfer-artifact.md @@ -0,0 +1,109 @@ +# Transfer artifact + +A transfer artifact is a finished SpriteMotion export in a form another tool can import without knowing +SpriteMotion's folder layout. It is one directory: a manifest, `transfer.json`, and the PNG files it lists. +The contract comes from the "Transfer artifact" and "Pixel contract" paragraphs of +[guo-integration-plan.md](guo-integration-plan.md). This page covers the format and the reader. + +The schema is [`common/schemas/transfer-artifact.schema.json`](../common/schemas/transfer-artifact.schema.json) +(kind `spritemotion.transfer-artifact`, `schema_version` 1). **Status: draft for GUO review.** Nothing writes +this format yet: the exporter that produces it from a `tools/uo-content` build job is the next story. + +## What is in the manifest + +| Section | Holds | Required | +|---|---|---| +| `identity` | project id, item id, logical equipment slot; optionally source job, source revision, body profile | project, item, slot | +| `reproducibility` | model and renderer fingerprints, input hashes, a fit snapshot file (path + sha256) and its hash, tool versions | all optional | +| `animation` | `mirror_map`, `coverage` (`preview`, `current-action` or `full`), optional Blender `sampling`, and `actions` | map, coverage, actions | +| `pixels` | canvas, anchor, alpha convention, quantization policy | all | +| `frames` | one entry per stored frame | at least one | +| `equipment` | layer, item art file, declared male/female paperdoll support, tiledata, notes | all optional | +| `acceptance` | manual review status, validation report file, known failures, client/shard compatibility | manual review | +| `provenance` | `source` and `rendered`, each with its own redistribution class, licence and attribution | both sides | + +Optional means optional: nothing is filled in to look complete. An absent `paperdoll` entry means the export +declares nothing about it. + +### Pixels + +- Canvas is 256 x 256 and the anchor is (128, 192). The schema and the reader accept no other values. +- A frame's `crop` is `left, top, right, bottom` on the canvas (left and top inclusive, right and bottom exclusive). + Its PNG holds only the cropped pixels, so the PNG size must equal `(right - left, bottom - top)`. +- The centre is `(128 - left, 192 - bottom)`. Either value can be negative. A frame may state it as `centre`; the + reader checks the stated value against the crop. +- An empty frame has `"empty": true` and no `png`, `sha256`, `crop` or `centre`. It still counts as a frame. +- `alpha` says how the PNG alpha is meant to be read; `quantization` says whether a palette was applied + (`none`, or `per-animation-group` with a `palette_size`). +- An animation group is one (action, stored direction). `per-animation-group` means one palette of at most 256 colours + per (action, stored direction), which is what anim.mul stores per entry, so GUO encodes without requantizing. +- GUO stores 15-bit colour with 1-bit transparency, so `alpha: binary` imports losslessly; straight and premultiplied + alpha are thresholded. + +### Directions and mirroring + +Only directions 0-4 are stored. Directions 5, 6 and 7 are the horizontal mirror of 3, 2 and 1. The manifest +spells this out as `mirror_map` and the reader accepts exactly `{"5": 3, "6": 2, "7": 1}`. The map says nothing +about which compass facing a number means; it is the UO stored-direction convention, not the legacy outfit atlas's. + +A mirrored frame is the stored frame flipped about the anchor column (x = 128), so its crop becomes +`(256 - right, top, 256 - left, bottom)` and its centre x becomes `right - 128`. `ResolvedFrame.crop` and +`.centre` return those values. + +### Equipment tiledata + +`equipment.tiledata` holds exactly GUO's `tiledata-item` keys: `flags`, `weight`, `layer`, `count`, `hue`, `light`, +`height`, `name`, and nothing else (no `anim`: GUO assigns it). The numbers are non-negative integers, `layer` is 1-25 +and `name` is a string. `flags` is an integer bit field, not a list of names: it is what the tiledata file stores, so +no name table has to be kept in step. If `equipment.layer` and `equipment.tiledata.layer` are both present they must +be equal; the reader raises `TransferError` when they differ. + +### Timing + +`sampling` records which Blender timeline frames were rendered (first frame and step). `playback.frame_delay_ms` +on an action is how fast to play it. They are separate on purpose. `frame_delay_ms` is metadata only: the UO client +fixes the timing. + +## Reading it + +`spritemotion.transfer` uses the standard library only (no numpy or Pillow), so it works from a plain +`pip install spritemotion`. + +```python +from spritemotion import transfer + +artifact = transfer.read("path/to/export") # raises transfer.TransferError on any problem +artifact.identity["item_id"] +artifact.actions # [0, 4] +frame = artifact.frame(4, 3, 2) # action, stored direction, index +frame.path, frame.crop, frame.centre +shown = artifact.resolve(4, 6, 2) # any facing 0-7; 6 comes from stored direction 2 +shown.mirrored, shown.crop, shown.centre +transfer.centre(frame.crop) # (128 - left, 192 - bottom) +``` + +`read` always checks, whether or not `jsonschema` is installed: + +- the manifest kind, version, canvas, anchor and mirror map; +- every path is relative, forward-slash, free of `..` and empty segments, and stays inside the directory; +- every listed file exists and matches its sha256 (frames, and the fit snapshot, item art and validation report + when present); +- each PNG's size from its header matches its crop; +- every action has a frame for every stored direction it lists and every index below `frame_count`, with no + duplicates and no stored direction above 4. + +When `jsonschema` is present the manifest is also validated against the schema. Each failure is a `TransferError` +whose message names the frame or field. + +## GUO importer notes + +GUO's first importer refuses `coverage: preview` and needs every stored direction 0-4 for each imported action. The +reader stays permissive and accepts both; the importer enforces its own rules. + +## Test fixture + +`tests/fixtures/transfer/` is a synthetic artifact: no game data, two actions (0 with 2 frames, 4 with 3 frames), +stored directions 0-4, an asymmetric L-shaped sprite, one empty frame (action 4, direction 2, index 1) and one +negative centre (action 4, direction 4, index 0: centre (-22, -12)). Regenerate it with +`python tools/transfer-fixture/run.py`; the output is byte-identical every run and a test checks that the +committed copy matches. diff --git a/tests/fixtures/transfer/frames/a00-d0-f0.png b/tests/fixtures/transfer/frames/a00-d0-f0.png new file mode 100644 index 0000000..85c6808 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d0-f0.png differ diff --git a/tests/fixtures/transfer/frames/a00-d0-f1.png b/tests/fixtures/transfer/frames/a00-d0-f1.png new file mode 100644 index 0000000..3586e68 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d0-f1.png differ diff --git a/tests/fixtures/transfer/frames/a00-d1-f0.png b/tests/fixtures/transfer/frames/a00-d1-f0.png new file mode 100644 index 0000000..e7e38c2 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d1-f0.png differ diff --git a/tests/fixtures/transfer/frames/a00-d1-f1.png b/tests/fixtures/transfer/frames/a00-d1-f1.png new file mode 100644 index 0000000..d0e9b5e Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d1-f1.png differ diff --git a/tests/fixtures/transfer/frames/a00-d2-f0.png b/tests/fixtures/transfer/frames/a00-d2-f0.png new file mode 100644 index 0000000..d463d9a Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d2-f0.png differ diff --git a/tests/fixtures/transfer/frames/a00-d2-f1.png b/tests/fixtures/transfer/frames/a00-d2-f1.png new file mode 100644 index 0000000..7799cdb Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d2-f1.png differ diff --git a/tests/fixtures/transfer/frames/a00-d3-f0.png b/tests/fixtures/transfer/frames/a00-d3-f0.png new file mode 100644 index 0000000..807acf0 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d3-f0.png differ diff --git a/tests/fixtures/transfer/frames/a00-d3-f1.png b/tests/fixtures/transfer/frames/a00-d3-f1.png new file mode 100644 index 0000000..af91aea Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d3-f1.png differ diff --git a/tests/fixtures/transfer/frames/a00-d4-f0.png b/tests/fixtures/transfer/frames/a00-d4-f0.png new file mode 100644 index 0000000..181ca08 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d4-f0.png differ diff --git a/tests/fixtures/transfer/frames/a00-d4-f1.png b/tests/fixtures/transfer/frames/a00-d4-f1.png new file mode 100644 index 0000000..a4ee709 Binary files /dev/null and b/tests/fixtures/transfer/frames/a00-d4-f1.png differ diff --git a/tests/fixtures/transfer/frames/a04-d0-f0.png b/tests/fixtures/transfer/frames/a04-d0-f0.png new file mode 100644 index 0000000..7a4875e Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d0-f0.png differ diff --git a/tests/fixtures/transfer/frames/a04-d0-f1.png b/tests/fixtures/transfer/frames/a04-d0-f1.png new file mode 100644 index 0000000..8795b5e Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d0-f1.png differ diff --git a/tests/fixtures/transfer/frames/a04-d0-f2.png b/tests/fixtures/transfer/frames/a04-d0-f2.png new file mode 100644 index 0000000..c6c37d9 Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d0-f2.png differ diff --git a/tests/fixtures/transfer/frames/a04-d1-f0.png b/tests/fixtures/transfer/frames/a04-d1-f0.png new file mode 100644 index 0000000..d6a7b9b Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d1-f0.png differ diff --git a/tests/fixtures/transfer/frames/a04-d1-f1.png b/tests/fixtures/transfer/frames/a04-d1-f1.png new file mode 100644 index 0000000..546d00a Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d1-f1.png differ diff --git a/tests/fixtures/transfer/frames/a04-d1-f2.png b/tests/fixtures/transfer/frames/a04-d1-f2.png new file mode 100644 index 0000000..3a9cede Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d1-f2.png differ diff --git a/tests/fixtures/transfer/frames/a04-d2-f0.png b/tests/fixtures/transfer/frames/a04-d2-f0.png new file mode 100644 index 0000000..7fe8c3e Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d2-f0.png differ diff --git a/tests/fixtures/transfer/frames/a04-d2-f2.png b/tests/fixtures/transfer/frames/a04-d2-f2.png new file mode 100644 index 0000000..ccced0c Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d2-f2.png differ diff --git a/tests/fixtures/transfer/frames/a04-d3-f0.png b/tests/fixtures/transfer/frames/a04-d3-f0.png new file mode 100644 index 0000000..1e72159 Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d3-f0.png differ diff --git a/tests/fixtures/transfer/frames/a04-d3-f1.png b/tests/fixtures/transfer/frames/a04-d3-f1.png new file mode 100644 index 0000000..d3d9a17 Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d3-f1.png differ diff --git a/tests/fixtures/transfer/frames/a04-d3-f2.png b/tests/fixtures/transfer/frames/a04-d3-f2.png new file mode 100644 index 0000000..a27b6d8 Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d3-f2.png differ diff --git a/tests/fixtures/transfer/frames/a04-d4-f0.png b/tests/fixtures/transfer/frames/a04-d4-f0.png new file mode 100644 index 0000000..d1ed3da Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d4-f0.png differ diff --git a/tests/fixtures/transfer/frames/a04-d4-f1.png b/tests/fixtures/transfer/frames/a04-d4-f1.png new file mode 100644 index 0000000..a0e9abb Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d4-f1.png differ diff --git a/tests/fixtures/transfer/frames/a04-d4-f2.png b/tests/fixtures/transfer/frames/a04-d4-f2.png new file mode 100644 index 0000000..aa2ca45 Binary files /dev/null and b/tests/fixtures/transfer/frames/a04-d4-f2.png differ diff --git a/tests/fixtures/transfer/transfer.json b/tests/fixtures/transfer/transfer.json new file mode 100644 index 0000000..9292f0e --- /dev/null +++ b/tests/fixtures/transfer/transfer.json @@ -0,0 +1,540 @@ +{ + "schema": "spritemotion.transfer-artifact", + "schema_version": 1, + "identity": { + "project_id": "synthetic-fixture", + "item_id": "synthetic-flag", + "source_job": "none", + "source_revision": "0", + "slot": "OneHanded", + "body_profile": "synthetic" + }, + "reproducibility": { + "model_fingerprint": "synthetic", + "renderer_fingerprint": "tools/transfer-fixture/run.py", + "tool_versions": { + "python": "3" + } + }, + "animation": { + "mirror_map": { + "5": 3, + "6": 2, + "7": 1 + }, + "coverage": "preview", + "sampling": { + "first_scene_frame": 1, + "scene_frame_step": 3 + }, + "actions": [ + { + "action": 0, + "name": "stand", + "frame_count": 2, + "directions": [ + 0, + 1, + 2, + 3, + 4 + ], + "playback": { + "frame_delay_ms": 100 + } + }, + { + "action": 4, + "name": "walk", + "frame_count": 3, + "directions": [ + 0, + 1, + 2, + 3, + 4 + ], + "playback": { + "frame_delay_ms": 100 + } + } + ] + }, + "pixels": { + "canvas": { + "width": 256, + "height": 256 + }, + "anchor": { + "x": 128, + "y": 192 + }, + "alpha": "straight", + "quantization": { + "policy": "none" + } + }, + "frames": [ + { + "action": 0, + "direction": 0, + "index": 0, + "empty": false, + "png": "frames/a00-d0-f0.png", + "sha256": "8d68fe8b83b5a03eeb576ea55b54bfdc20a2fbc090c1d487b82a6acc36511ed5", + "crop": { + "left": 104, + "top": 100, + "right": 128, + "bottom": 190 + }, + "centre": { + "x": 24, + "y": 2 + } + }, + { + "action": 0, + "direction": 0, + "index": 1, + "empty": false, + "png": "frames/a00-d0-f1.png", + "sha256": "3e998d10f5608f695a32a89f4053cc9b6ee878e0b7a84da5a34cd642c8e90086", + "crop": { + "left": 107, + "top": 101, + "right": 131, + "bottom": 191 + }, + "centre": { + "x": 21, + "y": 1 + } + }, + { + "action": 0, + "direction": 1, + "index": 0, + "empty": false, + "png": "frames/a00-d1-f0.png", + "sha256": "aca1ac94e895233f5bb974465260fa72a015860da7ee683f61fda02264a292b2", + "crop": { + "left": 106, + "top": 100, + "right": 130, + "bottom": 190 + }, + "centre": { + "x": 22, + "y": 2 + } + }, + { + "action": 0, + "direction": 1, + "index": 1, + "empty": false, + "png": "frames/a00-d1-f1.png", + "sha256": "c075d194848b755361b22fea58a4eea75eb2be78cd0fcd5875321fded10b41c2", + "crop": { + "left": 109, + "top": 101, + "right": 133, + "bottom": 191 + }, + "centre": { + "x": 19, + "y": 1 + } + }, + { + "action": 0, + "direction": 2, + "index": 0, + "empty": false, + "png": "frames/a00-d2-f0.png", + "sha256": "bf4e2887623618dbfea9f203244c022d8ee1d6b39fb685a043812e7e7f9e7056", + "crop": { + "left": 108, + "top": 100, + "right": 132, + "bottom": 190 + }, + "centre": { + "x": 20, + "y": 2 + } + }, + { + "action": 0, + "direction": 2, + "index": 1, + "empty": false, + "png": "frames/a00-d2-f1.png", + "sha256": "5ac13de08e7dc95db8ec86bc2f3b1d918f4022599203139b14eea861e8a6cb6c", + "crop": { + "left": 111, + "top": 101, + "right": 135, + "bottom": 191 + }, + "centre": { + "x": 17, + "y": 1 + } + }, + { + "action": 0, + "direction": 3, + "index": 0, + "empty": false, + "png": "frames/a00-d3-f0.png", + "sha256": "66722bc6d9ad06677b8b0711c23d2b9b6cafe4efe167350d79e7979ddc972cda", + "crop": { + "left": 110, + "top": 100, + "right": 134, + "bottom": 190 + }, + "centre": { + "x": 18, + "y": 2 + } + }, + { + "action": 0, + "direction": 3, + "index": 1, + "empty": false, + "png": "frames/a00-d3-f1.png", + "sha256": "bbb4966dfdbff5a0b869cc4f3e2b8e262440b906fadbe0f3eac5009d11b28567", + "crop": { + "left": 113, + "top": 101, + "right": 137, + "bottom": 191 + }, + "centre": { + "x": 15, + "y": 1 + } + }, + { + "action": 0, + "direction": 4, + "index": 0, + "empty": false, + "png": "frames/a00-d4-f0.png", + "sha256": "34a3a9ced6f4b97108b6c437ab6b0ada4f7ea28dad591b30b025edba65dcb773", + "crop": { + "left": 112, + "top": 100, + "right": 136, + "bottom": 190 + }, + "centre": { + "x": 16, + "y": 2 + } + }, + { + "action": 0, + "direction": 4, + "index": 1, + "empty": false, + "png": "frames/a00-d4-f1.png", + "sha256": "27875e244c5002a6a28424742d0d3ca921394f0e5de4a5212af54cd251c68b9d", + "crop": { + "left": 115, + "top": 101, + "right": 139, + "bottom": 191 + }, + "centre": { + "x": 13, + "y": 1 + } + }, + { + "action": 4, + "direction": 0, + "index": 0, + "empty": false, + "png": "frames/a04-d0-f0.png", + "sha256": "bb5c35c6482159b47c5a94bc6cbcf3ae2dafe83243d18e8ebefa43b88a90020f", + "crop": { + "left": 104, + "top": 100, + "right": 128, + "bottom": 190 + }, + "centre": { + "x": 24, + "y": 2 + } + }, + { + "action": 4, + "direction": 0, + "index": 1, + "empty": false, + "png": "frames/a04-d0-f1.png", + "sha256": "6f39a7b21d9b508e0da5559775f480b712ea39f250d99bf52605b3f163b85fb3", + "crop": { + "left": 107, + "top": 101, + "right": 131, + "bottom": 191 + }, + "centre": { + "x": 21, + "y": 1 + } + }, + { + "action": 4, + "direction": 0, + "index": 2, + "empty": false, + "png": "frames/a04-d0-f2.png", + "sha256": "24b78deeadb05f1be3270b58c3fb98b4bb7f181b22854cca39d1fca200d6d4d4", + "crop": { + "left": 110, + "top": 102, + "right": 134, + "bottom": 192 + }, + "centre": { + "x": 18, + "y": 0 + } + }, + { + "action": 4, + "direction": 1, + "index": 0, + "empty": false, + "png": "frames/a04-d1-f0.png", + "sha256": "fa5e22e35a7c05484cf21d537dcddff2d440227ec7c19be9ed29c1e5ecf3873e", + "crop": { + "left": 106, + "top": 100, + "right": 130, + "bottom": 190 + }, + "centre": { + "x": 22, + "y": 2 + } + }, + { + "action": 4, + "direction": 1, + "index": 1, + "empty": false, + "png": "frames/a04-d1-f1.png", + "sha256": "d1deca7284ed05ee912c095dfdb492ce032750a061d8ef80e6a30f81cfa626fc", + "crop": { + "left": 109, + "top": 101, + "right": 133, + "bottom": 191 + }, + "centre": { + "x": 19, + "y": 1 + } + }, + { + "action": 4, + "direction": 1, + "index": 2, + "empty": false, + "png": "frames/a04-d1-f2.png", + "sha256": "794d8c1a5664e06d95b66c7fadf7d7aeb6a5117f4febb35455e84ca7ff784d81", + "crop": { + "left": 112, + "top": 102, + "right": 136, + "bottom": 192 + }, + "centre": { + "x": 16, + "y": 0 + } + }, + { + "action": 4, + "direction": 2, + "index": 0, + "empty": false, + "png": "frames/a04-d2-f0.png", + "sha256": "e1508f30340bb249a70782242e88011462795fd4aff219016c4ca8b1ffcd08dd", + "crop": { + "left": 108, + "top": 100, + "right": 132, + "bottom": 190 + }, + "centre": { + "x": 20, + "y": 2 + } + }, + { + "action": 4, + "direction": 2, + "index": 1, + "empty": true + }, + { + "action": 4, + "direction": 2, + "index": 2, + "empty": false, + "png": "frames/a04-d2-f2.png", + "sha256": "cbf50ef381fbb042884c33570e7b9c61aaf5dd6bedaa2d81664a1ba83c7dfed3", + "crop": { + "left": 114, + "top": 102, + "right": 138, + "bottom": 192 + }, + "centre": { + "x": 14, + "y": 0 + } + }, + { + "action": 4, + "direction": 3, + "index": 0, + "empty": false, + "png": "frames/a04-d3-f0.png", + "sha256": "1458cdc3948528d9c3e087b9f73576010a00699d833d54f18575ca6c50be2c80", + "crop": { + "left": 110, + "top": 100, + "right": 134, + "bottom": 190 + }, + "centre": { + "x": 18, + "y": 2 + } + }, + { + "action": 4, + "direction": 3, + "index": 1, + "empty": false, + "png": "frames/a04-d3-f1.png", + "sha256": "4e683e3e71f499da730c805593c1b7ccfc6af8852c7ff208f927063d2fd6b7ba", + "crop": { + "left": 113, + "top": 101, + "right": 137, + "bottom": 191 + }, + "centre": { + "x": 15, + "y": 1 + } + }, + { + "action": 4, + "direction": 3, + "index": 2, + "empty": false, + "png": "frames/a04-d3-f2.png", + "sha256": "7d003cef4c2676dc53eec762a263f33018046952f20f71b91353bef225e9deaa", + "crop": { + "left": 116, + "top": 102, + "right": 140, + "bottom": 192 + }, + "centre": { + "x": 12, + "y": 0 + } + }, + { + "action": 4, + "direction": 4, + "index": 0, + "empty": false, + "png": "frames/a04-d4-f0.png", + "sha256": "71316f4e017de874517224dc8607c9b33124a7cfc5493a665712ffe4aa789807", + "crop": { + "left": 150, + "top": 150, + "right": 170, + "bottom": 204 + }, + "centre": { + "x": -22, + "y": -12 + } + }, + { + "action": 4, + "direction": 4, + "index": 1, + "empty": false, + "png": "frames/a04-d4-f1.png", + "sha256": "296c494da083269613a856309bc0d866450c744b2c4e693fabe318c1205e10a7", + "crop": { + "left": 115, + "top": 101, + "right": 139, + "bottom": 191 + }, + "centre": { + "x": 13, + "y": 1 + } + }, + { + "action": 4, + "direction": 4, + "index": 2, + "empty": false, + "png": "frames/a04-d4-f2.png", + "sha256": "c8cc1bc34b6290b4702a866f30c7281fbd3b0792094aeb94456745c0f430a502", + "crop": { + "left": 118, + "top": 102, + "right": 142, + "bottom": 192 + }, + "centre": { + "x": 10, + "y": 0 + } + } + ], + "equipment": { + "layer": 1, + "paperdoll": { + "male": "unknown" + }, + "notes": "Synthetic test sprite; not equipment." + }, + "acceptance": { + "manual_review": "none", + "known_failures": [] + }, + "provenance": { + "source": { + "redistribution": "public", + "license": "CC0-1.0", + "origin": "generated by tools/transfer-fixture/run.py" + }, + "rendered": { + "redistribution": "public", + "license": "CC0-1.0", + "origin": "generated by tools/transfer-fixture/run.py" + } + } +} diff --git a/tests/integration/test_core_install.py b/tests/integration/test_core_install.py index d49b3e9..b48f3ac 100644 --- a/tests/integration/test_core_install.py +++ b/tests/integration/test_core_install.py @@ -16,11 +16,13 @@ STDLIB_ONLY = """ import sys sys.path.insert(0, {target!r}) -import spritemotion, spritemotion.jsonio, spritemotion.schemas as s +import spritemotion, spritemotion.jsonio, spritemotion.schemas as s, spritemotion.transfer as t for kind in s.SCHEMA_FILES: assert s.load_schema(kind) for name in {names!r}: assert s.load_file(name) +artifact = t.read({fixture!r}) +assert len(artifact.frames) == 25 and artifact.frame(4, 4, 0).centre == (-22, -12) leaked = sorted(m for m in ("numpy", "PIL") if m in sys.modules) assert not leaked, leaked print(s.version(), len(s.SCHEMA_FILES)) @@ -67,7 +69,8 @@ def test_wheel_carries_every_schema_and_core_imports_without_imaging(tmp_path): with zipfile.ZipFile(wheel) as archive: archive.extractall(target) # -S leaves out site-packages, so numpy and Pillow could not be imported even by accident. - run = subprocess.run([sys.executable, "-I", "-S", "-c", STDLIB_ONLY.format(names=SCHEMA_NAMES, target=str(target))], + run = subprocess.run([sys.executable, "-I", "-S", "-c", STDLIB_ONLY.format(names=SCHEMA_NAMES, target=str(target), + fixture=str(REPO / "tests/fixtures/transfer"))], capture_output=True, text=True, cwd=tmp_path) assert run.returncode == 0, run.stderr assert run.stdout.split()[0] == __version__ diff --git a/tests/unit/test_transfer.py b/tests/unit/test_transfer.py new file mode 100644 index 0000000..2273187 --- /dev/null +++ b/tests/unit/test_transfer.py @@ -0,0 +1,206 @@ +"""The transfer-artifact reader and its synthetic fixture (tests/fixtures/transfer, made by tools/transfer-fixture).""" +import hashlib +import importlib.util +import json +import shutil +from pathlib import Path + +import pytest + +from spritemotion import schemas, transfer +from spritemotion.transfer import Crop, TransferError + +REPO = Path(__file__).resolve().parents[2] +FIXTURE = REPO / "tests/fixtures/transfer" +KIND = "spritemotion.transfer-artifact" + + +@pytest.fixture +def artifact_dir(tmp_path): + target = tmp_path / "artifact" + shutil.copytree(FIXTURE, target) + return target + + +def edit_manifest(directory, change): + path = directory / "transfer.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + change(manifest) + path.write_text(json.dumps(manifest, indent=1), encoding="utf-8") + + +def frame_entry(manifest, action, direction, index): + return next(f for f in manifest["frames"] if (f["action"], f["direction"], f["index"]) == (action, direction, index)) + + +def test_valid_fixture_reads_and_validates_against_the_schema(): + pytest.importorskip("jsonschema") + manifest = json.loads((FIXTURE / "transfer.json").read_text(encoding="utf-8")) + assert schemas.validate(manifest, KIND, required=True) == [] + artifact = transfer.read(FIXTURE) + assert artifact.actions == [0, 4] + assert len(artifact.frames) == 25 + assert artifact.identity["item_id"] == "synthetic-flag" + assert artifact.frame_count(4) == 3 + + +def test_frames_are_found_by_action_direction_index(): + artifact = transfer.read(FIXTURE) + frame = artifact.frame(4, 3, 2) + assert (frame.action, frame.direction, frame.index) == (4, 3, 2) + assert frame.path.is_file() and not frame.empty + assert transfer.png_size(frame.path) == (frame.crop.width, frame.crop.height) + with pytest.raises(KeyError): + artifact.frame(4, 5, 0) # 5 is mirrored, never stored + with pytest.raises(KeyError): + artifact.frame(9, 0, 0) + + +def test_mirrored_directions_resolve_through_the_map(): + artifact = transfer.read(FIXTURE) + for shown, stored in {5: 3, 6: 2, 7: 1}.items(): + resolved = artifact.resolve(0, shown, 1) + assert resolved.mirrored and resolved.frame is artifact.frame(0, stored, 1) + straight = artifact.resolve(0, 2, 1) + assert not straight.mirrored and straight.crop == straight.frame.crop + + +def test_centre_maths_on_the_asymmetric_sprite(): + artifact = transfer.read(FIXTURE) + frame = artifact.frame(0, 0, 0) + assert frame.crop == Crop(104, 100, 128, 190) + assert transfer.centre(frame.crop) == (24, 2) == frame.centre + # the crop is not square, so a swapped formula would give something else + assert frame.crop.width != frame.crop.height + assert transfer.centre(Crop(100, 60, 124, 190)) == (28, 2) + # a mirrored sprite flips about x = 128, so the centre moves from 128 - left to right - 128 + resolved = artifact.resolve(0, 7, 0) # mirror of stored direction 1 + original = artifact.frame(0, 1, 0).crop + assert resolved.crop == Crop(256 - original.right, original.top, 256 - original.left, original.bottom) + assert resolved.centre == (original.right - 128, 192 - original.bottom) + + +def test_negative_centre_and_empty_frame(): + artifact = transfer.read(FIXTURE) + negative = artifact.frame(4, 4, 0) + assert negative.centre == (-22, -12) + empty = artifact.frame(4, 2, 1) + assert empty.empty and empty.path is None and empty.centre is None + assert artifact.resolve(4, 6, 1).centre is None + + +def test_absolute_path_is_rejected(artifact_dir): + for bad in ("/etc/passwd", "C:/Windows/x.png", "C:\\x.png"): + edit_manifest(artifact_dir, lambda m, bad=bad: frame_entry(m, 0, 0, 0).update(png=bad)) + with pytest.raises(TransferError, match="absolute"): + transfer.read(artifact_dir) + + +def test_traversal_is_rejected(artifact_dir): + outside = artifact_dir.parent / "outside.png" + shutil.copy(artifact_dir / "frames/a00-d0-f0.png", outside) + for bad in ("../outside.png", "frames/../../outside.png", "frames//a00-d0-f0.png"): + edit_manifest(artifact_dir, lambda m, bad=bad: frame_entry(m, 0, 0, 0).update(png=bad)) + with pytest.raises(TransferError, match="leaves the artifact"): + transfer.read(artifact_dir) + + +def test_missing_file_is_rejected(artifact_dir): + (artifact_dir / "frames/a04-d1-f2.png").unlink() + with pytest.raises(TransferError, match="missing"): + transfer.read(artifact_dir) + + +def test_hash_mismatch_is_rejected(artifact_dir): + target = artifact_dir / "frames/a00-d3-f1.png" + data = bytearray(target.read_bytes()) + data[-20] ^= 0xFF # same size and header, different bytes + target.write_bytes(bytes(data)) + with pytest.raises(TransferError, match="sha256 mismatch"): + transfer.read(artifact_dir) + + +def test_bad_mirror_map_is_rejected(artifact_dir): + for bad in ({"5": 3, "6": 1, "7": 1}, {"5": 3, "6": 2}, {"5": 3, "6": 2, "7": 1, "4": 0}): + edit_manifest(artifact_dir, lambda m, bad=bad: m["animation"].update(mirror_map=bad)) + with pytest.raises(TransferError, match="mirror_map"): + transfer.read(artifact_dir) + + +def test_png_size_must_match_the_crop(artifact_dir): + edit_manifest(artifact_dir, lambda m: frame_entry(m, 0, 0, 0)["crop"].update(right=129)) + with pytest.raises(TransferError, match="crop"): + transfer.read(artifact_dir) + + +def test_stated_centre_must_match_the_crop(artifact_dir): + edit_manifest(artifact_dir, lambda m: frame_entry(m, 0, 0, 0)["centre"].update(x=0)) + with pytest.raises(TransferError, match="centre"): + transfer.read(artifact_dir) + + +def test_missing_frame_entry_is_rejected(artifact_dir): + edit_manifest(artifact_dir, lambda m: m["frames"].remove(frame_entry(m, 0, 1, 0))) + with pytest.raises(TransferError, match="missing"): + transfer.read(artifact_dir) + + +def test_a_mirrored_direction_cannot_be_stored(artifact_dir): + edit_manifest(artifact_dir, lambda m: frame_entry(m, 0, 4, 0).update(direction=5)) + with pytest.raises(TransferError, match="not a stored direction"): + transfer.read(artifact_dir) + + +def test_wrong_kind_and_missing_manifest(artifact_dir, tmp_path): + edit_manifest(artifact_dir, lambda m: m.update(schema="spritemotion.game")) + with pytest.raises(TransferError, match="expected schema"): + transfer.read(artifact_dir) + with pytest.raises(TransferError, match="transfer.json"): + transfer.read(tmp_path / "nothing") + + +def test_schema_rejects_what_the_reader_would_also_reject(): + pytest.importorskip("jsonschema") + manifest = json.loads((FIXTURE / "transfer.json").read_text(encoding="utf-8")) + manifest["frames"][0]["png"] = "../x.png" + manifest["animation"]["mirror_map"]["6"] = 1 + manifest["pixels"]["anchor"]["y"] = 191 + text = " ".join(schemas.validate(manifest, KIND, required=True)) + assert "png" in text and "mirror_map" in text and "anchor" in text + + +def test_generator_is_deterministic_and_matches_the_committed_fixture(tmp_path): + spec = importlib.util.spec_from_file_location("transfer_fixture", REPO / "tools/transfer-fixture/run.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + first, second = module.build(tmp_path / "one"), module.build(tmp_path / "two") + + def digest(root): + return {p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) if p.is_file()} + + assert digest(first) == digest(second) == digest(FIXTURE) + + +def test_tiledata_accepts_guo_keys_and_rejects_unknown_ones(): + pytest.importorskip("jsonschema") + manifest = json.loads((FIXTURE / "transfer.json").read_text(encoding="utf-8")) + manifest.setdefault("equipment", {})["tiledata"] = {"flags": 5, "weight": 1, "layer": 5, "count": 1, "hue": 0, + "light": 0, "height": 0, "name": "flag"} + assert schemas.validate(manifest, KIND, required=True) == [] + manifest["equipment"]["tiledata"]["anim"] = 12 + assert schemas.validate(manifest, KIND, required=True) + + +def test_layer_mismatch_between_equipment_and_tiledata_is_rejected(artifact_dir): + edit_manifest(artifact_dir, lambda m: m.setdefault("equipment", {}).update(layer=5, tiledata={"layer": 6})) + with pytest.raises(TransferError, match="differ"): + transfer.read(artifact_dir) + edit_manifest(artifact_dir, lambda m: m["equipment"]["tiledata"].update(layer=5)) + transfer.read(artifact_dir) + + +def test_non_object_centre_is_a_transfer_error(artifact_dir): + edit_manifest(artifact_dir, lambda m: frame_entry(m, 0, 0, 0).update(centre=[24, 2])) + with pytest.raises(TransferError, match="centre must be an object"): + transfer.read(artifact_dir) diff --git a/tools/transfer-fixture/run.py b/tools/transfer-fixture/run.py new file mode 100644 index 0000000..5e532b2 --- /dev/null +++ b/tools/transfer-fixture/run.py @@ -0,0 +1,121 @@ +"""Write the synthetic transfer-artifact fixture (no game data, standard library only). + + python tools/transfer-fixture/run.py [--out tests/fixtures/transfer] + +Two actions, stored directions 0-4, an asymmetric sprite, one empty frame and one negative centre. The output is +deterministic: PNGs use stored (uncompressed) deflate blocks so the bytes do not depend on the zlib build. +""" +import argparse +import hashlib +import json +import shutil +import struct +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OUT = ROOT / "tests/fixtures/transfer" +ACTIONS = {0: {"name": "stand", "frames": 2}, 4: {"name": "walk", "frames": 3}} +DIRECTIONS = range(5) +EMPTY = (4, 2, 1) # action, direction, index of the empty frame +NEGATIVE = (4, 4, 0) # this frame's centre is negative on both axes + + +def crop_for(action, direction, index): + """Canvas bounds (left, top, right, bottom) of a frame's sprite.""" + if (action, direction, index) == NEGATIVE: + return 150, 150, 170, 204 # centre (-22, -12) + left = 104 + 2 * direction + 3 * index + return left, 100 + index, left + 24, 190 + index # centre (24 - 2*dir - 3*idx, 2 - idx) + + +def sprite(width, height, seed): + """RGBA rows: a stem down the left edge, an arm along the top and one bright pixel bottom-right. No symmetry.""" + body = (40 + 30 * seed % 200, 90, 200 - 25 * seed % 180, 255) + rows = [] + for y in range(height): + row = [] + for x in range(width): + if x < 6 or y < 6: + row.append(body) + else: + row.append((0, 0, 0, 0)) + rows.append(row) + rows[height - 1][width - 1] = (255, 240, 0, 255) + return rows + + +def chunk(kind, data): + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + +def png_bytes(rows): + height, width = len(rows), len(rows[0]) + raw = b"".join(b"\x00" + b"".join(bytes(p) for p in row) for row in rows) + blocks = [raw[i:i + 65535] for i in range(0, len(raw), 65535)] or [b""] + deflate = b"".join(struct.pack("I", zlib.adler32(raw) & 0xFFFFFFFF) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", data) + chunk(b"IEND", b"")) + + +def build(out: Path): + if out.exists(): + shutil.rmtree(out) + (out / "frames").mkdir(parents=True) + frames = [] + for action, info in ACTIONS.items(): + for direction in DIRECTIONS: + for index in range(info["frames"]): + key = (action, direction, index) + if key == EMPTY: + frames.append({"action": action, "direction": direction, "index": index, "empty": True}) + continue + left, top, right, bottom = crop_for(*key) + data = png_bytes(sprite(right - left, bottom - top, action * 15 + direction * 3 + index)) + name = f"frames/a{action:02d}-d{direction}-f{index}.png" + (out / name).write_bytes(data) + frames.append({"action": action, "direction": direction, "index": index, "empty": False, + "png": name, "sha256": hashlib.sha256(data).hexdigest(), + "crop": {"left": left, "top": top, "right": right, "bottom": bottom}, + "centre": {"x": 128 - left, "y": 192 - bottom}}) + manifest = { + "schema": "spritemotion.transfer-artifact", + "schema_version": 1, + "identity": {"project_id": "synthetic-fixture", "item_id": "synthetic-flag", "source_job": "none", + "source_revision": "0", "slot": "OneHanded", "body_profile": "synthetic"}, + "reproducibility": {"model_fingerprint": "synthetic", "renderer_fingerprint": "tools/transfer-fixture/run.py", + "tool_versions": {"python": "3"}}, + "animation": { + "mirror_map": {"5": 3, "6": 2, "7": 1}, + "coverage": "preview", + "sampling": {"first_scene_frame": 1, "scene_frame_step": 3}, + "actions": [{"action": a, "name": i["name"], "frame_count": i["frames"], "directions": list(DIRECTIONS), + "playback": {"frame_delay_ms": 100}} for a, i in ACTIONS.items()], + }, + "pixels": {"canvas": {"width": 256, "height": 256}, "anchor": {"x": 128, "y": 192}, "alpha": "straight", + "quantization": {"policy": "none"}}, + "frames": frames, + "equipment": {"layer": 1, "paperdoll": {"male": "unknown"}, + "notes": "Synthetic test sprite; not equipment."}, + "acceptance": {"manual_review": "none", "known_failures": []}, + "provenance": {"source": {"redistribution": "public", "license": "CC0-1.0", "origin": "generated by tools/transfer-fixture/run.py"}, + "rendered": {"redistribution": "public", "license": "CC0-1.0", "origin": "generated by tools/transfer-fixture/run.py"}}, + } + (out / "transfer.json").write_bytes((json.dumps(manifest, indent=1) + "\n").encode("utf-8")) + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + args = parser.parse_args() + out = build(args.out) + print(f"Wrote {sum(1 for _ in out.rglob('*') if _.is_file())} files to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())