nbt-fixer.py

#!/usr/bin/env python3
"""自动修复 .nbt 结构文件,使其 size 与实际方块坐标匹配,兼容 Litematica。

用法:
    python nbt-fixer.py <文件或目录> [--overwrite]

零依赖,仅需 Python 3 标准库。
"""

import gzip
import struct
import sys
import os
import argparse
from pathlib import Path


class NBTReader:

    def __init__(self, data: bytes):
        self.data = data
        self.pos = 0

    def _b(self):
        v = struct.unpack(">b", self.data[self.pos : self.pos + 1])[0]
        self.pos += 1
        return v

    def _H(self):
        v = struct.unpack(">H", self.data[self.pos : self.pos + 2])[0]
        self.pos += 2
        return v

    def _i(self):
        v = struct.unpack(">i", self.data[self.pos : self.pos + 4])[0]
        self.pos += 4
        return v

    def _q(self):
        v = struct.unpack(">q", self.data[self.pos : self.pos + 8])[0]
        self.pos += 8
        return v

    def _str(self):
        length = self._H()
        s = self.data[self.pos : self.pos + length].decode("utf-8", errors="replace")
        self.pos += length
        return s

    def _payload(self, tag: int):
        if tag == 1:
            return self._b()
        if tag == 2:
            v = struct.unpack(">h", self.data[self.pos : self.pos + 2])[0]
            self.pos += 2
            return v
        if tag == 3:
            return self._i()
        if tag == 4:
            return self._q()
        if tag == 5:
            v = struct.unpack(">f", self.data[self.pos : self.pos + 4])[0]
            self.pos += 4
            return v
        if tag == 6:
            v = struct.unpack(">d", self.data[self.pos : self.pos + 8])[0]
            self.pos += 8
            return v
        if tag == 7:
            length = self._i()
            self.pos += length
            return f"<byte[{length}]>"
        if tag == 8:
            return self._str()
        if tag == 9:
            subtype = self.data[self.pos]
            self.pos += 1
            length = self._i()
            return [self._payload(subtype) for _ in range(length)]
        if tag == 10:
            d = {}
            while self.pos < len(self.data) and self.data[self.pos] != 0:
                t2 = self.data[self.pos]
                self.pos += 1
                key = self._str()
                d[key] = self._payload(t2)
            self.pos += 1
            return d
        if tag == 11:
            length = self._i()
            return [self._i() for _ in range(length)]
        if tag == 12:
            length = self._i()
            return [self._q() for _ in range(length)]
        return None

    def read_root(self) -> dict:
        self.pos += 1
        self._str()
        return self._payload(10)


def patch_size(raw: bytearray) -> tuple[list[int], int]:
    """返回 (旧size值, size字段起始偏移)。找不到则抛 ValueError。"""
    i = 0
    while i < len(raw) - 20:
        if raw[i] != 0x09:
            i += 1
            continue
        name_len = struct.unpack(">H", raw[i + 1 : i + 3])[0]
        if name_len > 256 or i + 3 + name_len > len(raw):
            i += 1
            continue
        name = raw[i + 3 : i + 3 + name_len]
        if name != b"size":
            i += 1
            continue

        off = i + 3 + name_len
        if off + 5 > len(raw):
            continue

        subtype = raw[off]
        list_len = struct.unpack(">i", raw[off + 1 : off + 5])[0]
        if subtype != 3 or list_len != 3:
            i += 1
            continue

        val_start = off + 5
        old = [struct.unpack(">i", raw[val_start + j * 4 : val_start + j * 4 + 4])[0] for j in range(3)]
        return old, val_start

    raise ValueError("未找到 'size' 标签")


def analyze_nbt(filepath: Path) -> dict | None:
    try:
        with open(filepath, "rb") as f:
            compressed = f.read()

        raw = gzip.decompress(compressed)
        reader = NBTReader(raw)
        data = reader.read_root()

        blocks = data.get("blocks", [])
        declared_size = data.get("size", [0, 0, 0])
        dv = data.get("DataVersion", 0)

        if not blocks or len(declared_size) != 3:
            return None

        xs = [b["pos"][0] for b in blocks]
        ys = [b["pos"][1] for b in blocks]
        zs = [b["pos"][2] for b in blocks]

        actual = [max(xs) - min(xs) + 1, max(ys) - min(ys) + 1, max(zs) - min(zs) + 1]

        return {
            "filepath": filepath,
            "data_version": dv,
            "declared": declared_size,
            "actual": actual,
            "block_count": len(blocks),
            "raw_size": len(raw),
            "compressed_size": len(compressed),
        }

    except Exception as e:
        print(f"  ✗ 解析失败: {e}", file=sys.stderr)
        return None


def fix_nbt(filepath: Path, overwrite: bool = False) -> Path | None:
    info = analyze_nbt(filepath)
    if info is None:
        return None

    if info["declared"] == info["actual"]:
        print(f"  ✓ 无需修复")
        return None

    # 读取原始数据
    with open(filepath, "rb") as f:
        compressed = f.read()

    raw = bytearray(gzip.decompress(compressed))
    _, val_start = patch_size(raw)

    for j, v in enumerate(info["actual"]):
        struct.pack_into(">i", raw, val_start + j * 4, v)

    new_compressed = gzip.compress(bytes(raw))

    if overwrite:
        out = filepath
    else:
        out = filepath.with_name(f"{filepath.stem}_fixed.nbt")

    with open(out, "wb") as f:
        f.write(new_compressed)

    print(f"  ✓ size: {info['declared']} → {info['actual']}")
    print(f"  → 输出: {out}")
    return out


def process_path(path: Path, overwrite: bool = False):
    if path.is_file():
        if path.suffix.lower() == ".nbt":
            print(f"\n📦 {path.name}  ({path.stat().st_size / 1024:.1f} KB)")
            fix_nbt(path, overwrite=overwrite)
        else:
            print(f"⏭  跳过非 .nbt 文件: {path.name}")
    elif path.is_dir():
        nbt_files = sorted(path.glob("*.nbt"))
        if not nbt_files:
            print(f"未找到 .nbt 文件: {path}")
            return
        print(f"找到 {len(nbt_files)} 个 .nbt 文件\n")
        for i, f in enumerate(nbt_files, 1):
            print(f"[{i}/{len(nbt_files)}] {f.name}  ({f.stat().st_size / 1024:.1f} KB)")
            fix_nbt(f, overwrite=overwrite)
    else:
        print(f"✗ 路径不存在: {path}")


def main():
    parser = argparse.ArgumentParser(
        description="自动修复 .nbt 结构文件的 size 字段,使其兼容 Litematica。"
    )
    parser.add_argument(
        "target",
        help=".nbt 文件或包含 .nbt 的目录",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="直接覆盖原文件(默认生成 *_fixed.nbt)",
    )
    args = parser.parse_args()

    path = Path(args.target)
    print(f"nbt-fixer | {'覆盖模式' if args.overwrite else '生成 *_fixed.nbt'}")
    process_path(path, overwrite=args.overwrite)


if __name__ == "__main__":
    main()