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>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Layered, read-only view of the game's resources, resolved like Minecraft's pack stack.
|
|
|
|
Layers from bottom to top: vanilla server data (plus the vanilla client jar for
|
|
textures, if Prism has it), NeoForge, every mod jar listed in pack/mods, then
|
|
datapacks/core. A higher layer replaces a file with the same path, tags are
|
|
merged over all layers, and a pack.mcmeta "filter" block hides matching files
|
|
of every layer below it (how Phase 3 can blank Cobblemon's own spawn pools).
|
|
|
|
Jars are copied into build/fieldguide-cache/jars first, so a server or Prism
|
|
instance that replaces its jars mid-run cannot change what we read.
|
|
"""
|
|
import json
|
|
import re
|
|
import shutil
|
|
import tomllib
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
BUILD = ROOT / "build"
|
|
CACHE = BUILD / "fieldguide-cache"
|
|
PRISM = Path.home() / ".var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher"
|
|
MOD_DIRS = [BUILD / "stage/full/mods", BUILD / "server/mods",
|
|
PRISM / "instances/Cobblemon Survival Pack (dev)/minecraft/mods"]
|
|
# Only these asset subtrees are indexed; Cobblemon alone ships ~20k model/sound files we never read.
|
|
ASSET_DIRS = ("lang/", "models/item/", "models/block/", "textures/item/", "textures/block/", "blockstates/")
|
|
|
|
|
|
class Layer:
|
|
"""One resource pack: a jar or a folder, with its pack.mcmeta filter."""
|
|
|
|
def __init__(self, name, path, roots=("data", "assets"), modids=()):
|
|
self.name, self.path, self.modids = name, Path(path), list(modids)
|
|
self.files = {}
|
|
self.filters = []
|
|
self.zip = zipfile.ZipFile(self.path) if self.path.is_file() else None
|
|
names = self.zip.namelist() if self.zip else [
|
|
p.relative_to(self.path).as_posix() for p in self.path.rglob("*") if p.is_file()]
|
|
for n in names:
|
|
parts = n.split("/", 3)
|
|
if len(parts) < 4 or parts[0] not in roots or n.endswith("/"):
|
|
continue
|
|
if parts[0] == "assets" and not parts[2].startswith(("lang", "models", "textures", "blockstates")):
|
|
continue
|
|
if parts[0] == "assets" and not (parts[2] + "/" + parts[3]).startswith(ASSET_DIRS):
|
|
continue
|
|
self.files[n] = n
|
|
meta = self.read("pack.mcmeta", missing_ok=True)
|
|
if meta:
|
|
block = json.loads(meta).get("filter", {}).get("block", [])
|
|
self.filters = [(re.compile(f.get("namespace", ".*")), re.compile(f.get("path", ".*"))) for f in block]
|
|
|
|
def read(self, rel, missing_ok=False):
|
|
try:
|
|
return self.zip.read(rel) if self.zip else (self.path / rel).read_bytes()
|
|
except (KeyError, FileNotFoundError):
|
|
if missing_ok:
|
|
return None
|
|
raise
|
|
|
|
def __repr__(self):
|
|
return f"Layer({self.name})"
|
|
|
|
|
|
def loads(raw):
|
|
"""JSON as Minecraft's lenient reader accepts it (a few mod files carry // comments)."""
|
|
text = raw.decode("utf-8-sig")
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
text = re.sub(r"^\s*//.*$", "", text, flags=re.M)
|
|
return json.loads(re.sub(r",(\s*[}\]])", r"\1", text))
|
|
|
|
|
|
class DataStack:
|
|
def __init__(self, layers):
|
|
self.layers = layers
|
|
self.index = {}
|
|
self.errors = []
|
|
self._ids = {}
|
|
self._json = {}
|
|
for i, layer in enumerate(layers):
|
|
for rel in layer.files:
|
|
self.index.setdefault(rel, []).append(i)
|
|
self.unfiltered = {rel: list(idxs) for rel, idxs in self.index.items()}
|
|
for i, layer in enumerate(layers):
|
|
if not layer.filters:
|
|
continue
|
|
for rel, idxs in self.index.items():
|
|
ns, path = rel.split("/", 2)[1:]
|
|
if any(n.fullmatch(ns) and p.fullmatch(path) for n, p in layer.filters):
|
|
self.index[rel] = [j for j in idxs if j >= i]
|
|
|
|
@property
|
|
def modids(self):
|
|
return {"minecraft", "neoforge", "c"} | {m for layer in self.layers for m in layer.modids}
|
|
|
|
def exists(self, rel):
|
|
return bool(self.index.get(rel))
|
|
|
|
def read(self, rel):
|
|
idxs = self.index.get(rel)
|
|
return self.layers[idxs[-1]].read(rel) if idxs else None
|
|
|
|
def layer_of(self, rel):
|
|
idxs = self.index.get(rel)
|
|
return self.layers[idxs[-1]] if idxs else None
|
|
|
|
def json(self, rel):
|
|
if rel not in self._json:
|
|
raw = self.read(rel)
|
|
try:
|
|
self._json[rel] = loads(raw) if raw is not None else None
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
self.errors.append(f"{rel}: {e}")
|
|
self._json[rel] = None
|
|
return self._json[rel]
|
|
|
|
def all_json(self, rel):
|
|
"""Every layer's copy of a file, bottom to top (tags merge these)."""
|
|
out = []
|
|
for i in self.index.get(rel, []):
|
|
try:
|
|
out.append(loads(self.layers[i].read(rel)))
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
self.errors.append(f"{self.layers[i].name}:{rel}: {e}")
|
|
return out
|
|
|
|
def ids(self, kind, root="data", ext=".json"):
|
|
"""{namespace:path -> rel} for every visible resource of a kind, e.g. ids('recipe')."""
|
|
key = (root, kind, ext)
|
|
if key not in self._ids:
|
|
found = {}
|
|
for rel, idxs in self.index.items():
|
|
if not idxs or not rel.startswith(root + "/") or not rel.endswith(ext):
|
|
continue
|
|
_, ns, rest = rel.split("/", 2)
|
|
if rest.startswith(kind + "/"):
|
|
found[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = rel
|
|
self._ids[key] = found
|
|
return self._ids[key]
|
|
|
|
def hidden(self, kind, root="data", ext=".json"):
|
|
"""{namespace:path -> json} for resources of a kind that a pack.mcmeta filter hides from every layer,
|
|
read from the topmost layer that has them (e.g. Cobblemon's own spawn pools under datapacks/core's filter)."""
|
|
out = {}
|
|
for rel, idxs in self.unfiltered.items():
|
|
if self.index.get(rel) or not rel.startswith(root + "/") or not rel.endswith(ext):
|
|
continue
|
|
_, ns, rest = rel.split("/", 2)
|
|
if rest.startswith(kind + "/"):
|
|
try:
|
|
out[f"{ns}:{rest[len(kind) + 1:-len(ext)]}"] = (rel, loads(self.layers[idxs[-1]].read(rel)))
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
self.errors.append(f"{self.layers[idxs[-1]].name}:{rel}: {e}")
|
|
return dict(sorted(out.items()))
|
|
|
|
def resource(self, kind, rid, root="data", ext=".json"):
|
|
ns, path = split_id(rid)
|
|
return f"{root}/{ns}/{kind}/{path}{ext}"
|
|
|
|
def get(self, kind, rid):
|
|
return self.json(self.resource(kind, rid))
|
|
|
|
|
|
def split_id(rid):
|
|
rid = rid.lstrip("#")
|
|
return rid.split(":", 1) if ":" in rid else ("minecraft", rid)
|
|
|
|
|
|
def norm_id(rid):
|
|
ns, path = split_id(rid)
|
|
return f"{ns}:{path}"
|
|
|
|
|
|
# --- locating and caching jars -----------------------------------------------------------
|
|
|
|
def pack_mod_files():
|
|
"""Jar filenames the pack defines (pack/mods/*.pw.toml)."""
|
|
out = []
|
|
for toml in sorted((ROOT / "pack/mods").glob("*.pw.toml")):
|
|
data = tomllib.loads(toml.read_text())
|
|
if data.get("filename", "").endswith(".jar"):
|
|
out.append(data["filename"])
|
|
return out
|
|
|
|
|
|
def find_jar(name, dirs=MOD_DIRS):
|
|
for d in dirs:
|
|
if (d / name).is_file():
|
|
return d / name
|
|
return None
|
|
|
|
|
|
def companion_jar():
|
|
for d in MOD_DIRS:
|
|
jars = sorted(d.glob("cobblesurvival-*.jar"), key=lambda p: p.stat().st_mtime)
|
|
if jars:
|
|
return jars[-1]
|
|
return None
|
|
|
|
|
|
def cached(src):
|
|
"""Copy a jar into build/fieldguide-cache/jars unless an identical copy is there."""
|
|
dst = CACHE / "jars" / src.name
|
|
st = src.stat()
|
|
if not dst.exists() or dst.stat().st_size != st.st_size or int(dst.stat().st_mtime) != int(st.st_mtime):
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
return dst
|
|
|
|
|
|
def jar_modids(path):
|
|
with zipfile.ZipFile(path) as z:
|
|
try:
|
|
toml = tomllib.loads(z.read("META-INF/neoforge.mods.toml").decode())
|
|
except (KeyError, tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
return []
|
|
return [m["modId"] for m in toml.get("mods", []) if "modId" in m]
|
|
|
|
|
|
def newest(pattern_dir, pattern):
|
|
hits = sorted(Path(pattern_dir).glob(pattern)) if Path(pattern_dir).exists() else []
|
|
return hits[-1] if hits else None
|
|
|
|
|
|
def build_stack(datapack=ROOT / "datapacks/core", extra_layers=(), log=print):
|
|
"""Create the layer stack. Missing optional inputs are reported, missing required ones raise."""
|
|
pack_toml = tomllib.loads((ROOT / "pack/pack.toml").read_text())
|
|
mc = pack_toml.get("versions", {}).get("minecraft", "1.21.1")
|
|
neo = pack_toml.get("versions", {}).get("neoforge")
|
|
libs = BUILD / "server/libraries"
|
|
vanilla = newest(libs / "net/minecraft/server", f"{mc}-*/server-{mc}-*-extra.jar")
|
|
if not vanilla:
|
|
raise SystemExit(f"vanilla data jar not found under {libs} (run tools/smoke_test.py --load-only once)")
|
|
layers = []
|
|
client = PRISM / f"libraries/com/mojang/minecraft/{mc}/minecraft-{mc}-client.jar"
|
|
if client.exists():
|
|
layers.append(Layer("minecraft-client", cached(client), roots=("assets",)))
|
|
else:
|
|
log(f"note: no vanilla client jar at {client}; vanilla items get no icons")
|
|
layers.append(Layer("minecraft", cached(vanilla), modids=["minecraft"]))
|
|
neo_jar = newest(libs / "net/neoforged/neoforge", f"{neo or '*'}/neoforge-*-universal.jar")
|
|
if neo_jar:
|
|
layers.append(Layer("neoforge", cached(neo_jar), modids=["neoforge"]))
|
|
else:
|
|
log("note: NeoForge universal jar not found; c: tags from NeoForge are missing")
|
|
mods = []
|
|
for name in pack_mod_files():
|
|
src = find_jar(name)
|
|
if not src:
|
|
log(f"warning: {name} (pack/mods) not found in {', '.join(str(d) for d in MOD_DIRS)}")
|
|
continue
|
|
mods.append(cached(src))
|
|
comp = companion_jar()
|
|
if comp:
|
|
mods.append(cached(comp))
|
|
for jar in sorted(mods, key=lambda p: p.name.lower()):
|
|
layers.append(Layer(jar.name, jar, modids=jar_modids(jar)))
|
|
for extra in extra_layers:
|
|
layers.append(Layer(Path(extra).name, extra))
|
|
layers.append(Layer("datapacks/core", datapack, roots=("data",)))
|
|
# KubeJS serves pack/kubejs/data as a data pack and pack/kubejs/assets as a resource pack.
|
|
if (ROOT / "pack/kubejs").is_dir():
|
|
layers.append(Layer("pack/kubejs", ROOT / "pack/kubejs"))
|
|
return DataStack(layers)
|