#!/usr/bin/env python3 """Give every packwiz mod both a Modrinth and a CurseForge source. packwiz only records the platform a mod was added from. Exporting to the other platform then copies the jar into overrides/, which would bundle a third-party jar. This script finds the same file on the other platform and verifies it by SHA-1 before linking it: * Modrinth -> CurseForge: CurseForge project found via cfwidget (slug from crosslink.json or the Modrinth slug), file matched by filename, then both files are downloaded and compared: same SHA-1, or (for jars re-built with other timestamps) the same entry names and CRC32s. * CurseForge -> Modrinth: exact lookup of the SHA-1 on Modrinth. Mods with no counterpart are reported; build.py keeps them out of the export for the platform that lacks them instead of bundling them. Usage: tools/crosslink.py [pack_dir] (then run `packwiz refresh`) """ import io import json import sys import tomllib import urllib.error import urllib.parse import urllib.request import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OVERRIDES = json.loads((ROOT / "tools" / "crosslink.json").read_text()) UA = {"User-Agent": "cobblemon-survival-pack-tools/0.1 (packwiz crosslink)"} def get_json(url): req = urllib.request.Request(url, headers=UA) try: with urllib.request.urlopen(req, timeout=60) as r: return json.load(r) except urllib.error.HTTPError as e: if e.code == 404: return None raise def download(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=300) as r: return r.read() def same_file(a, b): """'sha1' if byte-identical, 'content' if the jars hold identical entries. The manifest is ignored: authors who upload to each platform from a separate build get a different Implementation-Timestamp and nothing else. """ if a == b: return "sha1" try: entries = [sorted((i.filename, i.CRC) for i in zipfile.ZipFile(io.BytesIO(x)).infolist() if i.filename != "META-INF/MANIFEST.MF") for x in (a, b)] except zipfile.BadZipFile: return None return "content" if entries[0] == entries[1] else None def forgecdn_url(file_id, filename): return "https://edge.forgecdn.net/files/{}/{}/{}".format( file_id // 1000, file_id % 1000, urllib.parse.quote(filename)) def link_curseforge(path, meta, override): mr = meta["update"]["modrinth"] version = get_json(f"https://api.modrinth.com/v2/version/{mr['version']}") project = get_json(f"https://api.modrinth.com/v2/project/{mr['mod-id']}") file = next(f for f in version["files"] if f["filename"] == meta["filename"]) slug = override.get("curseforge_slug", project["slug"]) cf = get_json(f"https://api.cfwidget.com/minecraft/mc-mods/{slug}") if not cf or "files" not in cf: return f"no CurseForge project for slug '{slug}'" candidates = [f for f in cf["files"] if f["name"] == meta["filename"]] if not candidates: return f"CurseForge project {cf['id']} has no file named {meta['filename']}" mr_bytes = download(file["url"]) for cand in candidates: match = same_file(mr_bytes, download(forgecdn_url(cand["id"], cand["name"]))) if not match and override.get("trust_same_filename") and len(candidates) == 1: match = f"filename, trusted: {override['trust_same_filename']}" if match: text = path.read_text().rstrip() + ( f"\n\n[update.curseforge]\nfile-id = {cand['id']}\nproject-id = {cf['id']}\n") path.write_text(text) return f"linked CurseForge {cf['id']}/{cand['id']} (same {match})" return "CurseForge file found by name but its contents differ" def link_modrinth(path, meta): sha1 = meta["download"]["hash"] if meta["download"]["hash-format"] == "sha1" else None if not sha1: return "no SHA-1 in metadata" version = get_json(f"https://api.modrinth.com/v2/version_file/{sha1}?algorithm=sha1") if not version: return "not on Modrinth" file = next(f for f in version["files"] if f["hashes"]["sha1"] == sha1) cf = meta["update"]["curseforge"] path.write_text( f'name = "{meta["name"]}"\n' f'filename = "{meta["filename"]}"\n' f'side = "{meta.get("side", "both")}"\n\n' f'[download]\nurl = "{file["url"]}"\nhash-format = "sha1"\nhash = "{sha1}"\n\n' f'[update]\n[update.curseforge]\nfile-id = {cf["file-id"]}\nproject-id = {cf["project-id"]}\n\n' f'[update.modrinth]\nmod-id = "{version["project_id"]}"\nversion = "{version["id"]}"\n') return f"linked Modrinth {version['project_id']}/{version['id']}" def main(): pack = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "pack" missing = [] for path in sorted((pack / "mods").glob("*.pw.toml")): meta = tomllib.loads(path.read_text()) update = meta.get("update", {}) override = OVERRIDES.get(path.name.removesuffix(".pw.toml"), {}) if "modrinth" in update and "curseforge" in update: status = "already linked" elif override.get("skip"): status = f"skipped: {override['skip']}" elif "modrinth" in update: status = link_curseforge(path, meta, override) else: status = link_modrinth(path, meta) if not status.startswith(("linked", "already")): missing.append(path.name) print(f"{path.name:45} {status}") if missing: print("\nSingle-platform mods (kept out of the other platform's export):") print("\n".join(f" {m}" for m in missing)) if __name__ == "__main__": main()