Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus the Kotlin companion mod (cobblesurvival) and its built-in data pack. - Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and export tooling, Prism instance sync, test server with smoke checks. - Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26), Adrian's biome list, no natural ore, structures without ore or vanilla mobs, no Nether portals, world template builder and world report. - Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap fixes), Revival Charm, expensive Healing Machine, Lootr refreshing loot, in-game self-tests on Minecraft's GameTest framework. - Phase 3 so far: 14 zones with their own spawn pools and level bands (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition), Cold Sweat heat and cold as gates with boss-drop insulation recipes, spawn survey and hazard self-tests in tools/quick_check.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.7 KiB
Python
Executable File
143 lines
5.7 KiB
Python
Executable File
#!/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()
|