feat: add new fields, validate.py, GitHub Action, updated README

This commit is contained in:
Tim Jochen Kicker
2026-02-22 19:07:11 +01:00
parent bf70965548
commit cdf280acbf
5 changed files with 267 additions and 60 deletions

20
.github/workflows/validate.yml vendored Normal file
View File

@@ -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

119
README.md
View File

@@ -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 1. Fork this repository
2. Edit `repo.json` with your repo name and author 2. Edit `repo.json` (name, author)
3. Add your games under `games/[game-id]/game.json` 3. Add your game folders under `games/`
4. Host your mod ZIPs anywhere (GitHub Releases, direct links, etc.) 4. Add your mod ZIPs as GitHub Release assets
5. Add your raw `repo.json` URL to the Mod Store app 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 ```json
{ {
"repos": [ "formatVersion": 1,
"https://raw.githubusercontent.com/YOUR-USERNAME/YOUR-REPO/main/repo.json" "name": "My Mod Repo",
"description": "...",
"author": "your-name",
"version": "1",
"games": [
{ "path": "games/mario-kart-8/game.json" }
] ]
} }
``` ```
## Folder structure ## game.json
``` ```json
repo/ {
├── repo.json ← Repo index (required) "name": "Mario Kart 8",
└── games/ "icon": "https://...",
└── [game-id]/ "titleIds": [
├── game.json ← Game info + mod list "000500001010EB00",
└── assets/ "000500001010EC00",
└── cover.png ← Game cover image (optional) "000500001010ED00"
],
"mods": [ ... ]
}
``` ```
## Mod types ## Mod Fields
- `mod`: A single standalone mod
- `modpack`: A bundle of multiple mods installed together
## game.json fields
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `name` | yes | Display name of the game | | `id` | ✅ | Unique ID, only `a-z 0-9 - _` |
| `cover` | no | Relative path to cover image | | `name` | | Display name |
| `title_ids` | yes | Array of Wii U Title IDs (all regions) | | `author` | | Author name |
| `mods` | yes | Array of mod objects | | `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 | ```bash
|-------|----------|-------------| python3 validate.py # check structure
| `id` | yes | Unique identifier (no spaces) | python3 validate.py --check-urls # also verify download URLs
| `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 |
## 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 ZIP Structure
- 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 Your ZIP must extract to a folder matching the SDCafiine layout:
```
your-mod.zip
└── content/
└── ...game files...
```
CupStore installs to: `SD:/wiiu/sdcafiine/<titleId>/<modId>/`

View File

@@ -1,7 +1,7 @@
{ {
"name": "Mario Kart 8", "name": "Mario Kart 8",
"cover": "assets/cover.png", "icon": "https://cdn2.steamgriddb.com/icon_thumb/ac1ad983e08ad3304a97e147f522747e.png",
"title_ids": [ "titleIds": [
"000500001010EB00", "000500001010EB00",
"000500001010EC00", "000500001010EC00",
"000500001010ED00" "000500001010ED00"
@@ -9,17 +9,23 @@
"mods": [ "mods": [
{ {
"id": "dummydum-mod", "id": "dummydum-mod",
"name": "dummy mod for testing", "name": "Dummy Mod",
"type": "mod", "type": "mod",
"version": "1.0.0", "version": "1.0.0",
"author": "ExampleUser", "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", "thumbnail": "https://picsum.photos/seed/mk8mod1/400/225",
"screenshots": [ "screenshots": [
"https://picsum.photos/seed/mk8mod1a/800/450", "https://picsum.photos/seed/mk8mod1a/800/450",
"https://picsum.photos/seed/mk8mod1b/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", "id": "custom-music-pack",
@@ -33,7 +39,12 @@
"https://picsum.photos/seed/mk8musica/800/450", "https://picsum.photos/seed/mk8musica/800/450",
"https://picsum.photos/seed/mk8musicb/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", "id": "retro-track-pack",
@@ -48,7 +59,12 @@
"https://picsum.photos/seed/mk8retrob/800/450", "https://picsum.photos/seed/mk8retrob/800/450",
"https://picsum.photos/seed/mk8retroc/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", "id": "ultimate-bundle",
@@ -65,7 +81,10 @@
"custom-music-pack", "custom-music-pack",
"retro-track-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
} }
] ]
} }

View File

@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"name": "Wii U Mod Store Template Repo", "name": "CupStore Template Repo",
"description": "Official template repository for the Wii U Mod Store. Fork this to host your own mods.", "description": "Official template repository for CupStore. Fork this to host your own mods.",
"author": "your-name", "author": "your-name",
"version": "1", "version": "1",
"games": [ "games": [

149
validate.py Normal file
View File

@@ -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)