diff --git a/backend/render/src/scene.ts b/backend/render/src/scene.ts
index 7e0ddc43..5ea8f6b5 100644
--- a/backend/render/src/scene.ts
+++ b/backend/render/src/scene.ts
@@ -209,10 +209,12 @@ export async function renderPreviewJob(
scene.add(worn);
frameObject(camera, worn);
out[view] = capture(renderer, scene, camera);
- } else if (view === "model") {
+ } else if (view === "model" || view === "pull_2" || view === "charged") {
+ // 3D bows also show their drawn (and crossbows their loaded) model.
+ const modelKey = view !== "model" ? view : kind === "gun" ? "carry" : "model";
const { root } = await buildItemRoot(kind, assets, {
center: true,
- modelKey: kind === "gun" ? "carry" : "model",
+ modelKey,
});
scene.add(root);
const box = new THREE.Box3().setFromObject(root);
diff --git a/backend/src/api/skins_routes.py b/backend/src/api/skins_routes.py
index e1aa2021..ac253ca9 100644
--- a/backend/src/api/skins_routes.py
+++ b/backend/src/api/skins_routes.py
@@ -669,16 +669,17 @@ async def post_submissions(
tiers_list = [tier]
remapped: dict[str, bytes] = {}
remapped_names: dict[str, str | None] = {}
- for field in ARMOR_FIELDS:
+ legacy = ARMOR_FIELDS + tuple(f"{f}_mcmeta" for f in ARMOR_FIELDS)
+ for field in legacy:
if field in files_bytes:
tier_key = f"{tier}_{field}"
remapped[tier_key] = files_bytes[field]
remapped_names[tier_key] = filenames.get(field)
files_bytes = {
- k: v for k, v in files_bytes.items() if k not in ARMOR_FIELDS
+ k: v for k, v in files_bytes.items() if k not in legacy
}
filenames = {
- k: v for k, v in filenames.items() if k not in ARMOR_FIELDS
+ k: v for k, v in filenames.items() if k not in legacy
}
files_bytes.update(remapped)
filenames.update(remapped_names)
@@ -820,7 +821,8 @@ def get_thumbnail(
# The owner's uploads only; staff sheets and server files stay staff-side.
-_OWNER_FILE_SUFFIXES = (".png", ".json")
+# An animated texture has a sibling `.png.mcmeta`; it 404s when the PNG is still.
+_OWNER_FILE_SUFFIXES = (".png", ".json", ".png.mcmeta")
_OWNER_HIDDEN_FILES = frozenset({"meta.json", "review_sheet.png", "name_preview.png"})
diff --git a/backend/src/characters/web_entitlements.py b/backend/src/characters/web_entitlements.py
index 5a4c0ba2..6ef792c2 100644
--- a/backend/src/characters/web_entitlements.py
+++ b/backend/src/characters/web_entitlements.py
@@ -17,6 +17,8 @@
"bow",
"large_bow",
"crossbow",
+ "bow_3d",
+ "crossbow_3d",
"item_3d",
"shield",
"helmet_3d",
diff --git a/backend/src/skins/animation.py b/backend/src/skins/animation.py
new file mode 100644
index 00000000..84d900cc
--- /dev/null
+++ b/backend/src/skins/animation.py
@@ -0,0 +1,196 @@
+"""Minecraft ``.png.mcmeta`` texture animation: validation and first-frame crops.
+
+Frames are laid out the way Minecraft reads them: frame size from the
+animation's width/height (or a square of the shorter image side), numbered
+left to right, then top to bottom.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+from typing import Any
+
+from PIL import Image
+
+from .base_sets import mcmeta_max_bytes
+
+_ANIMATION_KEYS = frozenset({"frametime", "interpolate", "width", "height", "frames"})
+
+
+class AnimationError(ValueError):
+ """Invalid .mcmeta or a PNG that does not fit its frames."""
+
+
+def _positive_int(value: Any, *, minimum: int) -> bool:
+ return isinstance(value, int) and not isinstance(value, bool) and value >= minimum
+
+
+def frame_size(animation: dict[str, Any], image: tuple[int, int]) -> tuple[int, int]:
+ img_w, img_h = image
+ w = animation.get("width")
+ h = animation.get("height")
+ if w is not None and h is not None:
+ return w, h
+ if w is not None:
+ return w, img_h
+ if h is not None:
+ return img_w, h
+ side = min(img_w, img_h)
+ return side, side
+
+
+def parse_mcmeta(
+ data: bytes,
+ image: tuple[int, int],
+ expected_frame: tuple[int, int] | None = None,
+ *,
+ label: str = "texture",
+) -> tuple[dict[str, Any], int]:
+ """Validate an .mcmeta for a PNG of ``image`` size; return (clean json, frame count)."""
+ cap = mcmeta_max_bytes()
+ if not data:
+ raise AnimationError(f"{label}: .mcmeta file is empty")
+ if len(data) > cap:
+ raise AnimationError(
+ f"{label}: .mcmeta is {len(data)} bytes (limit {cap} bytes)"
+ )
+ try:
+ raw = json.loads(data.decode("utf-8-sig"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
+ raise AnimationError(f"{label}: .mcmeta is not valid JSON") from e
+ if not isinstance(raw, dict) or set(raw) != {"animation"}:
+ raise AnimationError(
+ f'{label}: .mcmeta must be an object with only an "animation" section'
+ )
+ anim = raw["animation"]
+ if not isinstance(anim, dict):
+ raise AnimationError(f'{label}: .mcmeta "animation" must be an object')
+ unknown = sorted(set(anim) - _ANIMATION_KEYS)
+ if unknown:
+ raise AnimationError(
+ f"{label}: .mcmeta animation has unsupported keys: {', '.join(unknown)}"
+ )
+
+ clean: dict[str, Any] = {}
+ if "frametime" in anim:
+ if not _positive_int(anim["frametime"], minimum=1):
+ raise AnimationError(f"{label}: .mcmeta frametime must be a whole number ≥ 1")
+ clean["frametime"] = anim["frametime"]
+ if "interpolate" in anim:
+ if not isinstance(anim["interpolate"], bool):
+ raise AnimationError(f"{label}: .mcmeta interpolate must be true or false")
+ clean["interpolate"] = anim["interpolate"]
+ for key in ("width", "height"):
+ if key in anim:
+ if not _positive_int(anim[key], minimum=1):
+ raise AnimationError(f"{label}: .mcmeta {key} must be a whole number ≥ 1")
+ clean[key] = anim[key]
+
+ img_w, img_h = image
+ fw, fh = frame_size(clean, image)
+ if expected_frame is not None and (fw, fh) != expected_frame:
+ ew, eh = expected_frame
+ raise AnimationError(
+ f"{label}: animation frames must be {ew}x{eh}, got {fw}x{fh}"
+ )
+ if fw > img_w or fh > img_h or img_w % fw or img_h % fh:
+ raise AnimationError(
+ f"{label}: a {img_w}x{img_h} PNG is not a whole grid of {fw}x{fh} frames"
+ )
+ count = (img_w // fw) * (img_h // fh)
+
+ if "frames" in anim:
+ frames = anim["frames"]
+ if not isinstance(frames, list) or not frames:
+ raise AnimationError(f"{label}: .mcmeta frames must be a non-empty list")
+ out_frames: list[Any] = []
+ for entry in frames:
+ if isinstance(entry, dict):
+ if set(entry) - {"index", "time"} or "index" not in entry:
+ raise AnimationError(
+ f"{label}: .mcmeta frame entries need an index (and optional time)"
+ )
+ index = entry["index"]
+ if "time" in entry and not _positive_int(entry["time"], minimum=1):
+ raise AnimationError(f"{label}: .mcmeta frame time must be ≥ 1")
+ cleaned = {"index": index}
+ if "time" in entry:
+ cleaned["time"] = entry["time"]
+ else:
+ index = entry
+ cleaned = entry
+ if not _positive_int(index, minimum=0):
+ raise AnimationError(f"{label}: .mcmeta frame index must be a whole number ≥ 0")
+ if index >= count:
+ raise AnimationError(
+ f"{label}: .mcmeta frame {index} does not exist (PNG has {count} frames)"
+ )
+ out_frames.append(cleaned)
+ clean["frames"] = out_frames
+ return {"animation": clean}, count
+
+
+def mcmeta_bytes(clean: dict[str, Any]) -> bytes:
+ return json.dumps(clean, separators=(",", ":")).encode("utf-8")
+
+
+def mcmeta_path(png: Path) -> Path:
+ return png.with_name(png.name + ".mcmeta")
+
+
+def _read_animation(png: Path) -> tuple[dict[str, Any], tuple[int, int], int] | None:
+ """(animation, frame size, frame count) for a stored PNG with an .mcmeta, else None."""
+ meta = mcmeta_path(png)
+ if not meta.is_file():
+ return None
+ try:
+ anim = json.loads(meta.read_text(encoding="utf-8")).get("animation") or {}
+ with Image.open(png) as im:
+ size = im.size
+ except (OSError, ValueError, AttributeError):
+ return None
+ fw, fh = frame_size(anim, size)
+ if fw <= 0 or fh <= 0 or size[0] % fw or size[1] % fh:
+ return None
+ return anim, (fw, fh), (size[0] // fw) * (size[1] // fh)
+
+
+def frame_count(png: Path) -> int:
+ """Animation frames of a stored PNG; 1 when it is not animated."""
+ info = _read_animation(png)
+ return info[2] if info else 1
+
+
+def first_frame(png: Path, image: Image.Image) -> Image.Image:
+ """Crop ``image`` (loaded from ``png``) to the frame Minecraft shows first."""
+ info = _read_animation(png)
+ if info is None:
+ return image
+ anim, (fw, fh), count = info
+ index = 0
+ frames = anim.get("frames")
+ if isinstance(frames, list) and frames:
+ head = frames[0]
+ index = head.get("index", 0) if isinstance(head, dict) else head
+ if not isinstance(index, int) or not 0 <= index < count:
+ index = 0
+ cols = image.width // fw
+ x, y = (index % cols) * fw, (index // cols) * fh
+ return image.crop((x, y, x + fw, y + fh))
+
+
+def first_frame_png(png: Path) -> bytes | None:
+ """First-frame PNG bytes for an animated stored PNG, else None."""
+ if not mcmeta_path(png).is_file():
+ return None
+ try:
+ with Image.open(png) as im:
+ im.load()
+ frame = first_frame(png, im.convert("RGBA"))
+ except OSError:
+ return None
+ buf = io.BytesIO()
+ frame.save(buf, format="PNG")
+ return buf.getvalue()
diff --git a/backend/src/skins/base_sets.py b/backend/src/skins/base_sets.py
new file mode 100644
index 00000000..6fdb9f07
--- /dev/null
+++ b/backend/src/skins/base_sets.py
@@ -0,0 +1,53 @@
+"""Load what player skins can apply to (ProvinceSystem/shared/skins/base_sets.json).
+
+The frontend reads the same file, so the upload picker and these checks agree.
+"""
+
+from __future__ import annotations
+
+import json
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+# backend/src/skins → ProvinceSystem/
+_PROVINCE_ROOT = Path(__file__).resolve().parents[3]
+_BASE_SETS_PATH = _PROVINCE_ROOT / "shared" / "skins" / "base_sets.json"
+
+
+@lru_cache(maxsize=1)
+def load_base_sets() -> dict[str, Any]:
+ with _BASE_SETS_PATH.open(encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, dict):
+ raise RuntimeError(f"Invalid base sets at {_BASE_SETS_PATH}")
+ return data
+
+
+def base_sets_by_kind() -> dict[str, frozenset[str]]:
+ """Upload kind → ArmourShop base set ids it may target (armour excluded)."""
+ out: dict[str, set[str]] = {}
+ for row in load_base_sets()["base_sets"]:
+ for kind in row["kinds"]:
+ out.setdefault(kind, set()).add(row["id"])
+ return {kind: frozenset(ids) for kind, ids in out.items()}
+
+
+def armor_single_sets() -> dict[str, str]:
+ """Single armour set tier id → label (``wild_mage`` → ``Wild Mage``)."""
+ return {
+ row["id"]: row["label"] for row in load_base_sets()["armor"]["single_sets"]
+ }
+
+
+def limit(name: str) -> int:
+ return int(load_base_sets()["limits"][name])
+
+
+def ranged_3d_total_bytes() -> int:
+ """Every file of a bow_3d / crossbow_3d upload together, for every rank."""
+ return limit("ranged_3d_total_bytes")
+
+
+def mcmeta_max_bytes() -> int:
+ return limit("mcmeta_max_bytes")
diff --git a/backend/src/skins/display.py b/backend/src/skins/display.py
index 26141d3f..71ae9523 100644
--- a/backend/src/skins/display.py
+++ b/backend/src/skins/display.py
@@ -90,6 +90,57 @@
"fixed": {"scale": [2.5, 2.5, 2.5]},
}
+# Vanilla item/bow.json and item/crossbow.json hand poses (autofill holes only).
+_BOW_3D_DEFAULTS: dict[str, Any] = {
+ **copy.deepcopy(_DEFAULT_TAB),
+ "thirdperson_righthand": {
+ "rotation": [-80, 260, -40],
+ "translation": [-1, -2, 2.5],
+ "scale": [0.9, 0.9, 0.9],
+ },
+ "thirdperson_lefthand": {
+ "rotation": [-80, -280, 40],
+ "translation": [-1, -2, 2.5],
+ "scale": [0.9, 0.9, 0.9],
+ },
+ "firstperson_righthand": {
+ "rotation": [0, -90, 25],
+ "translation": [1.13, 3.2, 1.13],
+ "scale": [0.68, 0.68, 0.68],
+ },
+ "firstperson_lefthand": {
+ "rotation": [0, 90, -25],
+ "translation": [1.13, 3.2, 1.13],
+ "scale": [0.68, 0.68, 0.68],
+ },
+}
+_CROSSBOW_3D_DEFAULTS: dict[str, Any] = {
+ **copy.deepcopy(_DEFAULT_TAB),
+ "thirdperson_righthand": {
+ "rotation": [-90, 0, -60],
+ "translation": [2, 0.1, -3],
+ "scale": [0.9, 0.9, 0.9],
+ },
+ "thirdperson_lefthand": {
+ "rotation": [-90, 0, 30],
+ "translation": [2, 0.1, -3],
+ "scale": [0.9, 0.9, 0.9],
+ },
+ "firstperson_righthand": {
+ "rotation": [-90, 0, -55],
+ "translation": [1.13, 3.2, 1.13],
+ "scale": [0.68, 0.68, 0.68],
+ },
+ "firstperson_lefthand": {
+ "rotation": [-90, 0, 35],
+ "translation": [1.13, 3.2, 1.13],
+ "scale": [0.68, 0.68, 0.68],
+ },
+}
+
+# Held 3D kinds: hand/ground/gui/fixed tabs, no head unless the player adds one.
+_HELD_KINDS = ("item_3d", "gun", "bow_3d", "crossbow_3d")
+
class DisplayError(ValueError):
"""Invalid or incomplete model display after merge."""
@@ -182,7 +233,7 @@ def validate_java_block_model(model: dict[str, Any]) -> None:
def required_tabs(kind: str) -> tuple[str, ...]:
if kind in ("shield", "helmet_3d", "armor_helmet_3d", "mask"):
return COMMON_TABS + (HEAD_TAB,)
- if kind in ("item_3d", "gun"):
+ if kind in _HELD_KINDS:
return COMMON_TABS
raise DisplayError(f"Unknown 3D kind for display: {kind}")
@@ -190,6 +241,10 @@ def required_tabs(kind: str) -> tuple[str, ...]:
def defaults_for_kind(kind: str) -> dict[str, Any]:
if kind == "shield":
return copy.deepcopy(_SHIELD_DEFAULTS)
+ if kind == "bow_3d":
+ return copy.deepcopy(_BOW_3D_DEFAULTS)
+ if kind == "crossbow_3d":
+ return copy.deepcopy(_CROSSBOW_3D_DEFAULTS)
return copy.deepcopy(_DEFAULT_TAB)
@@ -203,8 +258,8 @@ def merge_display(
for key, value in submitted.items():
if isinstance(value, dict):
out[key] = copy.deepcopy(value)
- # Drop head from item_3d/gun defaults unless player supplied it.
- if kind in ("item_3d", "gun") and HEAD_TAB not in (submitted or {}):
+ # Drop head from held-item defaults unless player supplied it.
+ if kind in _HELD_KINDS and HEAD_TAB not in (submitted or {}):
out.pop(HEAD_TAB, None)
return out
diff --git a/backend/src/skins/naming.py b/backend/src/skins/naming.py
index d2b028f8..4bdd929f 100644
--- a/backend/src/skins/naming.py
+++ b/backend/src/skins/naming.py
@@ -8,6 +8,8 @@
import re
+from .base_sets import armor_single_sets
+
SLUG_RE = re.compile(r"^[a-z][a-z0-9_]{1,47}$")
RESERVED = frozenset(
@@ -39,14 +41,26 @@ def parse_armor_tier(tier: str) -> tuple[str, str] | None:
return None
+def is_armor_single_set(tier: str) -> bool:
+ """A set that is its own line, like ``leather`` or ``wild_mage`` (shared base_sets.json)."""
+ return (tier or "") in armor_single_sets()
+
+
def armor_tier_base_set(tier: str) -> str:
"""ArmourShop base set for a tier. Older bare tiers (``iron``, ``mage``) are their own set."""
parsed = parse_armor_tier(tier)
- return f"{parsed[0]} {parsed[1]}" if parsed else tier
+ if parsed:
+ return f"{parsed[0]} {parsed[1]}"
+ if is_armor_single_set(tier):
+ return tier.replace("_", " ")
+ return tier
def armor_tier_label(tier: str) -> str:
"""Default display suffix: ``Light Iron`` (matches shop YAML like "Osenic Iron")."""
+ single = armor_single_sets().get(tier or "")
+ if single:
+ return single
return " ".join(part.capitalize() for part in armor_tier_base_set(tier).split())
@@ -59,6 +73,11 @@ def armor_tier_label(tier: str) -> str:
BOW_PULL_FIELDS = ("pull_0", "pull_1", "pull_2")
BOW_FRAME_FIELDS = ("texture",) + BOW_PULL_FIELDS
CROSSBOW_FRAME_FIELDS = BOW_FRAME_FIELDS + ("charged",)
+# 3D bows: one texture, a model per bow state.
+BOW_3D_MODEL_FIELDS = ("model", "pull_0_model", "pull_1_model", "pull_2_model")
+CROSSBOW_3D_MODEL_FIELDS = BOW_3D_MODEL_FIELDS + ("charged_model",)
+# Optional animation for a PNG field: ``{field}_mcmeta``. Never on armour layers.
+MCMETA_SUFFIX = "_mcmeta"
BOOK_FIELDS = ("unsigned", "signed")
diff --git a/backend/src/skins/preview_3d.py b/backend/src/skins/preview_3d.py
index 155c7767..e660c747 100644
--- a/backend/src/skins/preview_3d.py
+++ b/backend/src/skins/preview_3d.py
@@ -14,6 +14,7 @@
import tempfile
from pathlib import Path
+from .animation import first_frame_png, mcmeta_path
from .storage import (
BOOK_KIND,
BOW_KINDS,
@@ -21,6 +22,7 @@
GUN_KIND,
ITEM_KINDS,
MODEL_3D_KINDS,
+ RANGED_3D_KINDS,
)
log = logging.getLogger("skins.preview_3d")
@@ -42,6 +44,8 @@
"body": "preview_body.png",
"book_unsigned": "preview_book_unsigned.png",
"book_signed": "preview_book_signed.png",
+ "pull_2": "preview_pull_2.png",
+ "charged": "preview_charged.png",
}
@@ -97,8 +101,9 @@ def _mtime(path: Path) -> float:
def _preview_inputs(files: dict[str, str]) -> list[Path]:
- """Model/texture paths plus the renderer bundle, so a rebuild refreshes tiles."""
+ """Model/texture paths (and their .mcmeta) plus the renderer bundle, so a rebuild refreshes tiles."""
inputs = [Path(p) for p in files.values()]
+ inputs += [mcmeta_path(p) for p in inputs if p.suffix.lower() == ".png"]
if isinstance(BUNDLE, Path):
inputs.append(BUNDLE)
return inputs
@@ -185,6 +190,24 @@ def _job_for_kind(kind: str, slug: str, out_dir: Path, tiers: list[str]) -> dict
views.append("hat")
return {"kind": kind, "views": views, "files": files}
+ if kind in RANGED_3D_KINDS:
+ if "texture" not in files:
+ return None
+ model = out_dir / f"{slug}.json"
+ if model.is_file():
+ files["model"] = str(model)
+ views = ["model"]
+ # Fully drawn, and loaded for crossbows: the states that differ most from standby.
+ extra = [("pull_2", "_2")]
+ if kind == "crossbow_3d":
+ extra.append(("charged", "_charged"))
+ for view, suffix in extra:
+ path = out_dir / f"{slug}{suffix}.json"
+ if path.is_file():
+ files[view] = str(path)
+ views.append(view)
+ return {"kind": kind, "views": views, "files": files}
+
if kind == GUN_KIND:
if "texture" not in files:
return None
@@ -199,6 +222,22 @@ def _job_for_kind(kind: str, slug: str, out_dir: Path, tiers: list[str]) -> dict
return None
+def _first_frame_files(files: dict[str, str], tmp_dir: Path) -> dict[str, str]:
+ """Swap animated textures for a crop of the frame Minecraft shows first."""
+ out = dict(files)
+ for key, raw in files.items():
+ path = Path(raw)
+ if path.suffix.lower() != ".png":
+ continue
+ frame = first_frame_png(path)
+ if frame is None:
+ continue
+ crop = tmp_dir / path.name
+ crop.write_bytes(frame)
+ out[key] = str(crop)
+ return out
+
+
def ensure_preview_tiles(
kind: str,
slug: str,
@@ -239,14 +278,15 @@ def ensure_preview_tiles(
log.warning("3D sheet renderer unavailable: backend/render/dist/browser.js missing")
return tiles, "3D renderer unavailable: run npm install in backend/render"
- payload = {
- "kind": job["kind"],
- "views": job["views"],
- "files": job["files"],
- "outDir": str(out_dir),
- }
render_error: str | None = None
try:
+ frame_dir = tempfile.TemporaryDirectory()
+ payload = {
+ "kind": job["kind"],
+ "views": job["views"],
+ "files": _first_frame_files(job["files"], Path(frame_dir.name)),
+ "outDir": str(out_dir),
+ }
with tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8"
) as tmp:
@@ -265,6 +305,7 @@ def ensure_preview_tiles(
os.unlink(tmp_path)
except OSError:
pass
+ frame_dir.cleanup()
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
render_error = (
diff --git a/backend/src/skins/review_sheet.py b/backend/src/skins/review_sheet.py
index c35765f8..ae04c8b9 100644
--- a/backend/src/skins/review_sheet.py
+++ b/backend/src/skins/review_sheet.py
@@ -13,6 +13,7 @@
from src import database
+from .animation import first_frame, frame_count
from .db import SKINS_DIR
from .name_preview import render_name_preview_png
from .naming import ARMOR_FIELDS
@@ -33,6 +34,7 @@
GUN_KIND,
ITEM_KINDS,
MODEL_3D_KINDS,
+ RANGED_3D_KINDS,
)
TILE_DISPLAY = 128
@@ -67,14 +69,21 @@ def _font(size: int = 14) -> ImageFont.ImageFont:
def _load_rgba(path: Path) -> Image.Image:
+ """The PNG, or for an animated texture the frame Minecraft shows first."""
try:
img = Image.open(path)
img.load()
- return img.convert("RGBA")
+ return first_frame(path, img.convert("RGBA"))
except OSError as e:
raise ReviewSheetError(f"Cannot read image: {path.name}") from e
+def _animated_note(path: Path) -> str:
+ """`` · animated · N frames`` for a PNG with an .mcmeta, else empty."""
+ frames = frame_count(path)
+ return f" · animated · {frames} frames" if frames > 1 else ""
+
+
def _scale_nn(img: Image.Image, max_side: int) -> Image.Image:
w, h = img.size
if w <= 0 or h <= 0:
@@ -208,6 +217,7 @@ def _armor_tier_strip(tier: str, out_dir: Path) -> Image.Image:
label = "helmet_3d" if field == "helmet" and path.name.endswith(
"_helmet_texture.png"
) else field
+ label += _animated_note(path)
bbox = draw.textbbox((0, 0), label, font=font)
tw = bbox[2] - bbox[0]
draw.text(
@@ -265,7 +275,7 @@ def _single_texture_body(
raise ReviewSheetError(f"Missing file: {path.name}")
display = ITEM_DISPLAY
- caption = f"kind={kind}"
+ caption = f"kind={kind}{_animated_note(path)}"
if base_set:
caption += f" base={base_set}"
if grip_preset:
@@ -339,6 +349,7 @@ def _bow_body(
_paste_centered(
canvas, tile, (x0, y0, x0 + BOW_DISPLAY, y0 + BOW_DISPLAY)
)
+ label += _animated_note(path)
bbox = draw.textbbox((0, 0), label, font=font)
tw = bbox[2] - bbox[0]
draw.text(
@@ -400,6 +411,7 @@ def _book_body(
_paste_centered(
canvas, tile, (x0, y0, x0 + BOW_DISPLAY, y0 + BOW_DISPLAY)
)
+ label += _animated_note(path)
bbox = draw.textbbox((0, 0), label, font=font)
tw = bbox[2] - bbox[0]
draw.text(
@@ -436,6 +448,8 @@ def _book_body(
"body": "full body",
"book_unsigned": "3D unsigned",
"book_signed": "3D signed",
+ "pull_2": "3D pull 2",
+ "charged": "3D charged",
}
@@ -499,7 +513,12 @@ def _compose_full_sheet(row, out_dir: Path) -> Image.Image:
if base_set:
caption_bits.append(f"base={base_set}")
body = _book_body(slug, base_set, out_dir)
- elif kind in ITEM_KINDS or kind in MODEL_3D_KINDS or kind == GUN_KIND:
+ elif (
+ kind in ITEM_KINDS
+ or kind in MODEL_3D_KINDS
+ or kind in RANGED_3D_KINDS
+ or kind == GUN_KIND
+ ):
if base_set:
caption_bits.append(f"base={base_set}")
if grip:
diff --git a/backend/src/skins/size_limits.py b/backend/src/skins/size_limits.py
index a16b5c87..93e6e467 100644
--- a/backend/src/skins/size_limits.py
+++ b/backend/src/skins/size_limits.py
@@ -1,7 +1,12 @@
-"""Combined texture+model byte budgets for 3D skin kinds."""
+"""Combined texture+model byte budgets for 3D skin kinds.
+
+A texture's optional ``.mcmeta`` counts toward its pair.
+"""
from __future__ import annotations
+from .base_sets import ranged_3d_total_bytes
+
class SizeLimitError(ValueError):
"""Texture + model pair exceeds configured budget."""
@@ -17,8 +22,9 @@ def assert_pair(
max_bytes: int,
*,
label: str,
+ mcmeta: bytes | None = None,
) -> None:
- total = _pair_len(texture, model)
+ total = _pair_len(texture, model) + len(mcmeta or b"")
cap = max(0, int(max_bytes))
if total > cap:
raise SizeLimitError(
@@ -38,12 +44,23 @@ def assert_3d_pair_budgets(
k = (kind or "").strip()
cap = max(0, int(max_bytes))
+ if k in ("bow_3d", "crossbow_3d"):
+ # One fixed budget for every rank, across all of the upload's files.
+ limit = ranged_3d_total_bytes()
+ total = sum(len(data or b"") for data in files_bytes.values())
+ if total > limit:
+ raise SizeLimitError(
+ f"{k}: texture + models is {total} bytes (limit {limit} bytes)"
+ )
+ return
+
if k in ("item_3d", "shield", "helmet_3d", "mask"):
assert_pair(
files_bytes.get("texture"),
files_bytes.get("model"),
cap,
label=k,
+ mcmeta=files_bytes.get("texture_mcmeta"),
)
return
@@ -55,6 +72,7 @@ def assert_3d_pair_budgets(
files_bytes.get(stem),
cap,
label=f"gun/{stem}",
+ mcmeta=files_bytes.get("texture_mcmeta"),
)
return
@@ -68,4 +86,5 @@ def assert_3d_pair_budgets(
files_bytes.get(f"{t}_helmet_model"),
cap,
label=f"armor_set/{t}_helmet",
+ mcmeta=files_bytes.get(f"{t}_helmet_texture_mcmeta"),
)
diff --git a/backend/src/skins/storage.py b/backend/src/skins/storage.py
index a3d21f87..457a6e5d 100644
--- a/backend/src/skins/storage.py
+++ b/backend/src/skins/storage.py
@@ -8,13 +8,17 @@
from datetime import datetime, timezone
from pathlib import Path
+from .animation import AnimationError, mcmeta_bytes, parse_mcmeta
from .db import SKINS_DIR
from .display import DisplayError, parse_and_merge_model
from .naming import (
ARMOR_LAYER_FIELDS,
BOOK_FIELDS,
+ BOW_3D_MODEL_FIELDS,
BOW_FRAME_FIELDS,
+ CROSSBOW_3D_MODEL_FIELDS,
CROSSBOW_FRAME_FIELDS,
+ MCMETA_SUFFIX,
)
from .pack_models.gun import build_gun_models
from .pack_models.large_bow import bow_file_suffix, build_large_bow_models
@@ -42,6 +46,16 @@
BOW_KINDS = frozenset({"bow", "large_bow"})
CROSSBOW_KINDS = frozenset({"crossbow"})
+# One texture and a model per bow state; see naming.BOW_3D_MODEL_FIELDS.
+RANGED_3D_KINDS = frozenset({"bow_3d", "crossbow_3d"})
+# Model field → file suffix after the slug ({slug}.json, {slug}_0.json, …).
+RANGED_3D_MODEL_SUFFIX = {
+ "model": "",
+ "pull_0_model": "_0",
+ "pull_1_model": "_1",
+ "pull_2_model": "_2",
+ "charged_model": "_charged",
+}
ITEM_KINDS = frozenset({"handheld", "large_handheld", "item"})
MODEL_3D_KINDS = frozenset({"item_3d", "shield", "helmet_3d", "mask"})
GUN_KIND = "gun"
@@ -90,6 +104,58 @@ def validate_model_json_size(data: bytes) -> None:
)
+def ranged_3d_model_fields(kind: str) -> tuple[str, ...]:
+ return CROSSBOW_3D_MODEL_FIELDS if kind == "crossbow_3d" else BOW_3D_MODEL_FIELDS
+
+
+def check_mcmeta_fields(files: dict[str, bytes]) -> None:
+ """Every ``{field}_mcmeta`` needs an uploaded PNG ``field`` that is not an armour layer."""
+ for key in files:
+ if not key.endswith(MCMETA_SUFFIX):
+ continue
+ field = key[: -len(MCMETA_SUFFIX)]
+ if any(field.endswith(f"_{layer}") or field == layer for layer in ARMOR_LAYER_FIELDS):
+ raise StorageError(
+ f"{key}: armour layer textures cannot be animated"
+ )
+ data = files.get(field)
+ if not data or not data.startswith(PNG_MAGIC):
+ raise StorageError(f"{key}: no PNG '{field}' to animate")
+
+
+def _write_png(
+ out_dir: Path,
+ files: dict[str, bytes],
+ field: str,
+ names: tuple[str, ...],
+ expected: tuple[int, int] | None = None,
+) -> None:
+ """Validate ``field`` and write it as each of ``names``, with ``{name}.mcmeta`` if animated.
+
+ An animated PNG with a fixed ``expected`` size must be a grid of frames that size.
+ """
+ if field not in files:
+ raise StorageError(f"Missing file field: {field}")
+ data = files[field]
+ meta = files.get(field + MCMETA_SUFFIX)
+ clean: bytes | None = None
+ if meta is None:
+ validate_png(data, expected)
+ else:
+ validate_png(data)
+ try:
+ parsed, _ = parse_mcmeta(
+ meta, png_dimensions(data), expected, label=field
+ )
+ except AnimationError as e:
+ raise StorageError(str(e)) from e
+ clean = mcmeta_bytes(parsed)
+ for name in names:
+ (out_dir / name).write_bytes(data)
+ if clean is not None:
+ (out_dir / f"{name}.mcmeta").write_bytes(clean)
+
+
def _iso_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -111,19 +177,16 @@ def _write_bow_frames(
) -> None:
size = _expected_bow_size(kind)
for field in fields:
- if field not in files:
- raise StorageError(f"Missing file field: {field}")
- validate_png(files[field], size)
if field == "texture":
- (out_dir / f"{slug}.png").write_bytes(files[field])
+ names: tuple[str, ...] = (f"{slug}.png",)
elif field.startswith("pull_"):
n = field.split("_", 1)[1]
- (out_dir / f"{slug}_{n}.png").write_bytes(files[field])
+ names = (f"{slug}_{n}.png",)
elif field == "charged":
- (out_dir / f"{slug}_charged.png").write_bytes(files[field])
- (out_dir / f"{slug}_arrow.png").write_bytes(files[field])
+ names = (f"{slug}_charged.png", f"{slug}_arrow.png")
else:
raise StorageError(f"Unknown bow field: {field}")
+ _write_png(out_dir, files, field, names, size)
def _write_model_3d(
@@ -133,13 +196,12 @@ def _write_model_3d(
raise StorageError("Missing file field: texture")
if "model" not in files:
raise StorageError("Missing file field: model")
- validate_png(files["texture"])
validate_model_json_size(files["model"])
try:
model = parse_and_merge_model(files["model"], kind)
except DisplayError as e:
raise StorageError(str(e)) from e
- (out_dir / f"{slug}.png").write_bytes(files["texture"])
+ _write_png(out_dir, files, "texture", (f"{slug}.png",))
if kind == "shield":
idle, blocking = build_shield_models(model, slug)
(out_dir / f"{slug}.json").write_bytes(pack_model_to_bytes(idle))
@@ -151,11 +213,28 @@ def _write_model_3d(
(out_dir / f"{slug}.json").write_bytes(pack_model_to_bytes(normalized))
+def _write_ranged_3d(
+ out_dir: Path, slug: str, kind: str, files: dict[str, bytes]
+) -> None:
+ """3D bow / crossbow: {slug}.png plus {slug}.json, _0, _1, _2 (and _charged)."""
+ built: dict[str, bytes] = {}
+ for field in ranged_3d_model_fields(kind):
+ if field not in files:
+ raise StorageError(f"Missing file field: {field}")
+ validate_model_json_size(files[field])
+ try:
+ model = parse_and_merge_model(files[field], kind)
+ except DisplayError as e:
+ raise StorageError(f"{field}: {e}") from e
+ name = f"{slug}{RANGED_3D_MODEL_SUFFIX[field]}.json"
+ built[name] = pack_model_to_bytes(normalize_textures(model, slug))
+ _write_png(out_dir, files, "texture", (f"{slug}.png",))
+ for name, data in built.items():
+ (out_dir / name).write_bytes(data)
+
+
def _write_gun(out_dir: Path, slug: str, files: dict[str, bytes]) -> None:
- if "texture" not in files:
- raise StorageError("Missing file field: texture")
- validate_png(files["texture"])
- (out_dir / f"{slug}.png").write_bytes(files["texture"])
+ _write_png(out_dir, files, "texture", (f"{slug}.png",))
merged: dict[str, dict] = {}
for field, stem in zip(GUN_MODEL_FIELDS, GUN_MODEL_STEMS, strict=True):
if field not in files:
@@ -173,14 +252,11 @@ def _write_gun(out_dir: Path, slug: str, files: dict[str, bytes]) -> None:
def _write_large_handheld(
out_dir: Path, slug: str, files: dict[str, bytes], grip_preset: str | None
) -> None:
- if "texture" not in files:
- raise StorageError("Missing file field: texture")
- validate_png(files["texture"], LARGE_HANDHELD_SIZE)
try:
grip = parse_grip_preset(grip_preset)
except ValueError as e:
raise StorageError(str(e)) from e
- (out_dir / f"{slug}.png").write_bytes(files["texture"])
+ _write_png(out_dir, files, "texture", (f"{slug}.png",), LARGE_HANDHELD_SIZE)
(out_dir / f"{slug}.json").write_bytes(
pack_model_to_bytes(build_large_handheld_model(slug, grip))
)
@@ -209,10 +285,7 @@ def _write_armor(
for field in ("chestplate", "leggings", "boots"):
key = f"{tier}_{field}"
- if key not in files:
- raise StorageError(f"Missing file field: {key}")
- validate_png(files[key], ICON_SIZE)
- (out_dir / f"{tier}_{field}.png").write_bytes(files[key])
+ _write_png(out_dir, files, key, (f"{key}.png",), ICON_SIZE)
if tier in helmet_3d:
model_key = f"{tier}_helmet_model"
@@ -221,7 +294,6 @@ def _write_armor(
raise StorageError(f"Missing file field: {model_key}")
if tex_key not in files:
raise StorageError(f"Missing file field: {tex_key}")
- validate_png(files[tex_key])
validate_model_json_size(files[model_key])
try:
model = parse_and_merge_model(
@@ -229,9 +301,7 @@ def _write_armor(
)
except DisplayError as e:
raise StorageError(str(e)) from e
- (out_dir / f"{tier}_helmet_texture.png").write_bytes(
- files[tex_key]
- )
+ _write_png(out_dir, files, tex_key, (f"{tex_key}.png",))
# Pack slug at apply is {submission_id}_{tier}; texture id matches.
texture_id = f"{out_dir.name}_{tier}_helmet"
normalized = normalize_textures(model, texture_id)
@@ -240,10 +310,7 @@ def _write_armor(
)
else:
key = f"{tier}_helmet"
- if key not in files:
- raise StorageError(f"Missing file field: {key}")
- validate_png(files[key], ICON_SIZE)
- (out_dir / f"{tier}_helmet.png").write_bytes(files[key])
+ _write_png(out_dir, files, key, (f"{key}.png",), ICON_SIZE)
def write_submission_files(
@@ -271,6 +338,7 @@ def write_submission_files(
h3d = list(helmet_3d_tiers or [])
try:
+ check_mcmeta_fields(files)
if kind == "armor_set":
tier_list = list(tiers or [])
if not tier_list:
@@ -279,10 +347,7 @@ def write_submission_files(
elif kind == "large_handheld":
_write_large_handheld(out_dir, slug, files, grip_preset)
elif kind == "handheld":
- if "texture" not in files:
- raise StorageError("Missing file field: texture")
- validate_png(files["texture"], ITEM_SIZE)
- (out_dir / f"{slug}.png").write_bytes(files["texture"])
+ _write_png(out_dir, files, "texture", (f"{slug}.png",), ITEM_SIZE)
elif kind == "large_bow":
_write_bow_frames(out_dir, slug, kind, files, BOW_FRAME_FIELDS)
_write_large_bow_models(out_dir, slug)
@@ -294,14 +359,15 @@ def write_submission_files(
)
elif kind in MODEL_3D_KINDS:
_write_model_3d(out_dir, slug, kind, files)
+ elif kind in RANGED_3D_KINDS:
+ _write_ranged_3d(out_dir, slug, kind, files)
elif kind == GUN_KIND:
_write_gun(out_dir, slug, files)
elif kind == BOOK_KIND:
for field in BOOK_FIELDS:
- if field not in files:
- raise StorageError(f"Missing file field: {field}")
- validate_png(files[field], ITEM_SIZE)
- (out_dir / f"{slug}_{field}.png").write_bytes(files[field])
+ _write_png(
+ out_dir, files, field, (f"{slug}_{field}.png",), ITEM_SIZE
+ )
else:
raise StorageError(f"Unsupported kind: {kind}")
diff --git a/backend/src/skins/submissions.py b/backend/src/skins/submissions.py
index f4f8a034..0bf41bca 100644
--- a/backend/src/skins/submissions.py
+++ b/backend/src/skins/submissions.py
@@ -11,6 +11,7 @@
from .db import SKINS_DIR
from .codes import CodeError, SCOPE_LORE_UPLOAD, api_time, lock_code_for_submission, mark_code_consumed
+from .base_sets import base_sets_by_kind
from .discord_link import get_link_for_uuid
from .naming import (
ARMOR_METALS,
@@ -23,6 +24,7 @@
SlugError,
armor_tier_base_set,
armor_tier_label,
+ is_armor_single_set,
build_staff_submission_id,
build_submission_id,
build_submission_id_for_realm,
@@ -37,7 +39,9 @@
CROSSBOW_KINDS,
GUN_KIND,
GUN_MODEL_FIELDS,
+ RANGED_3D_KINDS,
StorageError,
+ ranged_3d_model_fields,
write_submission_files,
)
from src.name_colours import NameColourError, validate_name_colours
@@ -50,23 +54,6 @@
ALLOWED_STYLES = frozenset(
{"bold", "italic", "underline", "underlined", "strikethrough", "strike"}
)
-_HANDHELD_BASES = frozenset(
- {
- "swords",
- "lutes",
- "battleaxes",
- "daggers",
- "warhammers",
- "shortswords",
- "hatchets",
- "hoes",
- "knives",
- }
-)
-_LARGE_HANDHELD_BASES = frozenset(
- {"spears", "polearms", "greathammers", "staffs"}
-)
-
ALLOWED_KINDS = frozenset(
{
"armor_set",
@@ -75,6 +62,8 @@
"bow",
"large_bow",
"crossbow",
+ "bow_3d",
+ "crossbow_3d",
"item_3d",
"shield",
"helmet_3d",
@@ -84,19 +73,8 @@
}
)
# Armour has no base_set: its sets come from tiers (see _validate_tiers).
-BASE_SETS: dict[str, frozenset[str]] = {
- "handheld": _HANDHELD_BASES,
- "large_handheld": _LARGE_HANDHELD_BASES,
- "bow": frozenset({"shortbows"}),
- "large_bow": frozenset({"longbows"}),
- "crossbow": frozenset({"crossbows"}),
- "item_3d": _HANDHELD_BASES | _LARGE_HANDHELD_BASES,
- "shield": frozenset({"shields"}),
- "helmet_3d": frozenset({"helmets"}),
- "mask": frozenset({"masks"}),
- "gun": frozenset({"rifles", "pistols", "shotguns", "launchers"}),
- "book": frozenset({"books"}),
-}
+# Everything else comes from shared/skins/base_sets.json.
+BASE_SETS: dict[str, frozenset[str]] = base_sets_by_kind()
MODEL_3D_KINDS = frozenset({"item_3d", "shield", "helmet_3d", "mask"})
GUN_FIELDS = ("texture",) + GUN_MODEL_FIELDS
GRIP_Y_MIN = 0.0
@@ -282,9 +260,17 @@ def _validate_base_set(kind: str, base_set: str | None) -> str:
def _validate_tiers(raw: list[str] | None) -> list[str]:
- """One metal line: ``{type}_{metal}`` tiers, at most one per type, one metal."""
+ """One metal line (``{type}_{metal}`` tiers, at most one per type) or one single set."""
if not raw:
raise SubmissionError(f"armor_set requires 1–{MAX_ARMOR_SETS} sets")
+ singles = [t for t in raw if is_armor_single_set((t or "").strip().lower())]
+ if singles:
+ if len(raw) != 1:
+ raise SubmissionError(
+ f"'{singles[0].strip().lower()}' is a set of its own; "
+ "upload it without other armour sets"
+ )
+ return [singles[0].strip().lower()]
if len(raw) > MAX_ARMOR_SETS:
raise SubmissionError(f"at most {MAX_ARMOR_SETS} armor sets")
out: list[str] = []
@@ -593,8 +579,8 @@ def create_submission(
raise SubmissionError("kind 'item' is disabled")
if kind not in ALLOWED_KINDS:
raise SubmissionError(
- "kind must be armor_set, handheld, large_handheld, "
- "bow, large_bow, crossbow, item_3d, shield, helmet_3d, mask, gun, or book"
+ "kind must be armor_set, handheld, large_handheld, bow, large_bow, "
+ "crossbow, bow_3d, crossbow_3d, item_3d, shield, helmet_3d, mask, gun, or book"
)
try:
@@ -717,6 +703,14 @@ def create_submission(
raise SubmissionError("Missing file: texture")
if "model" not in files_bytes:
raise SubmissionError("Missing file: model")
+ elif kind in RANGED_3D_KINDS:
+ missing = [
+ f
+ for f in ("texture",) + ranged_3d_model_fields(kind)
+ if f not in files_bytes
+ ]
+ if missing:
+ raise SubmissionError(f"Missing files: {', '.join(missing)}")
elif kind == GUN_KIND:
missing = [f for f in GUN_FIELDS if f not in files_bytes]
if missing:
@@ -1090,7 +1084,7 @@ def _get_row(submission_id: str) -> dict | None:
def _list_asset_files(submission_id: str) -> list[str]:
- """PNG + model JSON for plugin download (excludes meta.json)."""
+ """PNG, .png.mcmeta and model JSON for plugin download (excludes meta.json)."""
out_dir = SKINS_DIR / submission_id
if not out_dir.is_dir():
return []
@@ -1100,6 +1094,7 @@ def _list_asset_files(submission_id: str) -> list[str]:
if p.is_file()
and (
p.suffix.lower() == ".png"
+ or p.name.lower().endswith(".png.mcmeta")
or (
p.suffix.lower() == ".json"
and p.name.lower() != "meta.json"
diff --git a/backend/src/skins/test_ban_events.py b/backend/src/skins/test_ban_events.py
index 6e71af3c..7facdeb4 100644
--- a/backend/src/skins/test_ban_events.py
+++ b/backend/src/skins/test_ban_events.py
@@ -168,7 +168,9 @@ def test_an_older_ban_running_out_does_not_lift_a_later_one():
def test_times_round_trip_exactly_and_out_of_range_times_are_rejected():
- for ms in (1792352364123, 1792352364999, 1792438773975):
+ # Fixed millisecond parts near now: fixed past dates expire and drop out of the sync.
+ second = _now_ms() // 1000 * 1000
+ for ms in (second + 123, second + 999, second + 86_400_975):
enqueue_ban_event("ban", player_uuid=UUID, banned_at_ms=ms, expires_at_ms=ms + DAY)
assert list_ban_sync()["bans"][0]["banned_at_ms"] == ms
_unban()
diff --git a/backend/src/skins/test_ranged_3d_and_animation.py b/backend/src/skins/test_ranged_3d_and_animation.py
new file mode 100644
index 00000000..8d85b18a
--- /dev/null
+++ b/backend/src/skins/test_ranged_3d_and_animation.py
@@ -0,0 +1,409 @@
+"""3D bows and crossbows, animated (.mcmeta) textures, shared base sets, single armour sets."""
+
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from src.skins import storage
+from src.skins.animation import AnimationError, first_frame, frame_count, parse_mcmeta
+from src.skins.base_sets import base_sets_by_kind, load_base_sets
+from src.skins.size_limits import SizeLimitError, assert_3d_pair_budgets
+from src.skins.storage import StorageError, write_submission_files
+
+MODEL = {
+ "elements": [
+ {
+ "from": [7, 0, 7],
+ "to": [9, 16, 9],
+ "faces": {"north": {"uv": [0, 0, 2, 16], "texture": "#0"}},
+ }
+ ],
+ "textures": {"0": "my_bow"},
+}
+
+
+def _png(size: tuple[int, int], colours: list[tuple[int, int, int, int]] | None = None) -> bytes:
+ """A PNG of 16-tall frames stacked downwards, each frame its own colour."""
+ img = Image.new("RGBA", size, (0, 0, 0, 0))
+ for i, colour in enumerate(colours or []):
+ img.paste(colour, (0, i * size[0], size[0], (i + 1) * size[0]))
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _model(**extra) -> bytes:
+ return json.dumps({**MODEL, **extra}).encode()
+
+
+def _meta(**animation) -> bytes:
+ return json.dumps({"animation": animation}).encode()
+
+
+@pytest.fixture()
+def skins_dir(tmp_path, monkeypatch) -> Path:
+ monkeypatch.setattr(storage, "SKINS_DIR", tmp_path)
+ return tmp_path
+
+
+def _write(kind: str, files: dict[str, bytes], **kwargs) -> Path:
+ return write_submission_files("sub1", "p_bow", kind, "Bow", files, **kwargs)
+
+
+# --- base sets ----------------------------------------------------------------
+
+
+def test_base_sets_come_from_shared_json():
+ sets = base_sets_by_kind()
+ for wanted in ("wands", "mage blades", "pickaxes", "shovels", "fishing rods", "instruments"):
+ assert wanted in sets["handheld"]
+ assert wanted in sets["item_3d"]
+ for wanted in ("longswords", "greataxes"):
+ assert wanted in sets["large_handheld"]
+ assert wanted in sets["item_3d"]
+ assert "battle standards" in sets["item_3d"]
+ assert "battle standards" not in sets["handheld"]
+ assert sets["bow_3d"] == {"shortbows", "longbows"}
+ assert sets["crossbow_3d"] == {"crossbows"}
+ assert "armor_set" not in sets
+
+
+def test_every_base_set_kind_is_an_upload_kind():
+ from src.skins.submissions import ALLOWED_KINDS, BASE_SETS
+
+ data = load_base_sets()
+ groups = {g["id"] for g in data["groups"]}
+ for row in data["base_sets"]:
+ assert row["group"] in groups
+ assert set(row["kinds"]) <= ALLOWED_KINDS
+ assert BASE_SETS == base_sets_by_kind()
+
+
+def test_spaced_base_set_is_kept_verbatim():
+ from src.skins.submissions import _validate_base_set
+
+ assert _validate_base_set("handheld", "mage blades") == "mage blades"
+ assert _validate_base_set("bow_3d", "longbows") == "longbows"
+
+
+# --- single armour sets -------------------------------------------------------
+
+
+def test_single_armour_sets():
+ from src.skins.naming import armor_tier_base_set, armor_tier_label
+ from src.skins.submissions import SubmissionError, _validate_tiers
+
+ assert _validate_tiers(["wild_mage"]) == ["wild_mage"]
+ assert armor_tier_base_set("wild_mage") == "wild mage"
+ assert armor_tier_base_set("leather") == "leather"
+ assert armor_tier_label("light_mage") == "Light Mage"
+ assert armor_tier_label("bloodmagic") == "Bloodmagic"
+ with pytest.raises(SubmissionError, match="set of its own"):
+ _validate_tiers(["leather", "light_iron"])
+ with pytest.raises(SubmissionError, match="set of its own"):
+ _validate_tiers(["illusion", "arcanum"])
+
+
+# --- size budget --------------------------------------------------------------
+
+
+def test_ranged_3d_budget_counts_every_file_for_every_rank():
+ files = {"texture": b"t" * 20000, "model": b"m" * 20000, "pull_0_model": b"m" * 20000}
+ # Far above the per-rank pair cap, still inside the fixed 60 KiB.
+ assert_3d_pair_budgets("bow_3d", files, 0)
+ files["texture_mcmeta"] = b"x" * 2000
+ with pytest.raises(SizeLimitError, match=r"bow_3d: texture \+ models is 62000 bytes \(limit 61440 bytes\)"):
+ assert_3d_pair_budgets("bow_3d", files, 10**9)
+
+
+def test_texture_mcmeta_counts_toward_item_3d_pair():
+ with pytest.raises(SizeLimitError):
+ assert_3d_pair_budgets(
+ "item_3d",
+ {"texture": b"t" * 100, "model": b"m" * 100, "texture_mcmeta": b"x" * 100},
+ 250,
+ )
+
+
+# --- 3D bows ------------------------------------------------------------------
+
+
+def test_bow_3d_writes_texture_and_four_models(skins_dir):
+ files = {"texture": _png((16, 16))}
+ for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model"):
+ files[field] = _model()
+ out = _write("bow_3d", files, base_set="longbows")
+ names = sorted(p.name for p in out.iterdir())
+ assert names == ["meta.json", "p_bow.json", "p_bow.png", "p_bow_0.json", "p_bow_1.json", "p_bow_2.json"]
+ model = json.loads((out / "p_bow_2.json").read_text())
+ assert model["textures"]["0"].endswith(":item/p_bow")
+ # Vanilla bow hand pose fills a missing display tab.
+ assert model["display"]["thirdperson_righthand"]["rotation"] == [-80, 260, -40]
+ assert "head" not in model["display"]
+
+
+def test_crossbow_3d_needs_charged_model(skins_dir):
+ files = {"texture": _png((16, 16))}
+ for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model"):
+ files[field] = _model()
+ with pytest.raises(StorageError, match="charged_model"):
+ _write("crossbow_3d", files)
+ files["charged_model"] = _model()
+ out = _write("crossbow_3d", files)
+ assert (out / "p_bow_charged.json").is_file()
+ charged = json.loads((out / "p_bow_charged.json").read_text())
+ assert charged["display"]["thirdperson_righthand"]["rotation"] == [-90, 0, -60]
+
+
+def test_bow_3d_reports_which_model_is_bad(skins_dir):
+ files = {"texture": _png((16, 16)), "model": _model(), "pull_0_model": _model(),
+ "pull_1_model": b'{"elements": []}', "pull_2_model": _model()}
+ with pytest.raises(StorageError, match="pull_1_model"):
+ _write("bow_3d", files)
+
+
+# --- animation ----------------------------------------------------------------
+
+
+RED, GREEN, BLUE = (255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255)
+
+
+def test_animated_handheld_is_a_strip_of_16px_frames(skins_dir):
+ meta = json.dumps({"animation": {"frametime": 4, "frames": [1, {"index": 0, "time": 8}]}}, indent=4).encode()
+ out = _write("handheld", {"texture": _png((16, 48), [RED, GREEN, BLUE]), "texture_mcmeta": meta})
+ stored = out / "p_bow.png.mcmeta"
+ assert json.loads(stored.read_text()) == {
+ "animation": {"frametime": 4, "frames": [1, {"index": 0, "time": 8}]}
+ }
+ assert b"\n" not in stored.read_bytes()
+ png = out / "p_bow.png"
+ assert frame_count(png) == 3
+ with Image.open(png) as im:
+ frame = first_frame(png, im.convert("RGBA"))
+ assert frame.size == (16, 16)
+ assert frame.getpixel((0, 0)) == GREEN
+
+
+def test_still_texture_keeps_its_exact_size(skins_dir):
+ with pytest.raises(StorageError, match="16x16"):
+ _write("handheld", {"texture": _png((16, 48))})
+
+
+def test_animated_frames_must_match_the_kind_size(skins_dir):
+ with pytest.raises(StorageError, match="frames must be 16x16, got 32x32"):
+ _write("handheld", {"texture": _png((32, 64)), "texture_mcmeta": _meta()})
+
+
+def test_flat_crossbow_charged_animation_follows_its_arrow_copy(skins_dir):
+ files = {
+ "texture": _png((16, 16), [RED]),
+ "pull_0": _png((16, 16), [GREEN]),
+ "pull_1": _png((16, 16), [BLUE]),
+ "pull_2": _png((16, 16), [(1, 2, 3, 255)]),
+ "charged": _png((16, 32), [RED, BLUE]),
+ "charged_mcmeta": _meta(frametime=2),
+ }
+ out = _write("crossbow", files)
+ assert (out / "p_bow_charged.png.mcmeta").is_file()
+ assert (out / "p_bow_arrow.png.mcmeta").is_file()
+ assert not (out / "p_bow.png.mcmeta").exists()
+
+
+def test_armour_layers_cannot_be_animated(skins_dir):
+ with pytest.raises(StorageError, match="armour layer textures cannot be animated"):
+ _write(
+ "armor_set",
+ {"light_iron_layer_1": _png((64, 32)), "light_iron_layer_1_mcmeta": _meta()},
+ tiers=["light_iron"],
+ )
+
+
+def test_mcmeta_needs_its_png(skins_dir):
+ with pytest.raises(StorageError, match="no PNG 'model'"):
+ _write("item_3d", {"texture": _png((16, 16)), "model": _model(), "model_mcmeta": _meta()})
+
+
+def test_failed_upload_leaves_no_files(skins_dir):
+ with pytest.raises(StorageError):
+ _write("handheld", {"texture": _png((16, 32)), "texture_mcmeta": b"{"})
+ assert not (skins_dir / "sub1").exists()
+
+
+@pytest.mark.parametrize(
+ ("meta", "image", "message"),
+ [
+ (b"[]", (16, 32), 'only an "animation" section'),
+ (b'{"animation": {}, "villager": {}}', (16, 32), 'only an "animation" section'),
+ (_meta(frametime=0), (16, 32), "frametime"),
+ (_meta(frametime=True), (16, 32), "frametime"),
+ (_meta(interpolate="yes"), (16, 32), "interpolate"),
+ (_meta(blur=True), (16, 32), "unsupported keys: blur"),
+ (_meta(frames=[]), (16, 32), "non-empty list"),
+ (_meta(frames=[2]), (16, 32), "frame 2 does not exist"),
+ (_meta(frames=[{"time": 2}]), (16, 32), "need an index"),
+ (_meta(frames=[{"index": 0, "time": 0}]), (16, 32), "time must be"),
+ (_meta(width=10), (16, 32), "whole grid"),
+ (b"\xff", (16, 32), "not valid JSON"),
+ (b" " * 3000, (16, 32), "limit 2048 bytes"),
+ ],
+)
+def test_bad_mcmeta(meta, image, message):
+ with pytest.raises(AnimationError, match=message):
+ parse_mcmeta(meta, image)
+
+
+def test_mcmeta_frame_size_rules():
+ # Square frames of the shorter side by default; width/height override.
+ assert parse_mcmeta(_meta(), (16, 64))[1] == 4
+ assert parse_mcmeta(_meta(width=8), (16, 64))[1] == 2
+ assert parse_mcmeta(_meta(height=8), (16, 64))[1] == 8
+ assert parse_mcmeta(_meta(width=8, height=16), (16, 64))[1] == 8
+ assert parse_mcmeta(_meta(width=16, height=16, frames=[3]), (32, 32))[1] == 4
+
+
+def test_first_frame_of_a_grid(tmp_path):
+ img = Image.new("RGBA", (32, 32))
+ img.paste(RED, (0, 0, 16, 16))
+ img.paste(GREEN, (16, 0, 32, 16))
+ img.paste(BLUE, (0, 16, 16, 32))
+ png = tmp_path / "grid.png"
+ img.save(png)
+ (tmp_path / "grid.png.mcmeta").write_bytes(_meta(width=16, height=16, frames=[2, 0]))
+ assert frame_count(png) == 4
+ assert first_frame(png, img).getpixel((0, 0)) == BLUE
+
+
+def test_plugin_file_list_includes_mcmeta(skins_dir, monkeypatch):
+ from src.skins import submissions
+
+ monkeypatch.setattr(submissions, "SKINS_DIR", skins_dir)
+ _write("handheld", {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta()})
+ assert submissions._list_asset_files("sub1") == ["p_bow.png", "p_bow.png.mcmeta"]
+
+
+# --- static renders -----------------------------------------------------------
+
+
+def test_bow_3d_preview_job_shows_standby_and_drawn(tmp_path):
+ from src.skins.preview_3d import _job_for_kind
+
+ (tmp_path / "b.png").write_bytes(_png((16, 16)))
+ for suffix in ("", "_0", "_1", "_2", "_charged"):
+ (tmp_path / f"b{suffix}.json").write_text("{}")
+ assert _job_for_kind("bow_3d", "b", tmp_path, [])["views"] == ["model", "pull_2"]
+ job = _job_for_kind("crossbow_3d", "b", tmp_path, [])
+ assert job["views"] == ["model", "pull_2", "charged"]
+ assert job["files"]["charged"].endswith("b_charged.json")
+
+
+def test_renderer_gets_first_frame_crops(tmp_path):
+ from src.skins.preview_3d import _first_frame_files
+
+ png = tmp_path / "b.png"
+ png.write_bytes(_png((16, 32), [RED, GREEN]))
+ (tmp_path / "b.png.mcmeta").write_bytes(_meta(frames=[1]))
+ still = tmp_path / "still.png"
+ still.write_bytes(_png((16, 16)))
+ crops = tmp_path / "crops"
+ crops.mkdir()
+ out = _first_frame_files({"texture": str(png), "other": str(still), "model": "x.json"}, crops)
+ assert out["other"] == str(still) and out["model"] == "x.json"
+ with Image.open(out["texture"]) as im:
+ assert im.size == (16, 16)
+ assert im.convert("RGBA").getpixel((0, 0)) == GREEN
+
+
+def test_thumbnail_uses_first_frame(skins_dir, monkeypatch):
+ from src.skins import thumbnail
+
+ monkeypatch.setattr(thumbnail, "SKINS_DIR", skins_dir)
+ _write("handheld", {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta()})
+ data = thumbnail.skin_thumbnail("sub1", "p_bow")
+ with Image.open(io.BytesIO(data)) as im:
+ assert im.size == (16, 16)
+
+
+# --- through the real upload and plugin API -----------------------------------
+
+from src.skins.test_batch_and_wire import PLUGIN, api # noqa: E402,F401 - shared fixture
+from src.skins.test_locking import ENTITLEMENTS, env, session_for # noqa: E402,F401
+
+
+def _real_files(monkeypatch, kinds):
+ """The shared env fixture skips writing files; these tests need them on disk."""
+ from src.skins import db, submissions
+
+ # One folder for writing, listing and the download route's path check.
+ monkeypatch.setattr(storage, "SKINS_DIR", db.SKINS_DIR)
+ monkeypatch.setattr(submissions, "SKINS_DIR", db.SKINS_DIR)
+ monkeypatch.setattr(submissions, "write_submission_files", write_submission_files)
+ monkeypatch.setattr(submissions, "resolve_web_entitlements", lambda *a, **k: {
+ **ENTITLEMENTS, "skin_kinds": kinds, "max_3d_pair_bytes": 0})
+
+
+def test_plugin_downloads_3d_crossbow_with_animation(api, monkeypatch):
+ from src.skins import submissions
+
+ _real_files(monkeypatch, ["crossbow_3d"])
+ files = {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta(frametime=3)}
+ for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model", "charged_model"):
+ files[field] = _model()
+ made = submissions.create_submission(
+ session_for("skin"), "crossbow_3d", "Arm Crossbow", files, base_set="crossbows")
+ submissions.approve_submission(made["id"])
+ row = api.get("/skins/plugin/approved", headers=PLUGIN).json()["submissions"][0]
+ slug = made["slug"]
+ assert row["kind"] == "crossbow_3d" and row["base_set"] == "crossbows"
+ for name in (f"{slug}.png", f"{slug}.png.mcmeta", f"{slug}.json", f"{slug}_2.json", f"{slug}_charged.json"):
+ assert name in row["files"]
+ response = api.get(f"/skins/plugin/submissions/{made['id']}/files/{slug}.png.mcmeta", headers=PLUGIN)
+ assert response.status_code == 200
+ assert response.json() == {"animation": {"frametime": 3}}
+
+
+def test_crossbow_3d_missing_models_are_named(api, monkeypatch):
+ from src.skins import submissions
+
+ monkeypatch.setattr(submissions, "resolve_web_entitlements", lambda *a, **k: {
+ **ENTITLEMENTS, "skin_kinds": ["crossbow_3d"]})
+ with pytest.raises(submissions.SubmissionError, match="Missing files: pull_2_model, charged_model"):
+ submissions.create_submission(
+ session_for("skin"), "crossbow_3d", "Arm Crossbow",
+ {"texture": _png((16, 16)), "model": _model(), "pull_0_model": _model(), "pull_1_model": _model()},
+ base_set="crossbows")
+
+
+def test_single_armour_set_reaches_the_plugin(api, monkeypatch):
+ from src.skins import submissions
+
+ _real_files(monkeypatch, ["armor_set"])
+ files = {}
+ for i, part in enumerate(("helmet", "chestplate", "leggings", "boots")):
+ files[f"wild_mage_{part}"] = _png((16, 16), [(i + 1, 9, 9, 255)])
+ files["wild_mage_layer_1"] = _png((64, 32), [(1, 1, 1, 255)])
+ files["wild_mage_layer_2"] = _png((64, 32), [(2, 2, 2, 255)])
+ made = submissions.create_submission(
+ session_for("skin"), "armor_set", "Storm Robes", files, tiers=["wild_mage"])
+ submissions.approve_submission(made["id"])
+ row = api.get("/skins/plugin/approved", headers=PLUGIN).json()["submissions"][0]
+ assert row["tiers"] == ["wild_mage"]
+ assert row["tier_sets"] == {"wild_mage": "wild mage"}
+ assert row["tier_aliases"] == {"wild_mage": "Wild Mage"}
+
+
+def test_review_sheet_shows_first_frame_and_notes_animation(tmp_path):
+ from src.skins.review_sheet import _animated_note, _load_rgba, _single_texture_body
+
+ png = tmp_path / "b.png"
+ png.write_bytes(_png((16, 48), [RED, GREEN, BLUE]))
+ assert _animated_note(png) == ""
+ (tmp_path / "b.png.mcmeta").write_bytes(_meta())
+ assert _load_rgba(png).size == (16, 16)
+ assert _animated_note(png) == " · animated · 3 frames"
+ body = _single_texture_body("b", "bow_3d", None, "longbows", tmp_path)
+ assert body.width >= 256
diff --git a/backend/src/skins/thumbnail.py b/backend/src/skins/thumbnail.py
index a821ed3a..e2d73f4e 100644
--- a/backend/src/skins/thumbnail.py
+++ b/backend/src/skins/thumbnail.py
@@ -8,6 +8,7 @@
import numpy as np
from PIL import Image
+from .animation import first_frame_png
from .db import SKINS_DIR
from .preview_3d import PREVIEW_NAMES
@@ -28,7 +29,7 @@ def _without_backdrop(path: Path) -> bytes:
def skin_thumbnail(submission_id: str, slug: str) -> bytes | None:
- """A rendered preview without its backdrop, else the flat texture, else None.
+ """A rendered preview without its backdrop, else the flat texture (first frame), else None.
Only reads files the review sheet already made; it never starts a render.
"""
@@ -40,5 +41,5 @@ def skin_thumbnail(submission_id: str, slug: str) -> bytes | None:
for name in (f"{slug}.png", f"{slug}_unsigned.png"):
path = out_dir / name
if path.is_file():
- return path.read_bytes()
+ return first_frame_png(path) or path.read_bytes()
return None
diff --git a/backend/tests/test_skin_asset_lifecycle.py b/backend/tests/test_skin_asset_lifecycle.py
index 754e39dd..27a075ee 100644
--- a/backend/tests/test_skin_asset_lifecycle.py
+++ b/backend/tests/test_skin_asset_lifecycle.py
@@ -19,6 +19,8 @@
("helmet_3d", [""]),
("mask", [""]),
("gun", ["_carry", "_reload", "_aim", "_aim_charged"]),
+ ("bow_3d", ["", "_0", "_1", "_2"]),
+ ("crossbow_3d", ["", "_0", "_1", "_2", "_charged"]),
],
)
def test_upload_writes_downloadable_models(tmp_path, monkeypatch, kind, suffixes):
@@ -35,6 +37,11 @@ def test_upload_writes_downloadable_models(tmp_path, monkeypatch, kind, suffixes
files.update({f"pull_{i}": png.getvalue() for i in range(3)})
elif kind == "gun":
files.update({f"{pose}_model": model for pose in ("carry", "reload", "aim")})
+ elif kind in ("bow_3d", "crossbow_3d"):
+ files["model"] = model
+ files.update({f"pull_{i}_model": model for i in range(3)})
+ if kind == "crossbow_3d":
+ files["charged_model"] = model
elif kind != "large_handheld":
files["model"] = model
folder = storage.write_submission_files(
diff --git a/docs/docs/cosmetics/naming.md b/docs/docs/cosmetics/naming.md
index db0e75a7..9a2d208f 100644
--- a/docs/docs/cosmetics/naming.md
+++ b/docs/docs/cosmetics/naming.md
@@ -40,9 +40,9 @@ Filenames are **freeform** - the API only validates PNG magic bytes, max size, a
### `armor_set` (multi-tier)
-One submission holds **1-6 tiers** from the allowlist `iron | steel | abyssalite | mythril | mage | infantry`. Each tier needs all six multipart fields: `{tier}_helmet`, `{tier}_chestplate`, `{tier}_leggings`, `{tier}_boots`, `{tier}_layer_1`, `{tier}_layer_2`.
+One submission is one **metal line** — up to one set per type (`light`, `medium`, `heavy`, `infantry`, `mage`) of one metal (`iron`, `steel`, `bronze`, `abyssalite`, `mythril`), tier `{type}_{metal}`, ArmourShop set `{type} {metal}` — or exactly one **single set**: `leather`, `illusion`, `spiritual`, `arcanum`, `bloodmagic`, `necromancy`, `shadowmancy`, `light_mage`, `dark_mage`, `wild_mage` (ArmourShop set: underscores become spaces, e.g. `wild mage`). Single sets cannot be mixed with other sets. Each tier needs all six multipart fields: `{tier}_helmet`, `{tier}_chestplate`, `{tier}_leggings`, `{tier}_boots`, `{tier}_layer_1`, `{tier}_layer_2`.
-Plus a form field `tiers` - a JSON array, e.g. `["iron","steel"]`. Exact sizes: icons **16×16**; layers **64×32**. Every PNG in a submission must have distinct upload bytes (SHA-256).
+Plus a form field `tiers` - a JSON array, e.g. `["light_iron","heavy_iron"]` or `["wild_mage"]`. Exact sizes: icons **16×16**; layers **64×32**. Every PNG in a submission must have distinct upload bytes (SHA-256).
*Legacy single-tier path:* unprefixed fields plus `base_set` still accepted for backward compatibility.
@@ -54,6 +54,14 @@ One `texture` field. Sizes: `handheld` **16×16**; `large_handheld` **32×32**.
Fields `texture`, `pull_0`, `pull_1`, `pull_2` (bow four + `charged` for crossbow). Sizes: `bow` **16×16**; `large_bow` **32×32**.
+### `bow_3d` / `crossbow_3d`
+
+One `texture` plus a Blockbench model per bow state: `model` (standby), `pull_0_model`, `pull_1_model`, `pull_2_model`, and `charged_model` for crossbows. Stored as `{id}.png`, `{id}.json`, `{id}_0.json`, `{id}_1.json`, `{id}_2.json`, `{id}_charged.json`, every model's textures pointing at `{id}`. All uploaded files together (texture, models, `.mcmeta`) may be at most **61440 bytes** for every rank. Missing display tabs take vanilla bow / crossbow hand poses.
+
+### Animated textures
+
+`{field}_mcmeta` beside any PNG field except armour layers; stored as `{stored png}.mcmeta` (e.g. `{id}.png.mcmeta`, `{id}_0.png.mcmeta`, `{tier}_chestplate.png.mcmeta`). A flat crossbow's `charged` animation is stored for both `{id}_charged.png` and `{id}_arrow.png`.
+
### `item_3d` / `shield` / `helmet_3d` / `mask` / `gun`
Multipart `texture` + model JSON(s). Combined texture+JSON pair byte budget from ArmourShop `permission-groups.yml`. Shield blocking model is **not** uploaded (ArmourShop clones at apply).
diff --git a/docs/docs/cosmetics/skins.md b/docs/docs/cosmetics/skins.md
index 020a8223..624bcc8c 100644
--- a/docs/docs/cosmetics/skins.md
+++ b/docs/docs/cosmetics/skins.md
@@ -6,7 +6,7 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review
## Goals
-- Donators submit **armor sets** (2D, optional per-tier 3D helmet), **weapon/tool skins** (`handheld` / `large_handheld` / `bow` / `large_bow` / `crossbow`), **3D kinds** (`item_3d` / `shield` / `helmet_3d` / `mask`), **guns** (`gun`), and **books** (`book`; unsigned + signed covers).
+- Donators submit **armor sets** (2D, optional per-tier 3D helmet), **weapon/tool skins** (`handheld` / `large_handheld` / `bow` / `large_bow` / `crossbow`), **3D kinds** (`item_3d` / `shield` / `helmet_3d` / `mask` / `bow_3d` / `crossbow_3d`), **guns** (`gun`), and **books** (`book`; unsigned + signed covers).
- Players start a skin from the Profile wardrobe ([below](#profile-wardrobe)) or with a TFMCWeb code (`/token create skin` or **`/token create skin staff`**); either is bound to the player UUID.
- **Player:** staff approve/deny in Discord; ArmourShop writes **`tfmc_submissions`** + `ps_*` + LP.
- **Staff curated:** auto-approve (no bot); category + scroll on upload; writes **`tfmc_armorshop`** into real ArmourShop categories.
@@ -22,6 +22,8 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review
| `bow` | `texture`, `pull_0`, `pull_1`, `pull_2` | **16×16** | |
| `large_bow` | same four fields | **32×32** | |
| `crossbow` | bow four + `charged` | **16×16** | |
+| `bow_3d` | `texture` + `model`, `pull_0_model`, `pull_1_model`, `pull_2_model` | All files together ≤ **61440 bytes**, every rank | `shortbows` / `longbows`; `BOW` + `model_path` |
+| `crossbow_3d` | `bow_3d` fields + `charged_model` | Same fixed total | `crossbows`; `CROSSBOW` + `model_path` |
| `item` | - | - | **Disabled** for upload |
| `item_3d` | `texture` + `model` | Pair byte budget from entitlements | `generate: false` |
| `shield` | `texture` + `model` | Same pair budget | Blocking clone at apply |
@@ -30,6 +32,12 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review
| `gun` | `texture` + carry/reload/aim models | Three pair checks | GaG `skins.yml` `ia.…` |
| `book` | `unsigned` + `signed` | Both **16×16** | `base_set: books` |
+Which item types each kind may target (`base_set`) lives in [`shared/skins/base_sets.json`](../../../shared/skins/base_sets.json), read by both the API and the upload page.
+
+### Animated textures (`.mcmeta`)
+
+Any PNG field may send an optional `{field}_mcmeta` (Minecraft `.png.mcmeta` JSON, ≤ 2048 bytes), except armour layers (`{tier}_layer_1` / `{tier}_layer_2`). Only an `animation` section is accepted (`frametime`, `interpolate`, `width`, `height`, `frames`). With an `.mcmeta`, a fixed-size slot takes a whole grid of frames of that size (e.g. a 16×64 handheld strip of four frames). The API stores it beside the PNG as `{png name}.mcmeta`, lists it for the plugin, and ArmourShop copies it into the pack. Review sheets and 3D preview tiles show the first frame and label the tile `animated · N frames`. For 3D kinds the `.mcmeta` counts toward the byte budget.
+
Upload **filenames are ignored** for identity - the API validates PNG/JSON and writes fixed stems from the submission id. See [naming.md](naming.md).
### Grip presets (`large_handheld` only)
@@ -88,7 +96,7 @@ Configured in TFMCWeb `config.yml` `player-meta.skins`; pushed on join/reload vi
| defaults / no rank | disallowed | none | false |
| Noble | 28 days | handheld, large_handheld, bow, large_bow, crossbow, book | false |
| Gilded | 21 days | + armor_set | false |
-| Ascended | 14 days | + item_3d, shield, helmet_3d, mask, gun | true |
+| Ascended | 14 days | + item_3d, shield, helmet_3d, mask, gun, bow_3d, crossbow_3d | true |
| Legacy | 7 days | (same as Ascended) | true |
## Discord link (MC ↔ Discord)
diff --git a/docs/docs/integrations/armourshop.md b/docs/docs/integrations/armourshop.md
index 5f2bbf82..52f562c1 100644
--- a/docs/docs/integrations/armourshop.md
+++ b/docs/docs/integrations/armourshop.md
@@ -74,6 +74,7 @@ Namespace: **`tfmc_submissions`**.
| `handheld` | `generate: true` + `parent: item/handheld` |
| `large_handheld` | `generate: false` + grip template model |
| `bow` / `large_bow` / `crossbow` | ArmourShop writers; pull/draw frames |
+| `bow_3d` / `crossbow_3d` | `generate: false` + `model_path`; web-built `{slug}.json`, `_0`, `_1`, `_2` (+ `_charged`, firework copy at apply) |
| `book` | `{slug}` (`WRITABLE_BOOK`) + `{slug}_signed` (`WRITTEN_BOOK`); shop lists `{slug}` only |
| `item_3d` | `generate: false` + donor JSON (`PAPER`) |
| `helmet_3d` | `CARVED_PUMPKIN` + `behaviours.hat: true` (armor-set 3D helmets use the same) |
@@ -81,6 +82,8 @@ Namespace: **`tfmc_submissions`**.
| `shield` | Donor JSON + ArmourShop blocking clone |
| `gun` | STONE_HOE carry/reload + CROSSBOW aim; GaG `skins.yml` |
+A PNG may arrive with a sibling `{png name}.mcmeta` (animated texture); writers copy it next to the pack PNG.
+
Never add new skins via manual `custom_model_data` lists in legacy `tfmc_pack`.
## Discord link (before skins upload)
diff --git a/frontend/app/components/character/LoreItemEditor.tsx b/frontend/app/components/character/LoreItemEditor.tsx
index 8553ba07..3b9f1248 100644
--- a/frontend/app/components/character/LoreItemEditor.tsx
+++ b/frontend/app/components/character/LoreItemEditor.tsx
@@ -25,6 +25,7 @@ import {
assertFileSize,
expectedSizeForField,
isModel3dKind,
+ isRanged3dKind,
pairBudgetHint,
type SkinKind,
} from "../../../lib/skins/sizes";
@@ -39,6 +40,8 @@ const KNOWN_SKIN_KINDS = new Set([
"bow",
"large_bow",
"crossbow",
+ "bow_3d",
+ "crossbow_3d",
"item_3d",
"shield",
"helmet_3d",
@@ -47,6 +50,11 @@ const KNOWN_SKIN_KINDS = new Set([
"book",
]);
+/** Kinds whose skin is a model + texture (a 3D bow's standby model stands in for the bow). */
+function isModelSkinKind(kind: SkinKind): boolean {
+ return isModel3dKind(kind) || isRanged3dKind(kind);
+}
+
function asSkinKind(raw: string | null | undefined, fallback: SkinKind): SkinKind {
const k = String(raw || "").trim();
if (KNOWN_SKIN_KINDS.has(k)) return k as SkinKind;
@@ -221,9 +229,21 @@ export default function LoreItemEditor({
[item.pickable_skins, pickedSkinId],
);
const pickedIs3d = Boolean(
- skinMode === "pick" && pickedSkin && isModel3dKind(asSkinKind(pickedSkin.kind, "item_3d")),
+ skinMode === "pick" && pickedSkin && isModelSkinKind(asSkinKind(pickedSkin.kind, "item_3d")),
);
+ /** The 3D kind and model the preview shows, or null for the flat texture. */
+ const previewKind: SkinKind | null = pickedIs3d
+ ? asSkinKind(pickedSkin!.kind, "item_3d")
+ : skinMode === "upload" && allows3d && use3d && modelFile
+ ? threeDKind || "item_3d"
+ : null;
+ const previewModel = pickedIs3d
+ ? previewModelFile
+ : skinMode === "upload" && allows3d && use3d && modelFile
+ ? modelFile
+ : null;
+
const isDirty = useMemo(() => {
if (displayName.trim() !== baseline.displayName.trim()) return true;
if (lore.length !== baseline.lore.length) return true;
@@ -396,7 +416,7 @@ export default function LoreItemEditor({
}
const file = await fetchAsFile(url, filename);
let modelFile: File | null = null;
- if (skinMode === "pick" && pickedSkin && isModel3dKind(asSkinKind(pickedSkin.kind, "item_3d"))) {
+ if (skinMode === "pick" && pickedSkin && isModelSkinKind(asSkinKind(pickedSkin.kind, "item_3d"))) {
modelFile = await fetchAsFile(
loreItemSkinModelUrl(pickedSkinId, item.base_set),
`${pickedSkinId.trim()}.json`,
@@ -802,7 +822,7 @@ export default function LoreItemEditor({
{skin.display_name || skin.id}
- {isModel3dKind(asSkinKind(skin.kind, "handheld")) ? (
+ {isModelSkinKind(asSkinKind(skin.kind, "handheld")) ? (
3D
) : null}
{skin.staff ? staff : null}
@@ -846,20 +866,9 @@ export default function LoreItemEditor({
: "Default skin"}
diff --git a/frontend/app/components/profile/SkinShowcase.tsx b/frontend/app/components/profile/SkinShowcase.tsx
index e634ac6e..aa8a2947 100644
--- a/frontend/app/components/profile/SkinShowcase.tsx
+++ b/frontend/app/components/profile/SkinShowcase.tsx
@@ -7,7 +7,7 @@ import { kindLabel } from "../skins/KindPicker";
import { getSubmissionFile, type SubmissionPublic } from "../../../lib/skins/api";
import { armorTierLabel, baseSetLabel } from "../../../lib/skins/baseSets";
import { previewColourStopRuns } from "../../../lib/skins/namePreview";
-import { ownerFileNames } from "../../../lib/skins/ownerFiles";
+import { ownerAnimationFileNames, ownerFileNames } from "../../../lib/skins/ownerFiles";
import { ARMOR_ICON_FIELDS } from "../../../lib/skins/sizes";
import { SkinPicture } from "./WardrobePictures";
import {
@@ -28,7 +28,12 @@ import {
type Files = Record;
const FLAT_KINDS = ["handheld", "large_handheld", "bow", "large_bow", "crossbow"];
-const ICON_LABELS: Record = { helmet: "Helmet", chestplate: "Chest", leggings: "Legs", boots: "Boots" };
+const ICON_LABELS: Record = {
+ helmet: "Helmet",
+ chestplate: "Chest",
+ leggings: "Legs",
+ boots: "Boots",
+};
function statusMessage(row: SubmissionPublic): string {
switch (row.status) {
@@ -58,10 +63,10 @@ function useOwnerFiles(row: SubmissionPublic, sessionToken: string): Files | nul
// Checking the status again returns a new row; the files only change with the skin.
const { kind, slug } = row;
const tiers = (row.tiers ?? []).join(",");
- const names = useMemo(
- () => ownerFileNames({ kind, slug, tiers: tiers ? tiers.split(",") : [] }),
- [kind, slug, tiers],
- );
+ const names = useMemo(() => {
+ const row = { kind, slug, tiers: tiers ? tiers.split(",") : [] };
+ return { ...ownerFileNames(row), ...ownerAnimationFileNames(row) };
+ }, [kind, slug, tiers]);
useEffect(() => {
let live = true;
setFiles(null);
@@ -99,7 +104,12 @@ function Turnable({ children }: { children: ReactNode }) {
);
}
-type ViewProps = { row: SubmissionPublic; files: Files; fallback: ReactNode; children: ReactNode };
+type ViewProps = {
+ row: SubmissionPublic;
+ files: Files;
+ fallback: ReactNode;
+ children: ReactNode;
+};
function ArmourView({ row, files, fallback, children }: ViewProps) {
const tiers = row.tiers ?? [];
@@ -121,6 +131,7 @@ function ArmourView({ row, files, fallback, children }: ViewProps) {
helmet3d={Boolean(file("helmet_model") && file("helmet_texture"))}
helmetModelFile={file("helmet_model")}
helmetTextureFile={file("helmet_texture")}
+ helmetTextureMcmetaFile={file("helmet_texture_mcmeta")}
/>
) : (
@@ -132,13 +143,19 @@ function ArmourView({ row, files, fallback, children }: ViewProps) {
{tiers.length > 1 ? (
({ id: t, label: row.tier_aliases?.[t] || armorTierLabel(t) }))}
+ options={tiers.map((t) => ({
+ id: t,
+ label: row.tier_aliases?.[t] || armorTierLabel(t),
+ }))}
value={tier}
onChange={setTier}
/>
) : null}
({ label: ICON_LABELS[field]!, src: iconUrls[i] ?? null }))}
+ icons={ARMOR_ICON_FIELDS.map((field, i) => ({
+ label: ICON_LABELS[field]!,
+ src: iconUrls[i] ?? null,
+ }))}
/>
>
}
@@ -156,7 +173,14 @@ function BookView({ files, fallback, children }: Omit) {
stage={
texture ? (
-
+
) : (
fallback
@@ -185,6 +209,8 @@ function ItemView({ row, files, fallback, children }: ViewProps) {
const [failed, setFailed] = useState(false);
const { kind } = row;
const flat = FLAT_KINDS.includes(kind);
+ const ranged3d = kind === "bow_3d" || kind === "crossbow_3d";
+ const mcmeta = (field: string) => files[`${field}_mcmeta`] ?? null;
const grip = row.grip_preset != null ? Number(row.grip_preset) : NaN;
const stage =
files.texture && !failed ? (
@@ -194,10 +220,26 @@ function ItemView({ row, files, fallback, children }: ViewProps) {
className="h-full p-3"
kind={kind}
textureFile={files.texture}
- modelFile={files.model ?? null}
+ modelFile={ranged3d ? null : (files.model ?? null)}
+ textureMcmetaFile={mcmeta("texture")}
+ frameModels={
+ ranged3d
+ ? {
+ texture: files.model ?? null,
+ pull_0: files.pull_0_model ?? null,
+ pull_1: files.pull_1_model ?? null,
+ pull_2: files.pull_2_model ?? null,
+ charged: files.charged_model ?? null,
+ }
+ : undefined
+ }
gunModels={
kind === "gun"
- ? { carry: files.carry_model ?? null, reload: files.reload_model ?? null, aim: files.aim_model ?? null }
+ ? {
+ carry: files.carry_model ?? null,
+ reload: files.reload_model ?? null,
+ aim: files.aim_model ?? null,
+ }
: undefined
}
flatTextureFile={flat ? files.texture : null}
@@ -212,6 +254,17 @@ function ItemView({ row, files, fallback, children }: ViewProps) {
}
: undefined
}
+ flatMcmeta={
+ flat
+ ? {
+ texture: mcmeta("texture"),
+ pull_0: mcmeta("pull_0"),
+ pull_1: mcmeta("pull_1"),
+ pull_2: mcmeta("pull_2"),
+ charged: mcmeta("charged"),
+ }
+ : undefined
+ }
gripY={kind === "large_handheld" && Number.isFinite(grip) ? grip : null}
onPreviewError={(message) => message && setFailed(true)}
/>
@@ -267,7 +320,12 @@ export default function SkinShowcase({
{row.add_name || row.name_colours?.length ? : null}
Upload a layer to preview.;
diff --git a/frontend/app/components/skins/FileSlot.tsx b/frontend/app/components/skins/FileSlot.tsx
new file mode 100644
index 00000000..8bd688e3
--- /dev/null
+++ b/frontend/app/components/skins/FileSlot.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useId } from "react";
+
+type Props = {
+ label: string;
+ /** Size / format hint under the label (e.g. "16×16 PNG"). */
+ hint: string;
+ accept: string;
+ file: File | null | undefined;
+ onPick: (file: File) => void;
+ disabled?: boolean;
+ /** When set, the card offers an optional `.mcmeta` animation for this texture. */
+ animation?: {
+ file: File | null | undefined;
+ onPick: (file: File) => void;
+ onClear: () => void;
+ };
+};
+
+/** Ignore cancel / empty picker so an existing selection is kept. */
+function picked(list: FileList | null, apply: (file: File) => void) {
+ const file = list?.[0];
+ if (file) apply(file);
+}
+
+const pickButton =
+ "inline-flex cursor-pointer items-center rounded-sm bg-[var(--tfmc-moss)] px-3 py-1.5 text-xs font-medium text-[var(--tfmc-cream)] transition hover:opacity-90";
+
+/** One upload card: required file, its hint, and an optional animation slot. */
+export default function FileSlot({ label, hint, accept, file, onPick, disabled, animation }: Props) {
+ const id = useId();
+ const animId = useId();
+ return (
+
+
+
+ {label}
+ {hint}
+
+
+ {file ? "✓" : "Required"}
+
+
+
+
+ {
+ picked(e.target.files, onPick);
+ e.target.value = "";
+ }}
+ className="sr-only"
+ />
+ {file?.name ?? ""}
+
+ {animation ? (
+
+ Animation (.mcmeta)
+
+ {
+ picked(e.target.files, animation.onPick);
+ e.target.value = "";
+ }}
+ className="sr-only"
+ />
+ {animation.file ? (
+ <>
+ {animation.file.name}
+
+ >
+ ) : (
+ optional
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/app/components/skins/KindHelp.tsx b/frontend/app/components/skins/KindHelp.tsx
index 42b359b1..243486c6 100644
--- a/frontend/app/components/skins/KindHelp.tsx
+++ b/frontend/app/components/skins/KindHelp.tsx
@@ -14,10 +14,10 @@ const GUIDES: Record = {
armor_set: {
title: "Armour set",
summary:
- "Submit one metal's skin line: up to 5 sets (Light, Medium, Heavy, Infantry and Mage) of that metal. Each set becomes its own shop listing under your name.",
+ "Submit one metal's skin line: up to 5 sets (Light, Medium, Heavy, Infantry and Mage) of that metal, or one single set such as Leather or a mage school. Each set becomes its own shop listing under your name.",
steps: [
- "Choose the metal (Iron, Steel, Bronze, Abyssalite or Mythril). Every set in the submission uses it.",
- "Add at least one set type (Light, Medium, Heavy, Infantry or Mage).",
+ "Choose a metal (Iron, Steel, Bronze, Abyssalite or Mythril) or a single set. Every set in the submission uses it.",
+ "For a metal, pick at least one set type (Light, Medium, Heavy, Infantry or Mage). You can give each weight its own look, or make only one.",
"Set name is optional if you want a custom name instead of the default label (e.g. Light Iron).",
"Per set, upload helmet, chestplate, leggings, and boots icons (16×16 PNG).",
"Upload Layer 1 and Layer 2 body textures (64×32 PNG) for that set.",
@@ -75,6 +75,30 @@ const GUIDES: Record = {
"Set the item name / colours, then submit.",
],
},
+ bow_3d: {
+ title: "3D bow",
+ summary: "One texture and four Blockbench models, so the bow bends as it's drawn.",
+ steps: [
+ "Choose Shortbows or Longbows.",
+ "Upload one texture PNG used by every model.",
+ "Export Standby, Pull 0, Pull 1 and Pull 2 as Java Block/Item JSON (File → Export → Export Block/Item Model; one-axis 22.5°/45° rotations only).",
+ "All five files together must be 60 KB or less.",
+ "Set the item name / colours, then submit.",
+ ],
+ notes: ["Use the frame buttons on the preview to check each draw stage in hand."],
+ },
+ crossbow_3d: {
+ title: "3D crossbow",
+ summary: "One texture and five Blockbench models: standby, three loading stages and charged.",
+ steps: [
+ "Choose Crossbows.",
+ "Upload one texture PNG used by every model.",
+ "Export Standby, Pull 0, Pull 1, Pull 2 and Charged as Java Block/Item JSON (one-axis 22.5°/45° rotations only).",
+ "All six files together must be 60 KB or less.",
+ "Set the item name / colours, then submit.",
+ ],
+ notes: ["The charged model is also used when a firework is loaded."],
+ },
item_3d: {
title: "Item 3D",
summary: "A custom Blockbench model used as a 3D item skin.",
diff --git a/frontend/app/components/skins/KindPicker.tsx b/frontend/app/components/skins/KindPicker.tsx
index 85ae0d3a..12fac9bd 100644
--- a/frontend/app/components/skins/KindPicker.tsx
+++ b/frontend/app/components/skins/KindPicker.tsx
@@ -4,130 +4,155 @@ import { useState } from "react";
import type { SkinKind } from "../../../lib/skins/sizes";
import { KindHelpPanel, KindHelpToggle, useKindHelpId } from "./KindHelp";
-const OPTIONS: {
- value: SkinKind;
- label: string;
- detail: string;
-}[] = [
- { value: "armor_set", label: "Armour set", detail: "One metal, up to 5 sets" },
- { value: "handheld", label: "Handheld", detail: "16×16 texture" },
- {
- value: "large_handheld",
+const KINDS: Record = {
+ armor_set: {
+ label: "Armour set",
+ style: "Armour",
+ detail: "Icons + body layers per set",
+ },
+ handheld: { label: "Handheld", style: "Flat", detail: "16×16 texture" },
+ large_handheld: {
label: "Large handheld",
- detail: "32×32",
+ style: "Flat (large)",
+ detail: "32×32 + grip slider",
+ },
+ bow: { label: "Bow", style: "Flat frames", detail: "16×16 · 4 frames" },
+ large_bow: {
+ label: "Large bow",
+ style: "Flat frames (large)",
+ detail: "32×32 · 4 frames",
+ },
+ crossbow: {
+ label: "Crossbow",
+ style: "Flat frames",
+ detail: "16×16 · 5 frames",
+ },
+ bow_3d: {
+ label: "3D bow",
+ style: "3D model",
+ detail: "1 texture + 4 models · 60 KB total",
+ },
+ crossbow_3d: {
+ label: "3D crossbow",
+ style: "3D model",
+ detail: "1 texture + 5 models · 60 KB total",
+ },
+ item_3d: { label: "Item 3D", style: "3D model", detail: "Model + texture" },
+ shield: {
+ label: "Shield 3D",
+ style: "3D model",
+ detail: "Model + texture · idle and blocking",
},
- { value: "bow", label: "Bow", detail: "16×16 · 4 frames" },
- { value: "large_bow", label: "Large bow", detail: "32×32 · 4 frames" },
- { value: "crossbow", label: "Crossbow", detail: "16×16 · 5 frames" },
- { value: "item_3d", label: "Item 3D", detail: "Model + texture" },
- { value: "shield", label: "Shield 3D", detail: "Model + texture" },
- { value: "helmet_3d", label: "Helmet 3D", detail: "Model + texture" },
- { value: "mask", label: "Mask", detail: "3D helmet · hides identity" },
- { value: "gun", label: "Gun", detail: "Carry · reload · aim" },
- {
- value: "book",
+ helmet_3d: {
+ label: "Helmet 3D",
+ style: "3D model",
+ detail: "Model + texture",
+ },
+ mask: {
+ label: "Mask",
+ style: "3D mask",
+ detail: "3D helmet · hides identity",
+ },
+ gun: { label: "Gun", style: "3D model", detail: "Carry · reload · aim" },
+ book: {
label: "Book",
+ style: "Flat covers",
detail: "Unsigned + signed · 16×16",
},
-];
+};
/** Player-facing label for a skin kind. */
export function kindLabel(kind: string): string {
- return OPTIONS.find((opt) => opt.value === kind)?.label ?? kind;
+ return KINDS[kind as SkinKind]?.label ?? kind;
+}
+
+/** Short style name and file summary for a kind (e.g. "3D model", "Model + texture"). */
+export function kindStyle(kind: SkinKind): { style: string; detail: string } {
+ return KINDS[kind];
}
type Props = {
- value: SkinKind;
+ /** Styles the chosen item supports. */
+ kinds: readonly SkinKind[];
+ value: SkinKind | null;
onChange: (kind: SkinKind) => void;
disabled?: boolean;
- /** When set, only these kinds are shown. Empty = none. Omit/undefined = all. */
- allowedKinds?: string[] | null;
+ /** Rank name that unlocks a kind the player can't use, or undefined when it's usable. */
+ lockedBy: (kind: SkinKind) => string | undefined;
};
-export default function KindPicker({ value, onChange, disabled, allowedKinds }: Props) {
+/** Step 2: choose how the chosen item is skinned (flat, 3D…). */
+export default function KindPicker({ kinds, value, onChange, disabled, lockedBy }: Props) {
const [helpOpen, setHelpOpen] = useState(false);
const helpId = useKindHelpId();
- const options =
- allowedKinds == null
- ? OPTIONS
- : OPTIONS.filter((opt) => allowedKinds.map((k) => k.toLowerCase()).includes(opt.value));
return (
);
}
+
+export function LockIcon() {
+ return (
+
+ );
+}
diff --git a/frontend/app/components/skins/ModelPreview.tsx b/frontend/app/components/skins/ModelPreview.tsx
index 1568c2b1..80cac714 100644
--- a/frontend/app/components/skins/ModelPreview.tsx
+++ b/frontend/app/components/skins/ModelPreview.tsx
@@ -14,6 +14,8 @@ import {
import { buildExtrudedItemGroup, loadImageDataFromFile } from "../../../lib/skins/extrudeItem";
import { resolveFlatDisplayTab, type FlatDisplayKind } from "../../../lib/skins/flatItemDisplay";
import { resolveShieldBlockingTab } from "../../../lib/skins/shieldBlockingDisplay";
+import { loadAnimatedFrames } from "../../../lib/skins/animatedTexture";
+import { frameIndexAt, prefersReducedMotion, type ScheduledFrame } from "../../../lib/textureAnimation";
import {
buildJavaModelGroup,
disposeObject3D,
@@ -41,6 +43,9 @@ export type FlatFrameId = "texture" | "pull_0" | "pull_1" | "pull_2" | "charged"
export type FlatFrames = Partial>;
+/** 3D bow / crossbow: one model per draw stage, keyed like the flat frames. */
+export type FrameModels = Partial>;
+
type ItemSlot = "model" | "thirdperson_righthand" | "thirdperson_lefthand" | "head";
type GunSlot = "model" | "carry_right" | "carry_left" | "reload_right" | "reload_left" | "aim_right" | "aim_left";
@@ -57,6 +62,12 @@ type Props = {
/** Standby / single PNG for flat kinds (optional if flatFrames.texture set). */
flatTextureFile?: File | null;
flatFrames?: FlatFrames;
+ /** Optional `.mcmeta` for each flat frame texture. */
+ flatMcmeta?: FlatFrames;
+ /** Optional `.mcmeta` animating `textureFile` on a 3D model. */
+ textureMcmetaFile?: File | null;
+ /** 3D bow / crossbow models per draw stage (all share `textureFile`). */
+ frameModels?: FrameModels;
/** Thirdperson grip Y for large_handheld (2.5–5.5). */
gripY?: number | null;
/** Tinted potion preview color (#RRGGBB); avoids rebuilding mesh on each change. */
@@ -91,8 +102,29 @@ type ContentRefs = {
steveTexture: THREE.Texture | null;
potionCanvas: HTMLCanvasElement | null;
floatingRoot: THREE.Group | null;
+ animation: PreviewAnimation | null;
+};
+
+/** A built animated texture: shows source frame `index` when asked. */
+type PreviewAnimation = {
+ schedule: ScheduledFrame[];
+ show: (index: number) => void;
+ dispose: () => void;
+ current: number;
+ startedAt: number;
};
+const EMPTY_CONTENT = (): ContentRefs => ({
+ itemRoot: null,
+ itemTexture: null,
+ javaJson: null,
+ steveRoot: null,
+ steveTexture: null,
+ potionCanvas: null,
+ floatingRoot: null,
+ animation: null,
+});
+
const FRAME_CHIPS: { id: FlatFrameId; label: string }[] = [
{ id: "texture", label: "Standby" },
{ id: "pull_0", label: "Pull 0" },
@@ -121,8 +153,19 @@ function flatDisplayKind(kind?: DisplayKind, preset?: FlatDisplayKind): FlatDisp
return preset ?? kind ?? "handheld";
}
+function isRanged3dKind(kind?: DisplayKind): boolean {
+ return kind === "bow_3d" || kind === "crossbow_3d";
+}
+
function isMultiFrameKind(kind?: DisplayKind): boolean {
- return kind === "bow" || kind === "large_bow" || kind === "crossbow";
+ return kind === "bow" || kind === "large_bow" || kind === "crossbow" || isRanged3dKind(kind);
+}
+
+/** Flat pose family for a 3D bow / crossbow. */
+function poseKind(kind?: DisplayKind): DisplayKind | undefined {
+ if (kind === "bow_3d") return "bow";
+ if (kind === "crossbow_3d") return "crossbow";
+ return kind;
}
function fileIdentity(file: File | null | undefined): string {
@@ -197,6 +240,8 @@ function disposeContent(content: ContentRefs, scene?: THREE.Scene): void {
content.itemTexture = null;
content.javaJson = null;
content.potionCanvas = null;
+ content.animation?.dispose();
+ content.animation = null;
}
function itemSlotsForKind(kind?: DisplayKind): { id: ItemSlot; label: string }[] {
@@ -288,12 +333,14 @@ function availableFrames(
flatFrames?: FlatFrames,
flatTextureFile?: File | null,
textureFile?: File | null,
+ frameModels?: FrameModels,
): FlatFrameId[] {
if (!isMultiFrameKind(kind)) return ["texture"];
const ids: FlatFrameId[] =
- kind === "crossbow"
+ kind === "crossbow" || kind === "crossbow_3d"
? ["texture", "pull_0", "pull_1", "pull_2", "charged"]
: ["texture", "pull_0", "pull_1", "pull_2"];
+ if (isRanged3dKind(kind)) return ids.filter((id) => Boolean(frameModels?.[id]));
return ids.filter((id) => Boolean(resolveFlatTextureFile(id, flatFrames, flatTextureFile, textureFile)));
}
@@ -361,6 +408,9 @@ export default function ModelPreview({
flatDisplayPreset,
flatTextureFile = null,
flatFrames,
+ flatMcmeta,
+ textureMcmetaFile = null,
+ frameModels,
gripY = null,
potionTintColor = null,
onPreviewError,
@@ -375,15 +425,7 @@ export default function ModelPreview({
const hostRef = useRef(null);
const runtimeRef = useRef(null);
- const contentRef = useRef({
- itemRoot: null,
- itemTexture: null,
- javaJson: null,
- steveRoot: null,
- steveTexture: null,
- potionCanvas: null,
- floatingRoot: null,
- });
+ const contentRef = useRef(EMPTY_CONTENT());
const heldItemRef = useRef(null);
const heldMirrorLeftRef = useRef(false);
const steveLiveRef = useRef(null);
@@ -408,8 +450,8 @@ export default function ModelPreview({
showOuterLayerRef.current = showOuterLayer;
const frameOptions = useMemo(
- () => availableFrames(kind, flatFrames, flatTextureFile, textureFile),
- [kind, flatFrames, flatTextureFile, textureFile],
+ () => availableFrames(kind, flatFrames, flatTextureFile, textureFile, frameModels),
+ [kind, flatFrames, flatTextureFile, textureFile, frameModels],
);
const previewSlots = useMemo(() => {
@@ -419,9 +461,13 @@ export default function ModelPreview({
const pipelineKey = `${Number(gun)}-${Number(flat)}-${kind ?? ""}-${displayKind}`;
- const activeModelFile = resolveActiveModelFile(gun, slot, modelFile, gunModels);
+ const ranged3d = isRanged3dKind(kind);
+ const activeModelFile = ranged3d
+ ? (frameModels?.[frame] ?? null)
+ : resolveActiveModelFile(gun, slot, modelFile, gunModels);
const activeFlatTexture = flat ? resolveFlatTextureFile(frame, flatFrames, flatTextureFile, textureFile) : null;
+ const activeMcmeta = flat ? (flatMcmeta?.[frame] ?? null) : textureMcmetaFile;
const hasPreview = flat ? Boolean(activeFlatTexture) || potionTintMode : Boolean(activeModelFile && textureFile);
@@ -431,10 +477,10 @@ export default function ModelPreview({
const placement = meshOnMannequin ? "m" : "f";
if (potionTintMode) return "potion:generated";
if (flat) {
- return `flat:${fileIdentity(activeFlatTexture)}:${placement}`;
+ return `flat:${fileIdentity(activeFlatTexture)}:${fileIdentity(activeMcmeta)}:${placement}`;
}
- return `3d:${fileIdentity(activeModelFile)}:${fileIdentity(textureFile)}:${placement}`;
- }, [potionTintMode, flat, activeFlatTexture, activeModelFile, textureFile, meshOnMannequin]);
+ return `3d:${fileIdentity(activeModelFile)}:${fileIdentity(textureFile)}:${fileIdentity(activeMcmeta)}:${placement}`;
+ }, [potionTintMode, flat, activeFlatTexture, activeModelFile, textureFile, activeMcmeta, meshOnMannequin]);
const layoutSignature = `${slot}:${frame}:${shieldMode}:${fileIdentity(playerSkinFile)}`;
@@ -482,6 +528,7 @@ export default function ModelPreview({
texture: THREE.Texture;
javaJson: JavaModelJson | null;
canvas: HTMLCanvasElement | null;
+ animation: PreviewAnimation | null;
}> => {
if (flat) {
if (potionTintMode && tintColor) {
@@ -494,11 +541,52 @@ export default function ModelPreview({
const root = buildExtrudedItemGroup(imageData, texture, {
center: !onMannequin,
});
- return { root, texture, javaJson: null, canvas };
+ return { root, texture, javaJson: null, canvas, animation: null };
}
if (!activeFlatTexture) {
throw new Error("No texture for selected frame");
}
+ if (activeMcmeta) {
+ // The outline can change per frame, so each frame gets its own mesh.
+ const animated = await loadAnimatedFrames(activeFlatTexture, activeMcmeta);
+ const root = new THREE.Group();
+ root.name = "animatedItem";
+ const textures = animated.frames.map(textureFromCanvas);
+ const frameRoots = animated.frames.map((canvas, i) => {
+ let frameRoot: THREE.Object3D;
+ try {
+ frameRoot = buildExtrudedItemGroup(canvasImageData(canvas), textures[i]!, {
+ center: !onMannequin,
+ });
+ } catch {
+ // A blank frame is valid (blinks, flashes): the item disappears for it.
+ frameRoot = new THREE.Group();
+ }
+ frameRoot.visible = false;
+ root.add(frameRoot);
+ return frameRoot;
+ });
+ const animation: PreviewAnimation = {
+ schedule: animated.schedule,
+ current: -1,
+ startedAt: performance.now(),
+ show(index) {
+ frameRoots.forEach((frameRoot, i) => {
+ frameRoot.visible = i === index;
+ });
+ this.current = index;
+ },
+ dispose() {},
+ };
+ animation.show(animated.schedule[0]!.index);
+ return {
+ root,
+ texture: textures[0]!,
+ javaJson: null,
+ canvas: null,
+ animation,
+ };
+ }
const loaded = await loadImageDataFromFile(activeFlatTexture);
const root = buildExtrudedItemGroup(loaded.imageData, loaded.texture, {
center: !onMannequin,
@@ -508,6 +596,7 @@ export default function ModelPreview({
texture: loaded.texture,
javaJson: null,
canvas: null,
+ animation: null,
};
}
if (!activeModelFile || !textureFile) {
@@ -515,11 +604,49 @@ export default function ModelPreview({
}
const modelText = await activeModelFile.text();
const javaJson = parseJavaModelJson(modelText);
+ if (activeMcmeta) {
+ // Model UVs span one frame, so swapping whole frame images animates it.
+ const animated = await loadAnimatedFrames(textureFile, activeMcmeta);
+ const textures = animated.frames.map((canvas) => {
+ const texture = new THREE.Texture(canvas);
+ texture.magFilter = THREE.NearestFilter;
+ texture.minFilter = THREE.NearestFilter;
+ texture.generateMipmaps = false;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.needsUpdate = true;
+ return texture;
+ });
+ const firstIndex = animated.schedule[0]!.index;
+ const first = textures[firstIndex]!;
+ const root = buildJavaModelGroup(javaJson, first, first.image.width, first.image.height, {
+ center: !onMannequin,
+ });
+ const animation: PreviewAnimation = {
+ schedule: animated.schedule,
+ current: firstIndex,
+ startedAt: performance.now(),
+ show(index) {
+ const texture = textures[index];
+ if (texture) updateMeshTexture(root, texture);
+ this.current = index;
+ },
+ dispose() {
+ textures.forEach((texture) => texture.dispose());
+ },
+ };
+ return { root, texture: first, javaJson, canvas: null, animation };
+ }
const loaded = await loadTextureFromFile(textureFile);
const root = buildJavaModelGroup(javaJson, loaded.texture, loaded.width, loaded.height, { center: !onMannequin });
- return { root, texture: loaded.texture, javaJson, canvas: null };
+ return {
+ root,
+ texture: loaded.texture,
+ javaJson,
+ canvas: null,
+ animation: null,
+ };
},
- [flat, potionTintMode, activeFlatTexture, activeModelFile, textureFile],
+ [flat, potionTintMode, activeFlatTexture, activeModelFile, textureFile, activeMcmeta],
);
const applyLayout = useCallback(
@@ -565,8 +692,8 @@ export default function ModelPreview({
let pose: SteveArmPose;
let chargeProgress: number | undefined;
- if (flat) {
- const resolved = resolveFlatArmPose(kind, frame, leftHand);
+ if (flat || ranged3d) {
+ const resolved = resolveFlatArmPose(poseKind(kind), frame, leftHand);
pose = resolved.pose;
chargeProgress = resolved.chargeProgress;
} else if (kind === "shield" && slot !== "head") {
@@ -652,7 +779,7 @@ export default function ModelPreview({
restoreOrbit(camera, controls, orbitStateRef.current);
}
},
- [slot, gun, flat, kind, frame, shieldMode, displayKind, playerSkinFile],
+ [slot, gun, flat, ranged3d, kind, frame, shieldMode, displayKind, playerSkinFile],
);
// Viewer lifecycle: renderer + camera + controls persist until pipeline changes.
@@ -673,13 +800,17 @@ export default function ModelPreview({
reportError(null);
const transparent = stageRef.current;
+ const reducedMotion = prefersReducedMotion();
const scene = new THREE.Scene();
scene.background = transparent ? null : new THREE.Color(0x0f1c16);
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const widthPx = host.clientWidth || 320;
const heightPx = (transparent && host.clientHeight) || 240;
- const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: transparent });
+ const renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ alpha: transparent,
+ });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(widthPx, heightPx, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -715,6 +846,11 @@ export default function ModelPreview({
const tick = () => {
if (cancelled) return;
runtime.raf = requestAnimationFrame(tick);
+ const animation = contentRef.current.animation;
+ if (animation && !reducedMotion) {
+ const index = frameIndexAt(animation.schedule, performance.now() - animation.startedAt);
+ if (index !== animation.current) animation.show(index);
+ }
controls.update();
renderer.render(scene, camera);
};
@@ -728,15 +864,7 @@ export default function ModelPreview({
runtime.renderer.dispose();
runtimeRef.current = null;
disposeContent(contentRef.current, runtime.scene);
- contentRef.current = {
- itemRoot: null,
- itemTexture: null,
- javaJson: null,
- steveRoot: null,
- steveTexture: null,
- potionCanvas: null,
- floatingRoot: null,
- };
+ contentRef.current = EMPTY_CONTENT();
heldItemRef.current = null;
steveLiveRef.current = null;
orbitInitializedRef.current = false;
@@ -764,6 +892,7 @@ export default function ModelPreview({
if (gen !== syncGenRef.current) {
disposeObject3D(built.root);
built.texture.dispose();
+ built.animation?.dispose();
return;
}
@@ -777,6 +906,7 @@ export default function ModelPreview({
steveTexture: null,
potionCanvas: built.canvas,
floatingRoot: null,
+ animation: built.animation,
};
setContentVersion((v) => v + 1);
reportError(null);
@@ -864,7 +994,7 @@ export default function ModelPreview({
}
const mannequinPreview = isMannequinSlot(slot, gun);
- const showFrameChips = flat && isMultiFrameKind(kind) && frameOptions.length > 1;
+ const showFrameChips = (flat || ranged3d) && isMultiFrameKind(kind) && frameOptions.length > 1;
const showShieldMode = kind === "shield" && mannequinPreview;
return (
diff --git a/frontend/app/components/skins/SkinTargetPicker.tsx b/frontend/app/components/skins/SkinTargetPicker.tsx
new file mode 100644
index 00000000..790314f4
--- /dev/null
+++ b/frontend/app/components/skins/SkinTargetPicker.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useState } from "react";
+import {
+ ARMOR_SINGLE_SETS,
+ ARMOR_TYPES,
+ BASE_SET_ENTRIES,
+ BASE_SET_GROUPS,
+ armorTierLabel,
+ baseSetLabel,
+} from "../../../lib/skins/baseSets";
+import { ARMOR_TARGETS, kindsForTarget, sameTarget, type SkinTarget } from "../../../lib/skins/kindAccess";
+import type { SkinKind } from "../../../lib/skins/sizes";
+import { LockIcon, kindStyle } from "./KindPicker";
+
+type Tile = {
+ target: SkinTarget;
+ label: string;
+ detail: string;
+ keywords: string;
+};
+
+type Section = { id: string; label: string; tiles: Tile[] };
+
+function styleSummary(kinds: readonly SkinKind[]): string {
+ const styles = kinds.map((k) => (kindStyle(k).style.startsWith("3D") ? "3D" : "Flat"));
+ return [...new Set(styles)].join(" · ");
+}
+
+const ARMOR_TYPE_SUMMARY = ARMOR_TYPES.map((t) => armorTierLabel(t)).join(" · ");
+
+function buildSections(): Section[] {
+ const metals: Tile[] = ARMOR_TARGETS.filter((t) => t.type === "metal").map((target) => {
+ const metal = target.type === "metal" ? target.metal : "";
+ return {
+ target,
+ label: baseSetLabel(metal),
+ detail: ARMOR_TYPE_SUMMARY,
+ keywords: `${metal} armour armor ${ARMOR_TYPES.join(" ")}`,
+ };
+ });
+ const singles: Tile[] = ARMOR_SINGLE_SETS.map((s) => ({
+ target: { type: "single" as const, set: s.id },
+ label: s.label,
+ detail: s.detail,
+ keywords: `${s.id} ${s.label} ${s.detail} armour armor`,
+ }));
+ const sections: Section[] = [
+ { id: "armor_metal", label: "Armour · metal lines", tiles: metals },
+ { id: "armor_single", label: "Armour · single sets", tiles: singles },
+ ];
+ for (const group of BASE_SET_GROUPS) {
+ sections.push({
+ id: group.id,
+ label: group.label,
+ tiles: BASE_SET_ENTRIES.filter((row) => row.group === group.id).map((row) => ({
+ target: { type: "item" as const, baseSet: row.id },
+ label: row.label,
+ detail: styleSummary(row.kinds),
+ keywords: `${row.id} ${row.label} ${group.label}`,
+ })),
+ });
+ }
+ return sections;
+}
+
+const SECTIONS = buildSections();
+
+type Props = {
+ value: SkinTarget | null;
+ onChange: (target: SkinTarget) => void;
+ disabled?: boolean;
+ /** Rank that unlocks a target the player can't skin at all, else undefined. */
+ lockedBy: (kinds: readonly SkinKind[]) => string | undefined;
+};
+
+/** Step 1: pick the item (or armour line) the skin goes on. */
+export default function SkinTargetPicker({ value, onChange, disabled, lockedBy }: Props) {
+ const [query, setQuery] = useState("");
+ const needle = query.trim().toLowerCase();
+ const visible = SECTIONS.map((section) => ({
+ ...section,
+ tiles: needle
+ ? section.tiles.filter((tile) => `${tile.keywords} ${section.label}`.toLowerCase().includes(needle))
+ : section.tiles,
+ })).filter((section) => section.tiles.length > 0);
+
+ return (
+
+ );
+}
diff --git a/frontend/app/components/skins/UploadForm.test.tsx b/frontend/app/components/skins/UploadForm.test.tsx
new file mode 100644
index 00000000..2b635fb5
--- /dev/null
+++ b/frontend/app/components/skins/UploadForm.test.tsx
@@ -0,0 +1,101 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import UploadForm from "./UploadForm";
+
+vi.mock("../../../lib/skins/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getCatalog: vi.fn(() => new Promise(() => undefined)),
+ createSubmission: vi.fn(),
+ checkSubmissionConflict: vi.fn(),
+}));
+// WebGL previews are not under test here.
+vi.mock("./ModelPreview", () => ({
+ default: () => ,
+}));
+vi.mock("./ArmorPreview", () => ({
+ default: () => ,
+}));
+
+afterEach(cleanup);
+
+function renderForm(skinKinds: string[] | null) {
+ return render( undefined} />);
+}
+
+describe("UploadForm item-first flow", () => {
+ it("asks what is being skinned before anything else", () => {
+ renderForm(["handheld"]);
+ expect(screen.getByRole("heading", { name: /What are you skinning/ })).toBeTruthy();
+ expect(screen.queryByText("Details")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
+ });
+
+ it("offers a wand's flat style and shows 3D locked to Ascended", () => {
+ renderForm(["handheld"]);
+ fireEvent.click(screen.getByRole("radio", { name: /Wands/ }));
+ const styles = screen.getByRole("radiogroup", { name: "Skin style" });
+ const flat = within(styles).getByRole("radio", { name: /Flat/ });
+ const threeD = within(styles).getByRole("radio", { name: /3D model/ });
+ expect(flat.getAttribute("aria-checked")).toBe("true");
+ expect((threeD as HTMLButtonElement).disabled).toBe(true);
+ expect(threeD.textContent).toContain("Ascended");
+ expect(screen.getByLabelText("Texture")).toBeTruthy();
+ expect(screen.getByLabelText("Texture animation (.mcmeta)")).toBeTruthy();
+ });
+
+ it("asks for a texture and five models for a 3D crossbow", () => {
+ renderForm(null);
+ fireEvent.change(screen.getByPlaceholderText(/Search/), {
+ target: { value: "cross" },
+ });
+ fireEvent.click(screen.getByRole("radio", { name: /Crossbows/ }));
+ fireEvent.click(
+ within(screen.getByRole("radiogroup", { name: "Skin style" })).getByRole("radio", { name: /3D model/ }),
+ );
+ for (const label of ["Texture", "Standby model", "Pull 0 model", "Pull 1 model", "Pull 2 model", "Charged model"]) {
+ expect(screen.getByLabelText(label)).toBeTruthy();
+ }
+ expect(screen.queryByLabelText("Standby model animation (.mcmeta)")).toBeNull();
+ expect(screen.getByText(/All files together: 0 bytes of 60 KB/)).toBeTruthy();
+ });
+
+ it("locks items a rank cannot skin at all", () => {
+ renderForm(["armor_set"]);
+ const tile = screen.getByRole("radio", {
+ name: /Battle standards/,
+ }) as HTMLButtonElement;
+ expect(tile.disabled).toBe(true);
+ expect(tile.textContent).toContain("Ascended");
+ });
+
+ it("makes a single armour set one fixed set with no layer animation", () => {
+ renderForm(["armor_set"]);
+ fireEvent.click(screen.getByRole("radio", { name: /Leather/ }));
+ expect(screen.getByText("Leather armour", { selector: "h3" })).toBeTruthy();
+ expect(screen.queryByRole("group", { name: "Set types" })).toBeNull();
+ expect(screen.getByLabelText("Chestplate icon animation (.mcmeta)")).toBeTruthy();
+ expect(screen.queryByLabelText("Layer 1 animation (.mcmeta)")).toBeNull();
+ });
+
+ it("lets a metal line pick each weight", () => {
+ renderForm(["armor_set"]);
+ fireEvent.click(screen.getByRole("radio", { name: /^Steel/ }));
+ const types = screen.getByRole("group", { name: "Set types" });
+ fireEvent.click(within(types).getByRole("button", { name: /Light Steel/ }));
+ fireEvent.click(within(types).getByRole("button", { name: /Heavy Steel/ }));
+ expect(screen.getAllByText(/^(Light|Heavy) Steel$/, { selector: "span" }).length).toBe(2);
+ });
+
+ it("re-picks the style when rank perks change after the item was chosen", () => {
+ const view = render( undefined} />);
+ fireEvent.click(screen.getByRole("radio", { name: /^Steel/ }));
+ expect(screen.getByRole("group", { name: "Set types" })).toBeTruthy();
+ view.rerender( undefined} />);
+ expect(screen.queryByRole("group", { name: "Set types" })).toBeNull();
+ view.rerender( undefined} />);
+ expect(screen.getByRole("group", { name: "Set types" })).toBeTruthy();
+ });
+});
diff --git a/frontend/app/components/skins/UploadForm.tsx b/frontend/app/components/skins/UploadForm.tsx
index e19f7ff5..8634fd68 100644
--- a/frontend/app/components/skins/UploadForm.tsx
+++ b/frontend/app/components/skins/UploadForm.tsx
@@ -2,38 +2,54 @@
import LoadingSkeleton from "@/app/components/shared/LoadingSkeleton";
-import { FormEvent, useEffect, useState } from "react";
+import { FormEvent, useEffect, useState, type ReactNode } from "react";
import { createSubmission, checkSubmissionConflict, getCatalog, SkinsApiError } from "../../../lib/skins/api";
import type { SkinsCatalog } from "../../../lib/skins/catalog";
import { filterStaffCategories } from "../../../lib/skins/catalog";
import { DEV_CATALOG_ENTITLEMENTS, DEV_SESSION_SKIN_KINDS } from "../../../lib/skins/entitlementsDev";
import { setLastSubmissionId } from "../../../lib/skins/session";
import {
+ ARMOR_SINGLE_SETS,
ARMOR_TYPES,
+ RANGED_3D_TOTAL_BYTES,
armorTier,
armorTierLabel,
baseSetLabel,
- baseSetPickerTitle,
- baseSetsForKind,
- defaultBaseSet,
+ kindsForBaseSet,
} from "../../../lib/skins/baseSets";
+import {
+ firstAllowedKind,
+ kindAllowed,
+ kindsForTarget,
+ rankForKind,
+ sameTarget,
+ type SkinTarget,
+} from "../../../lib/skins/kindAccess";
import {
assert3dPairBudgets,
- assertFileSize,
+ assertRanged3dBudget,
+ assertTextureFile,
expectedSizeForField,
fileFieldsForKind,
- isBowFrameKind,
+ formatBytes,
isBookKind,
+ isGunKind,
isLargeTextureKind,
isModel3dKind,
- isGunKind,
+ isModelField,
+ isRanged3dKind,
+ mcmetaAllowed,
+ mcmetaField,
pairBudgetHint,
+ ranged3dTotalBytes,
ARMOR_BODY_FIELDS,
type SkinKind,
} from "../../../lib/skins/sizes";
import { NAME_STYLES, normalizePreviewHex, type NameStyle } from "../../../lib/skins/namePreview";
import FancyCheckbox from "./FancyCheckbox";
+import FileSlot from "./FileSlot";
import KindPicker from "./KindPicker";
+import SkinTargetPicker from "./SkinTargetPicker";
import ModelPreview from "./ModelPreview";
import ArmorPreview from "./ArmorPreview";
import NameColourPicker from "../shared/NameColourPicker";
@@ -49,25 +65,6 @@ function isFlatPreviewKind(kind: SkinKind): boolean {
);
}
-function resolveModelPreviewFiles(
- kind: SkinKind,
- files: Record,
-): { model: File | null; texture: File | null } {
- if (isModel3dKind(kind)) {
- return {
- model: files.model ?? null,
- texture: files.texture ?? null,
- };
- }
- if (isGunKind(kind) || isFlatPreviewKind(kind)) {
- return {
- model: null,
- texture: files.texture ?? null,
- };
- }
- return { model: null, texture: null };
-}
-
type Props = {
sessionToken: string;
/** Staff curated lane — category/scroll required. */
@@ -84,7 +81,7 @@ type Props = {
};
type TierEntry = {
- /** Armour type (light, medium, …); the tier id adds the chosen metal. */
+ /** Armour type (light, medium, …) of the metal line, or the single set id. */
type: string;
/** Display suffix for this set; empty means the default (Light Iron, …). */
alias: string;
@@ -96,57 +93,71 @@ type TierEntry = {
};
const fieldLabel: Record = {
- helmet: "Helmet (16×16)",
- helmet_texture: "Helmet texture (PNG)",
- helmet_model: "Helmet model (JSON)",
- chestplate: "Chestplate (16×16)",
- leggings: "Leggings (16×16)",
- boots: "Boots (16×16)",
- layer_1: "Layer 1 (64×32)",
- layer_2: "Layer 2 (64×32)",
- texture: "Texture (PNG)",
- model: "Model (JSON)",
- carry_model: "Carry model (JSON)",
- reload_model: "Reload model (JSON)",
- aim_model: "Aim model (JSON)",
+ helmet: "Helmet icon",
+ helmet_texture: "Helmet texture",
+ helmet_model: "Helmet model",
+ chestplate: "Chestplate icon",
+ leggings: "Leggings icon",
+ boots: "Boots icon",
+ layer_1: "Layer 1",
+ layer_2: "Layer 2",
+ texture: "Texture",
+ model: "Model",
+ carry_model: "Carry model",
+ reload_model: "Reload model",
+ aim_model: "Aim model",
pull_0: "Pull 0",
pull_1: "Pull 1",
pull_2: "Pull 2",
charged: "Charged",
- unsigned: "Unsigned cover (16×16)",
- signed: "Signed cover (16×16)",
+ pull_0_model: "Pull 0 model",
+ pull_1_model: "Pull 1 model",
+ pull_2_model: "Pull 2 model",
+ charged_model: "Charged model",
+ unsigned: "Unsigned cover",
+ signed: "Signed cover",
};
function slotLabel(kind: SkinKind, field: string): string {
- if (kind === "armor_set") {
- return fieldLabel[field] || field;
- }
- if (isModel3dKind(kind) || isGunKind(kind) || isBookKind(kind)) {
- return fieldLabel[field] || field;
+ if (field === "texture" && (kind === "bow" || kind === "large_bow" || kind === "crossbow")) {
+ return "Standby";
}
- const size = isLargeTextureKind(kind) ? "32×32" : "16×16";
- const base = fieldLabel[field] || field;
- if (field === "texture") {
- return isBowFrameKind(kind) ? `Standby texture (${size})` : `Texture (${size})`;
+ if (field === "model" && isRanged3dKind(kind)) {
+ return "Standby model";
}
- if (isBowFrameKind(kind)) {
- return `${base} (${size})`;
- }
- return `Texture (${size})`;
+ return fieldLabel[field] || field;
+}
+
+function slotHint(kind: SkinKind, field: string): string {
+ if (isModelField(field)) return "Java Block/Item JSON";
+ const size = expectedSizeForField(kind, field);
+ if (!size) return isRanged3dKind(kind) ? "PNG, any size · shared by every model" : "PNG, any size";
+ return `${size.w}×${size.h} PNG`;
}
function acceptForField(field: string): string {
- if (
- field === "model" ||
- field.endsWith("_model") ||
- field === "helmet_model" ||
- field === "carry_model" ||
- field === "reload_model" ||
- field === "aim_model"
- ) {
- return "application/json,.json";
- }
- return "image/png,.png";
+ return isModelField(field) ? "application/json,.json" : "image/png,.png";
+}
+
+function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
+ return (
+
+
+
+ {n}
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function newTier(type: string): TierEntry {
+ return { type, alias: "", helmet3d: false, scroll: "", files: {} };
}
export default function UploadForm({
@@ -160,18 +171,10 @@ export default function UploadForm({
onSubmitted,
}: Props) {
const resolvedKinds = skinKinds != null ? skinKinds : staff ? null : DEV_SESSION_SKIN_KINDS;
- const initialKind = (
- resolvedKinds == null
- ? "armor_set"
- : resolvedKinds.includes("armor_set")
- ? "armor_set"
- : resolvedKinds[0] || "handheld"
- ) as SkinKind;
- const [kind, setKind] = useState(initialKind);
- const [baseSet, setBaseSet] = useState(defaultBaseSet(initialKind));
+ const [target, setTarget] = useState(null);
+ const [kind, setKind] = useState(null);
const canUseArmor3dHelmet = staff || allowArmor3dHelmet;
const [tiers, setTiers] = useState([]);
- const [tierToAdd, setTierToAdd] = useState("");
/** Which added set (by type) the armor mannequin preview shows. */
const [previewArmorTier, setPreviewArmorTier] = useState("");
const [itemName, setItemName] = useState("");
@@ -191,16 +194,6 @@ export default function UploadForm({
signed: string | null;
}>({ unsigned: null, signed: null });
- useEffect(() => {
- setTiers([]);
- setTierToAdd("");
- setError(null);
- setBaseSet(defaultBaseSet(kind));
- setFiles({});
- setCategory("");
- setScroll("");
- }, [kind]);
-
useEffect(() => {
if (canUseArmor3dHelmet) return;
setTiers((prev) => {
@@ -210,6 +203,7 @@ export default function UploadForm({
const files = { ...t.files };
delete files.helmet_model;
delete files.helmet_texture;
+ delete files[mcmetaField("helmet_texture")];
return { ...t, helmet3d: false, files };
});
});
@@ -245,6 +239,7 @@ export default function UploadForm({
}, []);
const catalogDefaults = catalog?.entitlements?.defaults;
+ const entitlements = catalog?.entitlements?.groups?.length ? catalog.entitlements : DEV_CATALOG_ENTITLEMENTS;
const resolvedColourStops =
nameColourStops !== undefined ? nameColourStops : (catalogDefaults?.name_colour_stops ?? 0);
const resolvedPairBytes =
@@ -257,27 +252,33 @@ export default function UploadForm({
: 0;
const pairHint = pairBudgetHint(resolvedPairBytes);
- const fileFields = fileFieldsForKind(kind);
- const baseOptions = baseSetsForKind(kind);
+ /** Rank that unlocks a kind, or undefined when the player can already use it. */
+ const lockedByKind = (k: SkinKind): string | undefined =>
+ kindAllowed(k, resolvedKinds) ? undefined : (rankForKind(entitlements, k) ?? "Locked");
+ const lockedByKinds = (kinds: readonly SkinKind[]): string | undefined =>
+ kinds.length === 0 || kinds.some((k) => kindAllowed(k, resolvedKinds)) ? undefined : lockedByKind(kinds[0]!);
+
const isArmor = kind === "armor_set";
- const staffCategories = catalog ? filterStaffCategories(catalog.categories, kind) : [];
+ const isSingleSet = target?.type === "single";
+ const metal = target?.type === "metal" ? target.metal : "";
+ const lineLabel =
+ target?.type === "metal"
+ ? baseSetLabel(target.metal)
+ : target?.type === "single"
+ ? (ARMOR_SINGLE_SETS.find((s) => s.id === target.set)?.label ?? target.set)
+ : "";
+ const baseSet = target?.type === "item" ? target.baseSet : "";
+ const fileFields = kind ? fileFieldsForKind(kind) : [];
+ const staffCategories = catalog && kind ? filterStaffCategories(catalog.categories, kind) : [];
const staffScrolls = catalog?.scrolls ?? [];
- const previewFiles = resolveModelPreviewFiles(kind, files);
- /** Armour's base-set picker is the metal; every set in the line uses it. */
- const tierId = (entry: TierEntry) => armorTier(entry.type, baseSet);
+ const tierId = (entry: TierEntry) => (isSingleSet ? entry.type : armorTier(entry.type, metal));
const tierLabel = (entry: TierEntry) => armorTierLabel(tierId(entry));
- const remainingTiers: string[] = ARMOR_TYPES.filter((t) => !tiers.some((entry) => entry.type === t));
- const effectiveTierToAdd = remainingTiers.includes(tierToAdd) ? tierToAdd : (remainingTiers[0] ?? "");
const previewTierEntry = tiers.find((e) => e.type === previewArmorTier) ?? tiers[0] ?? null;
useEffect(() => {
- if (!isArmor) {
- setPreviewArmorTier("");
- return;
- }
- if (tiers.length === 0) {
+ if (!isArmor || tiers.length === 0) {
setPreviewArmorTier("");
return;
}
@@ -286,71 +287,96 @@ export default function UploadForm({
}
}, [isArmor, tiers, previewArmorTier]);
- function setFile(field: string, file: File | null) {
- setFiles((prev) => ({ ...prev, [field]: file }));
+ function resetUploads() {
+ setFiles({});
+ setError(null);
+ setCategory("");
+ setScroll("");
}
- /** Ignore cancel / empty picker so an existing selection is kept. */
- function onPickedFile(fileList: FileList | null, apply: (file: File) => void) {
- const file = fileList?.[0];
- if (file) apply(file);
+ // Rank perks can arrive after a tile was picked: re-pick the style if the chosen one is
+ // no longer allowed, or one became allowed (adjusting state during render, not in an effect).
+ const kindsKey = resolvedKinds == null ? "*" : resolvedKinds.join(",");
+ const [seenKindsKey, setSeenKindsKey] = useState(kindsKey);
+ if (kindsKey !== seenKindsKey) {
+ setSeenKindsKey(kindsKey);
+ if (target && (!kind || !kindAllowed(kind, resolvedKinds))) {
+ const next = firstAllowedKind(target, resolvedKinds);
+ if (next !== kind) {
+ setKind(next);
+ resetUploads();
+ }
+ }
}
- function addTier(type: string) {
- if (!type || tiers.some((entry) => entry.type === type)) return;
- if (tiers.length >= MAX_SETS) return;
- setTiers((prev) => [
- ...prev,
- {
- type,
- alias: "",
- helmet3d: false,
- scroll: "",
- files: {},
- },
- ]);
- setTierToAdd("");
- setError(null);
+ function selectTarget(next: SkinTarget) {
+ // Re-clicking the chosen tile only matters when it has no usable style yet.
+ if (sameTarget(next, target) && kind) return;
+ setTarget(next);
+ setKind(firstAllowedKind(next, resolvedKinds));
+ setTiers(next.type === "single" ? [newTier(next.set)] : []);
+ resetUploads();
+ }
+
+ function selectKind(next: SkinKind) {
+ if (next === kind) return;
+ setKind(next);
+ resetUploads();
}
- function removeTier(type: string) {
- setTiers((prev) => prev.filter((entry) => entry.type !== type));
+ function setFile(field: string, file: File | null) {
+ setFiles((prev) => ({ ...prev, [field]: file }));
}
- function setTierAlias(type: string, alias: string) {
- setTiers((prev) => prev.map((entry) => (entry.type === type ? { ...entry, alias } : entry)));
+ function toggleType(type: string) {
+ setError(null);
+ setTiers((prev) => {
+ if (prev.some((entry) => entry.type === type)) return prev.filter((entry) => entry.type !== type);
+ if (prev.length >= MAX_SETS) return prev;
+ // Keep sets in Light → Mage order.
+ return [...prev, newTier(type)].sort((a, b) => ARMOR_TYPES.indexOf(a.type) - ARMOR_TYPES.indexOf(b.type));
+ });
}
- function setTierScroll(type: string, scrollId: string) {
- setTiers((prev) => prev.map((entry) => (entry.type === type ? { ...entry, scroll: scrollId } : entry)));
+ function updateTier(type: string, change: (entry: TierEntry) => TierEntry) {
+ setTiers((prev) => prev.map((entry) => (entry.type === type ? change(entry) : entry)));
}
function setTierHelmet3d(type: string, helmet3d: boolean) {
- setTiers((prev) =>
- prev.map((entry) => {
- if (entry.type !== type) return entry;
- const files = { ...entry.files };
- if (helmet3d) {
- delete files.helmet;
- } else {
- delete files.helmet_model;
- delete files.helmet_texture;
- }
- return { ...entry, helmet3d, files };
- }),
- );
+ updateTier(type, (entry) => {
+ const files = { ...entry.files };
+ if (helmet3d) {
+ delete files.helmet;
+ delete files[mcmetaField("helmet")];
+ } else {
+ delete files.helmet_model;
+ delete files.helmet_texture;
+ delete files[mcmetaField("helmet_texture")];
+ }
+ return { ...entry, helmet3d, files };
+ });
}
function setTierFile(type: string, field: string, file: File | null) {
- setTiers((prev) =>
- prev.map((entry) => (entry.type === type ? { ...entry, files: { ...entry.files, [field]: file } } : entry)),
- );
+ updateTier(type, (entry) => ({
+ ...entry,
+ files: { ...entry.files, [field]: file },
+ }));
}
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
+ if (!target || !kind) {
+ setError(target ? "Choose a style" : "Choose what you're skinning");
+ return;
+ }
+ if (!kindAllowed(kind, resolvedKinds)) {
+ setError(`That style unlocks at ${lockedByKind(kind)}.`);
+ return;
+ }
+
const name = itemName.trim();
if (!name) {
setError("Enter an item name.");
@@ -380,15 +406,13 @@ export default function UploadForm({
return;
}
}
- }
-
- if (!baseSet || !baseOptions.includes(baseSet)) {
- setError(`Choose a ${baseSetPickerTitle(kind).toLowerCase()}`);
+ } else if (!baseSet || !kindsForBaseSet(baseSet).includes(kind)) {
+ setError("Choose an item type");
return;
}
if (isArmor && tiers.length < 1) {
- setError("Add at least 1 armour set");
+ setError("Pick at least 1 armour set");
return;
}
@@ -426,55 +450,51 @@ export default function UploadForm({
const uploadFiles: Record = {};
- if (isArmor) {
- try {
+ /** Check one slot and add it (and its optional animation) to the upload. */
+ async function addSlot(
+ key: string,
+ field: string,
+ file: File | null | undefined,
+ mcmeta: File | null | undefined,
+ label: string,
+ ) {
+ if (!file) throw new Error(`Missing ${label}`);
+ if (isModelField(field)) {
+ await assertVanillaJavaBlockModelFile(file);
+ } else {
+ const animation = mcmetaAllowed(field) ? mcmeta : null;
+ await assertTextureFile(file, expectedSizeForField(kind!, field), animation, label);
+ if (animation) uploadFiles[mcmetaField(key)] = animation;
+ }
+ uploadFiles[key] = file;
+ }
+
+ try {
+ if (isArmor) {
for (const entry of tiers) {
- const setLabel = tierLabel(entry);
const helmetFields = entry.helmet3d ? (["helmet_model", "helmet_texture"] as const) : (["helmet"] as const);
- const fields = [...helmetFields, ...ARMOR_BODY_FIELDS];
- for (const field of fields) {
- const file = entry.files[field];
- const label = fieldLabel[field] || field;
- if (!file) {
- throw new Error(`Missing ${label} for the ${setLabel} set`);
- }
- const expected = expectedSizeForField(kind, field);
- if (expected) {
- await assertFileSize(file, expected, `${setLabel} ${label}`);
- }
- if (field === "helmet_model") {
- await assertVanillaJavaBlockModelFile(file);
- }
- uploadFiles[`${tierId(entry)}_${field}`] = file;
+ for (const field of [...helmetFields, ...ARMOR_BODY_FIELDS]) {
+ await addSlot(
+ `${tierId(entry)}_${field}`,
+ field,
+ entry.files[field],
+ entry.files[mcmetaField(field)],
+ `${tierLabel(entry)} ${fieldLabel[field] || field}`,
+ );
}
}
const h3d = tiers.filter((t) => t.helmet3d).map(tierId);
assert3dPairBudgets(kind, uploadFiles, resolvedPairBytes, h3d);
- } catch (err) {
- setError(err instanceof Error ? err.message : "File validation failed");
- return;
- }
- } else {
- try {
+ } else {
for (const field of fileFields) {
- const file = files[field];
- if (!file) {
- throw new Error(`Missing file: ${fieldLabel[field] || field}`);
- }
- const expected = expectedSizeForField(kind, field);
- if (expected) {
- await assertFileSize(file, expected, fieldLabel[field] || field);
- }
- if (field === "model" || field.endsWith("_model")) {
- await assertVanillaJavaBlockModelFile(file);
- }
- uploadFiles[field] = file;
+ await addSlot(field, field, files[field], files[mcmetaField(field)], slotLabel(kind, field));
}
assert3dPairBudgets(kind, uploadFiles, resolvedPairBytes);
- } catch (err) {
- setError(err instanceof Error ? err.message : "File validation failed");
- return;
+ assertRanged3dBudget(kind, uploadFiles);
}
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "File validation failed");
+ return;
}
if (colours.length > 0) {
@@ -539,6 +559,40 @@ export default function UploadForm({
setStyles((prev) => (prev.includes(style) ? prev.filter((s) => s !== style) : [...prev, style]));
}
+ /** Upload card for one field; textures get an optional animation slot. */
+ function slot(
+ field: string,
+ label: string,
+ picked: Record,
+ pick: (field: string, file: File | null) => void,
+ ) {
+ return (
+ pick(field, file)}
+ animation={
+ mcmetaAllowed(field)
+ ? {
+ file: picked[mcmetaField(field)],
+ onPick: (file) => pick(mcmetaField(field), file),
+ onClear: () => pick(mcmetaField(field), null),
+ }
+ : undefined
+ }
+ />
+ );
+ }
+
+ const ranged3d = kind ? isRanged3dKind(kind) : false;
+ const rangedTotal = ranged3d ? ranged3dTotalBytes(files) : 0;
+ const styleKinds = target?.type === "item" ? kindsForTarget(target) : [];
+ const detailsStep = target?.type === "item" ? 3 : 2;
+
return (
{loading ? (
@@ -555,458 +609,487 @@ export default function UploadForm({
) : null}