Files
onion-mc/tools/prune-world-to-reveal.py
logan 769af6fa2d chore(tools): add FTB Chunks -> Xaero's map reveal migration tooling
The swap does not carry players' explored terrain across. FTB Chunks stores no
rendered map (its region zips hold chunks.dat plus five data PNGs, one of which
packs height into an image channel), and Xaero's region format is closed and now
version 6 with per-pixel NBT blobs, so the community converters no longer work.
Rather than convert pixels, prune a copy of the world save down to exactly the
chunks a player revealed and let Xaero's own generator map it.

Validated against real data: extracting the 167-region / 98,170-chunk mask
cross-checks clean against FTB's own pixel data, and Anvil pruning round-trips
byte-identically (15.7 MB -> 471 KB on a live region file, slot-to-chunk mapping
confirmed against 1024 chunk NBT headers).

Deferred for now: needs the world save and one singleplayer pass per player.
See analysis/FTB-XAERO-MAP-MIGRATION.md.
2026-09-30 22:04:56 -04:00

211 lines
8.6 KiB
Python
Executable File

#!/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 <world-save-dir> <mask.json> <out-dir>
[--dimension minecraft:overworld] [--dry-run]
Reads `<world-save-dir>/region/r.<x>.<z>.mca` and writes pruned copies to
`<out-dir>/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 <out-dir> == <world-save-dir>.
"""
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())