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