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.
211 lines
8.6 KiB
Python
Executable File
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())
|