From cdf280acbf35ed51874ba7be68d818647c8be50c Mon Sep 17 00:00:00 2001 From: Tim Jochen Kicker Date: Sun, 22 Feb 2026 19:07:11 +0100 Subject: [PATCH] feat: add new fields, validate.py, GitHub Action, updated README --- .github/workflows/validate.yml | 20 +++++ README.md | 119 +++++++++++++++----------- games/mario-kart-8/game.json | 35 ++++++-- repo.json | 4 +- validate.py | 149 +++++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 validate.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..9167139 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,20 @@ +name: Validate Repo + +on: + pull_request: + branches: [main] + push: + branches: [main, dev] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate repo structure + run: python3 validate.py + + - name: Validate repo structure + check URLs + run: python3 validate.py --check-urls + continue-on-error: true # URL checks are best-effort diff --git a/README.md b/README.md index 1ed606c..7303527 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,94 @@ -# Wii U Mod Store: Repository Template +# CupStore Repo Template -This is the official template for hosting mods on the **Wii U Mod Store** app. +This is the official template for hosting mods on [CupStore](https://github.com/timkicker/cupstore) — a mod manager for the Wii U. -## How to use +## Quick Start 1. Fork this repository -2. Edit `repo.json` with your repo name and author -3. Add your games under `games/[game-id]/game.json` -4. Host your mod ZIPs anywhere (GitHub Releases, direct links, etc.) -5. Add your raw `repo.json` URL to the Mod Store app +2. Edit `repo.json` (name, author) +3. Add your game folders under `games/` +4. Add your mod ZIPs as GitHub Release assets +5. Share your `repo.json` URL with users -## Repository URL format +## Structure -After forking, your repo index URL will look like: ``` -https://raw.githubusercontent.com/YOUR-USERNAME/YOUR-REPO/main/repo.json +repo.json ← repo index +games/ + mario-kart-8/ + game.json ← game + mod definitions + your-game/ + game.json ``` -Add this to `sd:/wiiu/apps/modstore/config.json` on your Wii U SD card: +## repo.json + ```json { - "repos": [ - "https://raw.githubusercontent.com/YOUR-USERNAME/YOUR-REPO/main/repo.json" + "formatVersion": 1, + "name": "My Mod Repo", + "description": "...", + "author": "your-name", + "version": "1", + "games": [ + { "path": "games/mario-kart-8/game.json" } ] } ``` -## Folder structure +## game.json -``` -repo/ -├── repo.json ← Repo index (required) -└── games/ - └── [game-id]/ - ├── game.json ← Game info + mod list - └── assets/ - └── cover.png ← Game cover image (optional) +```json +{ + "name": "Mario Kart 8", + "icon": "https://...", + "titleIds": [ + "000500001010EB00", + "000500001010EC00", + "000500001010ED00" + ], + "mods": [ ... ] +} ``` -## Mod types - -- `mod`: A single standalone mod -- `modpack`: A bundle of multiple mods installed together - -## game.json fields +## Mod Fields | Field | Required | Description | |-------|----------|-------------| -| `name` | yes | Display name of the game | -| `cover` | no | Relative path to cover image | -| `title_ids` | yes | Array of Wii U Title IDs (all regions) | -| `mods` | yes | Array of mod objects | +| `id` | ✅ | Unique ID, only `a-z 0-9 - _` | +| `name` | ✅ | Display name | +| `author` | ✅ | Author name | +| `version` | ✅ | Version string e.g. `1.0.0` | +| `description` | ✅ | Short description | +| `download` | ✅ | Direct URL to `.zip` file | +| `type` | ✅ | `"mod"` or `"modpack"` | +| `thumbnail` | ☑️ | Preview image URL (400x225) | +| `screenshots` | ☑️ | List of screenshot URLs (800x450) | +| `includes` | ☑️ | For modpacks: list of mod IDs | +| `releaseDate` | ☑️ | e.g. `"2024-03-15"` | +| `license` | ☑️ | e.g. `"CC BY-NC"` | +| `tags` | ☑️ | e.g. `["skin", "music", "course"]` | +| `fileSize` | ☑️ | ZIP size in bytes | +| `requirements` | ☑️ | List of required mods/patches | +| `changelog` | ☑️ | Free text changelog | -## Mod fields +## Validation -| Field | Required | Description | -|-------|----------|-------------| -| `id` | yes | Unique identifier (no spaces) | -| `name` | yes | Display name | -| `type` | yes | `mod` or `modpack` | -| `version` | yes | Semantic version string | -| `author` | yes | Author name | -| `description` | yes | Description text | -| `thumbnail` | no | URL to thumbnail image (400x225 recommended) | -| `screenshots` | no | Array of screenshot URLs | -| `download` | yes | Direct URL to ZIP file | -| `modpack_path` | yes | SDCafiine path (`content/` or `aoc/`) | -| `includes` | modpack only | Array of mod IDs included in bundle | +```bash +python3 validate.py # check structure +python3 validate.py --check-urls # also verify download URLs +``` -## Notes +Validation runs automatically on every PR via GitHub Actions. -- Title IDs can be found at [wiiubrew.org/wiki/Title_database](https://wiiubrew.org/wiki/Title_database) -- Mod ZIPs must match the SDCafiine folder structure (`content/` at root of ZIP) -- The app does **not** verify mod safety! only distribute mods you trust +## Mod ZIP Structure + +Your ZIP must extract to a folder matching the SDCafiine layout: + +``` +your-mod.zip +└── content/ + └── ...game files... +``` + +CupStore installs to: `SD:/wiiu/sdcafiine///` diff --git a/games/mario-kart-8/game.json b/games/mario-kart-8/game.json index 368c393..40c504d 100644 --- a/games/mario-kart-8/game.json +++ b/games/mario-kart-8/game.json @@ -1,7 +1,7 @@ { "name": "Mario Kart 8", - "cover": "assets/cover.png", - "title_ids": [ + "icon": "https://cdn2.steamgriddb.com/icon_thumb/ac1ad983e08ad3304a97e147f522747e.png", + "titleIds": [ "000500001010EB00", "000500001010EC00", "000500001010ED00" @@ -9,17 +9,23 @@ "mods": [ { "id": "dummydum-mod", - "name": "dummy mod for testing", + "name": "Dummy Mod", "type": "mod", "version": "1.0.0", "author": "ExampleUser", - "description": "Yeaaah this does some pretty crazy stuff yeaa", + "description": "A simple example mod that demonstrates the basic structure of a CupStore mod entry.", "thumbnail": "https://picsum.photos/seed/mk8mod1/400/225", "screenshots": [ "https://picsum.photos/seed/mk8mod1a/800/450", "https://picsum.photos/seed/mk8mod1b/800/450" ], - "download": "https://github.com/timkicker/modmanager-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip" + "download": "https://github.com/timkicker/cupstore-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip", + "releaseDate": "2024-01-15", + "license": "CC BY-NC", + "tags": ["skin"], + "fileSize": 204800, + "requirements": [], + "changelog": "1.0.0 - Initial release" }, { "id": "custom-music-pack", @@ -33,7 +39,12 @@ "https://picsum.photos/seed/mk8musica/800/450", "https://picsum.photos/seed/mk8musicb/800/450" ], - "download": "https://github.com/timkicker/modmanager-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip" + "download": "https://github.com/timkicker/cupstore-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip", + "releaseDate": "2024-03-10", + "license": "CC BY-NC-SA", + "tags": ["music"], + "fileSize": 52428800, + "changelog": "2.1.0 - Added 10 new tracks\n2.0.0 - Full soundtrack replacement\n1.0.0 - Initial release" }, { "id": "retro-track-pack", @@ -48,7 +59,12 @@ "https://picsum.photos/seed/mk8retrob/800/450", "https://picsum.photos/seed/mk8retroc/800/450" ], - "download": "https://github.com/timkicker/modmanager-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip" + "download": "https://github.com/timkicker/cupstore-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip", + "releaseDate": "2024-02-20", + "license": "CC BY", + "tags": ["course", "texture"], + "fileSize": 10485760, + "changelog": "1.3.2 - Fixed UV mapping on Koopa Troopa Beach\n1.3.0 - Added Rainbow Road retro style\n1.0.0 - Initial release" }, { "id": "ultimate-bundle", @@ -65,7 +81,10 @@ "custom-music-pack", "retro-track-pack" ], - "download": "https://github.com/timkicker/modmanager-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip" + "download": "https://github.com/timkicker/cupstore-repo-template/releases/download/dummy-testing/dummy-mod-mk8.zip", + "releaseDate": "2024-04-01", + "tags": ["music", "course", "texture"], + "fileSize": 62914560 } ] } diff --git a/repo.json b/repo.json index a639ef0..588b9eb 100644 --- a/repo.json +++ b/repo.json @@ -1,7 +1,7 @@ { "formatVersion": 1, - "name": "Wii U Mod Store Template Repo", - "description": "Official template repository for the Wii U Mod Store. Fork this to host your own mods.", + "name": "CupStore Template Repo", + "description": "Official template repository for CupStore. Fork this to host your own mods.", "author": "your-name", "version": "1", "games": [ diff --git a/validate.py b/validate.py new file mode 100644 index 0000000..e157eb6 --- /dev/null +++ b/validate.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +CupStore repo validation script. +Usage: python3 validate.py [--check-urls] +""" +import json +import sys +import re +import os +import argparse +import urllib.request + +REQUIRED_MOD_FIELDS = ["id", "name", "author", "version", "description", "download", "type"] +REQUIRED_GAME_FIELDS = ["name", "titleIds"] +VALID_TYPES = ["mod", "modpack"] +ID_PATTERN = re.compile(r'^[a-z0-9\-_]+$') +URL_PATTERN = re.compile(r'^https?://.+\..+') + +errors = [] +warnings = [] + +def err(msg): errors.append(msg) +def warn(msg): warnings.append(msg) + +def validate_url(url, field, context): + if not URL_PATTERN.match(url): + err(f"{context}: {field} is not a valid URL: {url!r}") + return False + return True + +def check_url_reachable(url, context): + try: + req = urllib.request.Request(url, method='HEAD') + urllib.request.urlopen(req, timeout=10) + except Exception as e: + warn(f"{context}: URL not reachable: {url} ({e})") + +def validate_mod(mod, game_name, seen_ids, check_urls): + ctx = f"Game '{game_name}' / Mod '{mod.get('id', '?')}'" + + # Required fields + for f in REQUIRED_MOD_FIELDS: + if f not in mod: + err(f"{ctx}: missing required field '{f}'") + + mod_id = mod.get("id", "") + + # ID format + if mod_id and not ID_PATTERN.match(mod_id): + err(f"{ctx}: id '{mod_id}' contains invalid characters (only a-z, 0-9, - _ allowed)") + + # ID uniqueness (global) + if mod_id in seen_ids: + err(f"{ctx}: duplicate id '{mod_id}' (also in '{seen_ids[mod_id]}')") + else: + seen_ids[mod_id] = game_name + + # Type + if mod.get("type") not in VALID_TYPES: + err(f"{ctx}: type must be one of {VALID_TYPES}, got {mod.get('type')!r}") + + # URLs + if "download" in mod: validate_url(mod["download"], "download", ctx) + if "thumbnail" in mod: validate_url(mod["thumbnail"], "thumbnail", ctx) + for i, s in enumerate(mod.get("screenshots", [])): + validate_url(s, f"screenshots[{i}]", ctx) + + if check_urls: + if "download" in mod: check_url_reachable(mod["download"], ctx) + + # Optional field types + if "fileSize" in mod and not isinstance(mod["fileSize"], int): + err(f"{ctx}: fileSize must be an integer (bytes)") + if "tags" in mod and not isinstance(mod["tags"], list): + err(f"{ctx}: tags must be a list of strings") + if "requirements" in mod and not isinstance(mod["requirements"], list): + err(f"{ctx}: requirements must be a list of strings") + +def validate_game(game_data, game_path, seen_ids, check_urls): + name = game_data.get("name", game_path) + + # titleIds (support both camelCase and snake_case) + if "titleIds" not in game_data and "title_ids" not in game_data: + err(f"Game '{name}': missing 'titleIds'") + else: + tids = game_data.get("titleIds", game_data.get("title_ids", [])) + if not tids: + err(f"Game '{name}': titleIds is empty") + + if "mods" not in game_data or not game_data["mods"]: + warn(f"Game '{name}': no mods defined") + return + + for mod in game_data["mods"]: + validate_mod(mod, name, seen_ids, check_urls) + +def main(): + parser = argparse.ArgumentParser(description="Validate a CupStore repo") + parser.add_argument("--check-urls", action="store_true", help="Check if download URLs are reachable") + parser.add_argument("--repo", default="repo.json", help="Path to repo.json") + args = parser.parse_args() + + if not os.path.exists(args.repo): + print(f"ERROR: {args.repo} not found") + sys.exit(1) + + with open(args.repo) as f: + try: + repo = json.load(f) + except json.JSONDecodeError as e: + print(f"ERROR: repo.json is invalid JSON: {e}") + sys.exit(1) + + # Repo-level fields + for f in ["name", "formatVersion", "games"]: + if f not in repo: + err(f"repo.json: missing required field '{f}'") + + seen_ids = {} + + for entry in repo.get("games", []): + if "path" not in entry: + err(f"repo.json: game entry missing 'path'") + continue + game_path = entry["path"] + if not os.path.exists(game_path): + err(f"repo.json: game file not found: {game_path!r}") + continue + with open(game_path) as f: + try: + game_data = json.load(f) + except json.JSONDecodeError as e: + err(f"{game_path}: invalid JSON: {e}") + continue + validate_game(game_data, game_path, seen_ids, args.check_urls) + + # Report + if warnings: + print(f"\n⚠ {len(warnings)} warning(s):") + for w in warnings: print(f" {w}") + + if errors: + print(f"\n✗ {len(errors)} error(s):") + for e in errors: print(f" {e}") + sys.exit(1) + else: + print(f"✓ Repo is valid ({len(seen_ids)} mods across {len(repo.get('games', []))} game(s))") + if warnings: + sys.exit(0)