build/...-private.mrpack lists every mod by download link, CurseForge-only ones included, with the credits and non-affiliation notice inside and no third-party jar. tools/check_private_pack.py downloads all of it and checks the hashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
10 KiB
Python
228 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the companion mod and export the pack.
|
|
|
|
Outputs, all under build/:
|
|
stage/full/ every mod plus the companion jar; the Prism
|
|
instance and the server smoke test install this
|
|
<slug>-<ver>.mrpack Modrinth pack
|
|
<slug>-<ver>-curseforge.zip CurseForge pack
|
|
<slug>-<ver>-private.mrpack every mod, by download link: the pack to hand out
|
|
SHA256SUMS what the build produced
|
|
|
|
Reproducible: the same commit gives the same bytes, whatever the machine has
|
|
installed and wherever the repo is. Every tool is pinned (tools/lock.py), and
|
|
tools/check_reproducible.py proves it with two builds from clean copies.
|
|
|
|
Each platform export only lists mods that platform hosts. A mod hosted on just
|
|
one platform is left out of the other export and reported; it is never copied
|
|
into overrides/, because the pack must not bundle third-party jars. The
|
|
companion mod is our own work, so it is the one jar allowed in overrides/.
|
|
|
|
Usage: tools/build.py [--skip-mod]
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import lock
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PACK = ROOT / "pack"
|
|
BUILD = ROOT / "build"
|
|
MODRINTH_HOSTS = ("https://cdn.modrinth.com/", "https://github.com/",
|
|
"https://raw.githubusercontent.com/", "https://gitlab.com/")
|
|
|
|
|
|
def run(cmd, cwd):
|
|
print(f"$ {' '.join(map(str, cmd))} (in {cwd.relative_to(ROOT) if cwd.is_relative_to(ROOT) else cwd})")
|
|
subprocess.run(cmd, cwd=cwd, check=True)
|
|
|
|
|
|
def packwiz(*args, cwd):
|
|
run([lock.packwiz(), *args, "-y"], cwd)
|
|
|
|
|
|
def gradle_properties():
|
|
return dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines()
|
|
if "=" in line and not line.startswith("#"))
|
|
|
|
|
|
def check_versions():
|
|
"""mod/gradle.properties compiles against the versions the pack installs."""
|
|
props = gradle_properties()
|
|
if props["neoforge_version"] != tomllib.loads((PACK / "pack.toml").read_text())["versions"]["neoforge"]:
|
|
sys.exit("mod/gradle.properties and pack/pack.toml name different NeoForge versions")
|
|
for slug, prop in (("cobblemon", "cobblemon_version"), ("kotlin-for-forge", "kotlinforforge_version"), ("jei", "jei_version")):
|
|
filename = tomllib.loads((PACK / f"mods/{slug}.pw.toml").read_text())["filename"]
|
|
if props[prop] not in filename:
|
|
sys.exit(f"mod/gradle.properties says {prop}={props[prop]}, the pack installs {filename}")
|
|
|
|
|
|
def build_mod():
|
|
"""From clean, with only the pinned JDK to compile against (an incremental build can differ from a full one)."""
|
|
jdk = lock.jdk()
|
|
subprocess.run(["./gradlew", "clean", "build", "--console=plain", "--no-build-cache",
|
|
f"-Porg.gradle.java.installations.paths={jdk}",
|
|
"-Porg.gradle.java.installations.auto-detect=false",
|
|
"-Porg.gradle.java.installations.auto-download=false"],
|
|
cwd=ROOT / "mod", env=os.environ | {"JAVA_HOME": str(jdk)}, check=True)
|
|
|
|
|
|
def companion_jar():
|
|
props = gradle_properties()
|
|
jar = ROOT / "mod/build/libs" / f"{props['mod_id']}-{props['mod_version']}.jar"
|
|
if not jar.exists():
|
|
sys.exit(f"companion jar not found: {jar} (run without --skip-mod)")
|
|
return jar
|
|
|
|
|
|
def platforms_of(meta):
|
|
hosts = set()
|
|
if "curseforge" in meta.get("update", {}):
|
|
hosts.add("curseforge")
|
|
if meta.get("download", {}).get("url", "").startswith(MODRINTH_HOSTS):
|
|
hosts.add("modrinth")
|
|
return hosts
|
|
|
|
|
|
def stage(name, jar, platform=None):
|
|
dest = BUILD / "stage" / name
|
|
shutil.rmtree(dest, ignore_errors=True)
|
|
shutil.copytree(PACK, dest)
|
|
left_out = []
|
|
if platform:
|
|
for path in sorted((dest / "mods").glob("*.pw.toml")):
|
|
meta = tomllib.loads(path.read_text())
|
|
if platform not in platforms_of(meta):
|
|
left_out.append(meta["name"])
|
|
path.unlink()
|
|
shutil.copy2(jar, dest / "mods" / jar.name)
|
|
packwiz("refresh", cwd=dest)
|
|
return dest, left_out
|
|
|
|
|
|
def check_overrides(archive, jar):
|
|
bundled = [n for n in zipfile.ZipFile(archive).namelist()
|
|
if n.endswith(".jar") and Path(n).name != jar.name]
|
|
if bundled:
|
|
sys.exit(f"{archive.name} would bundle third-party jars: {bundled}")
|
|
|
|
|
|
def check_default_shader():
|
|
"""The shader pack named in the default Iris settings is the file the pack installs."""
|
|
settings = (PACK / "config/defaultoptions/extra/config/iris.properties").read_text()
|
|
named = next(line.split("=", 1)[1] for line in settings.splitlines() if line.startswith("shaderPack="))
|
|
files = [tomllib.loads(p.read_text())["filename"] for p in (PACK / "shaderpacks").glob("*.pw.toml")]
|
|
if named not in files:
|
|
sys.exit(f"config/defaultoptions/extra/config/iris.properties names {named}, the pack has {files}")
|
|
|
|
|
|
def normalise_export(archive):
|
|
"""packwiz writes an export's entries, the manifest's file list and the mod list in a random order: sort them."""
|
|
with zipfile.ZipFile(archive) as z:
|
|
files = {i.filename: z.read(i.filename) for i in z.infolist() if not i.is_dir()}
|
|
if "manifest.json" in files:
|
|
manifest = json.loads(files["manifest.json"])
|
|
manifest["files"].sort(key=lambda f: (f["projectID"], f["fileID"]))
|
|
files["manifest.json"] = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode()
|
|
if "modlist.html" in files:
|
|
lines = files["modlist.html"].decode().splitlines()
|
|
items = sorted(line for line in lines if line.lstrip().startswith("<li>"))
|
|
files["modlist.html"] = "\n".join(["<ul>", *items, "</ul>", ""]).encode()
|
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for name in sorted(files):
|
|
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
info.external_attr = 0o644 << 16
|
|
z.writestr(info, files[name])
|
|
|
|
|
|
def write_private_export(modrinth_pack, out):
|
|
"""The pack Adrian hands out himself (2026-11-09: no public release): the Modrinth export plus the mods only
|
|
CurseForge hosts, by their CurseForge download links. Still no third-party jar inside. Prism installs any link;
|
|
Modrinth's own site and app only accept their listed hosts, which is why this is separate. The credits go in too."""
|
|
with zipfile.ZipFile(modrinth_pack) as z:
|
|
files = {i.filename: z.read(i.filename) for i in z.infolist() if not i.is_dir()}
|
|
index = json.loads(files["modrinth.index.json"])
|
|
listed = {Path(entry["path"]).name for entry in index["files"]}
|
|
for path in sorted((PACK / "mods").glob("*.pw.toml")):
|
|
meta = tomllib.loads(path.read_text())
|
|
if meta["filename"] in listed:
|
|
continue
|
|
jar = lock.fetch_mod(meta)
|
|
side = meta.get("side", "both")
|
|
index["files"].append({
|
|
"path": f"mods/{meta['filename']}",
|
|
"hashes": {"sha1": lock.digest(jar, "sha1"), "sha512": lock.digest(jar, "sha512")},
|
|
"env": {"client": "unsupported" if side == "server" else "required", "server": "unsupported" if side == "client" else "required"},
|
|
"downloads": [lock.download_url(meta)],
|
|
"fileSize": jar.stat().st_size,
|
|
})
|
|
index["files"].sort(key=lambda entry: entry["path"])
|
|
index["summary"] = "Unofficial fan-made modpack, not affiliated with or endorsed by Nintendo, Game Freak, Creatures Inc., " \
|
|
"The Pokémon Company, the Cobblemon team, Mojang Studios or Microsoft. Credits: CREDITS.md."
|
|
files["modrinth.index.json"] = (json.dumps(index, indent=1, ensure_ascii=False) + "\n").encode()
|
|
files["overrides/CREDITS.md"] = (ROOT / "docs/licenses.md").read_bytes()
|
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for name, content in files.items():
|
|
z.writestr(name, content)
|
|
normalise_export(out)
|
|
|
|
|
|
def write_checksums(paths):
|
|
(BUILD / "SHA256SUMS").write_text("".join(f"{lock.digest(p)} {p.relative_to(ROOT).as_posix()}\n" for p in sorted(paths)))
|
|
|
|
|
|
def main():
|
|
lock.enter_venv()
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--skip-mod", action="store_true", help="reuse the last companion mod build")
|
|
args = parser.parse_args()
|
|
|
|
check_versions()
|
|
check_default_shader()
|
|
# Written from the pack's data, so these go first: the drop pages ship in the data pack the mod embeds.
|
|
run([sys.executable, str(ROOT / "tools/jei_sources.py")], ROOT)
|
|
run([sys.executable, str(ROOT / "tools/quests.py")], ROOT) # the quest book (Phase 8) from docs/quests.yaml
|
|
if not args.skip_mod:
|
|
build_mod()
|
|
jar = companion_jar()
|
|
packwiz("refresh", cwd=PACK)
|
|
pack = tomllib.loads((PACK / "pack.toml").read_text())
|
|
base = f"{pack['name'].lower().replace(' ', '-')}-{pack['version']}"
|
|
|
|
stage("full", jar)
|
|
report = {}
|
|
for platform, suffix, command in (("modrinth", ".mrpack", "modrinth"),
|
|
("curseforge", "-curseforge.zip", "curseforge")):
|
|
dest, left_out = stage(platform, jar, platform)
|
|
out = BUILD / f"{base}{suffix}"
|
|
out.unlink(missing_ok=True)
|
|
packwiz(command, "export", "-o", str(out), cwd=dest)
|
|
normalise_export(out)
|
|
check_overrides(out, jar)
|
|
report[out.name] = left_out
|
|
|
|
private = BUILD / f"{base}-private.mrpack"
|
|
write_private_export(BUILD / f"{base}.mrpack", private)
|
|
check_overrides(private, jar)
|
|
report[private.name] = []
|
|
|
|
write_checksums([jar, BUILD / "stage/full/pack.toml", BUILD / "stage/full/index.toml", *(BUILD / name for name in report)])
|
|
print("\nExports (no third-party jars in overrides):")
|
|
for name, left_out in report.items():
|
|
print(f" build/{name}")
|
|
for mod in left_out:
|
|
print(f" left out, not hosted on this platform: {mod}")
|
|
print(" build/stage/full/pack.toml (all mods; used by Prism sync and the smoke test)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|