#!/usr/bin/env python3 """Prune a Minecraft Anvil world save down to a set of revealed chunks. This is how we transplant FTB Chunks' reveal footprint onto Xaero's World Map without reverse-engineering Xaero's region format: strip the world save down to exactly the chunks a player had explored, then let Xaero's own generator map that save in singleplayer. Xaero's renders it natively (correct palette, shading, light, biome) and the absent chunks simply never appear on the map. Chunk payloads are copied through byte-for-byte -- no NBT re-encoding -- so the output is safe for modded 1.21.1 chunk data. Only the 8 KiB header, the sector offsets and the zero padding are rewritten. Usage: prune-world-to-reveal.py [--dimension minecraft:overworld] [--dry-run] Reads `/region/r...mca` and writes pruned copies to `/region/`, along with whatever else the world needs. Nested per-dimension `region`/`entities`/`poi` trees are never copied, so the Nether and End are not duplicated at full size; `--minimal` goes further and copies only `level.dat`, which is all Xaero's needs to map the overworld. If a full save copy is wanted (level.dat, data/, playerdata/ ...), copy the world first and then run this in place with == . """ from __future__ import annotations import argparse import json import os import shutil import struct import sys SECTOR = 4096 HEADER = 2 * SECTOR COMPRESSION_NAMES = {1: "gzip", 2: "zlib", 3: "none", 4: "lz4", 127: "external"} def read_slot(data: bytes, size: int, slot: int) -> tuple[bytes, int, int] | None: """Return (payload, timestamp, compression) for an Anvil slot, or None if empty.""" off = struct.unpack(">I", b"\x00" + data[slot * 4 : slot * 4 + 3])[0] if off == 0: return None timestamp = struct.unpack(">I", data[SECTOR + slot * 4 : SECTOR + slot * 4 + 4])[0] start = off * SECTOR if start + 5 > len(data): raise ValueError(f"slot {slot}: offset {off} past end of file") (length,) = struct.unpack(">I", data[start : start + 4]) compression = data[start + 4] if length < 1 or start + 4 + length > len(data): raise ValueError(f"slot {slot}: bad chunk length {length}") return data[start + 5 : start + 4 + length], timestamp, compression def prune_region(path: str, keep_slots: set[int], out_path: str, dry: bool) -> dict: with open(path, "rb") as fh: data = fh.read() kept: list[tuple[int, bytes, int, int]] = [] dropped = 0 external = 0 for slot in range(1024): entry = read_slot(data, SECTOR, slot) if entry is None: continue payload, timestamp, compression = entry if compression == 127: external += 1 if slot in keep_slots: kept.append((slot, payload, timestamp, compression)) else: dropped += 1 stats = { "file": os.path.basename(path), "kept": len(kept), "dropped": dropped, "external_payloads": external, } if dry: return stats out = bytearray(HEADER) sector = 2 for slot, payload, timestamp, compression in kept: blob = struct.pack(">I", len(payload) + 1) + bytes([compression]) + payload sectors = (len(blob) + SECTOR - 1) // SECTOR if sector + sectors > 0xFFFFFF: raise ValueError(f"{path}: region would exceed the Anvil 24-bit offset limit") out[slot * 4 : slot * 4 + 3] = sector.to_bytes(3, "big") out[slot * 4 + 3] = sectors out[SECTOR + slot * 4 : SECTOR + slot * 4 + 4] = struct.pack(">I", timestamp) out += blob out += b"\x00" * (sectors * SECTOR - len(blob)) sector += sectors os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) with open(out_path, "wb") as fh: fh.write(out) return stats def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("world", help="world save directory containing region/") ap.add_argument("mask", help="mask JSON from ftbchunks-reveal.py") ap.add_argument("out", help="output world directory (use the same path for in-place)") ap.add_argument("--dimension", default="minecraft:overworld", help="which dimension in the mask to apply (default overworld)") ap.add_argument("--minimal", action="store_true", help="output only level.dat + the pruned region/ tree (smallest usable world)") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() with open(args.mask) as fh: mask = json.load(fh) dims = mask["dimensions"] if args.dimension not in dims: print(f"mask has no dimension '{args.dimension}'; available: {list(dims)}", file=sys.stderr) return 2 chunks = {(cx, cz) for cx, cz in dims[args.dimension]["chunks"]} by_region: dict[tuple[int, int], set[int]] = {} for cx, cz in chunks: rx, rz = cx >> 5, cz >> 5 by_region.setdefault((rx, rz), set()).add((cx & 31) + (cz & 31) * 32) in_region = os.path.join(args.world, "region") if not os.path.isdir(in_region): print(f"no region/ directory under {args.world}", file=sys.stderr) return 2 out_region = os.path.join(args.out, "region") os.makedirs(out_region, exist_ok=True) # Copy the rest of the world so the pruned save is openable, unless in-place. # Nested `region`/`entities`/`poi` trees (i.e. per-dimension ones like DIM-1/) are never # copied - copying them wholesale would duplicate the Nether and End at full size. if os.path.abspath(args.out) != os.path.abspath(args.world) and not args.dry_run: ignore = shutil.ignore_patterns("region", "entities", "poi") for name in sorted(os.listdir(args.world)): if name in ("region", "entities", "poi"): continue if args.minimal and name not in ("level.dat", "level.dat_old"): continue src, dst = os.path.join(args.world, name), os.path.join(args.out, name) if os.path.exists(dst): continue if os.path.isdir(src): shutil.copytree(src, dst, ignore=ignore) else: shutil.copy2(src, dst) total_kept = total_dropped = 0 seen_regions = set() failures: list[tuple[str, str]] = [] failed_regions: set[tuple[int, int]] = set() for name in sorted(os.listdir(in_region)): if not (name.startswith("r.") and name.endswith(".mca")): continue _, rx, rz, _ = name.split(".") coords = (int(rx), int(rz)) keep = by_region.get(coords) if keep is None: continue # region untouched by the reveal set try: stats = prune_region( os.path.join(in_region, name), keep, os.path.join(out_region, name), args.dry_run, ) except Exception as exc: # A live server can rewrite a region file mid-read. A torn read is not a reason to # abandon the whole pass - no output is written for a region that fails, so it can # simply be retried. failures.append((name, str(exc))) failed_regions.add(coords) print(f" ! {name}: {exc}", file=sys.stderr) continue seen_regions.add(coords) total_kept += stats["kept"] total_dropped += stats["dropped"] if stats["external_payloads"]: print(f" ! {name}: {stats['external_payloads']} external (.mcc) payload(s) " f"referenced but not copied", file=sys.stderr) print(f" {name}: kept {stats['kept']:>4} / dropped {stats['dropped']:>4}") # Any region the player revealed but that has no .mca file. missing = sorted(set(by_region) - seen_regions - failed_regions) if missing: print(f" ! {len(missing)} revealed region(s) have no .mca in the save " f"(e.g. {missing[:3]})", file=sys.stderr) verb = "would keep" if args.dry_run else "kept" print(f"\n{verb} {total_kept:,} chunks, dropped {total_dropped:,}, " f"across {len(seen_regions)} region file(s) -> {out_region}") if failures: print(f"\n{len(failures)} region(s) could not be read (not written):", file=sys.stderr) for name, why in failures: print(f" {name}: {why}", file=sys.stderr) print("Re-run to retry them - usually a transient write by a running server.", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())