mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-06 12:53:44 +00:00
The client mounts brickdb.zip with LDD's own zip reader. It is lenient about compression, order and extra entries, but loses the whole brick database when the files sit under a wrapping folder or "./", use backslashes, carry ZIP64 end records, or set the data descriptor flag with sizes still in the local header. tools/brickdb/repack.py rebuilds the shipped layout (a round trip gives the identical file), adds or replaces entries from an overlay folder, and checks a zip against the reader's rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
9.7 KiB
Python
Executable File
230 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Rebuild or check the client's res/brickdb.zip in the layout the LEGO Universe client (1.10.64) accepts.
|
|
|
|
The client mounts brickdb.zip with LEGO Digital Designer's own zip reader (see docs/BrickDb.md). It is lenient
|
|
about compression and entry order, but a few things other zip tools commonly do make it drop the whole brick
|
|
database: a wrapping folder or "./" prefix, backslash separators, ZIP64 end records, and data descriptors whose
|
|
local header still carries the sizes. This script always writes the same layout the shipped file uses.
|
|
|
|
repack.py build SRC OUT.zip [--overlay DIR]... SRC is an extracted brickdb folder or a brickdb.zip; each
|
|
--overlay folder (same layout) adds or replaces entries
|
|
repack.py check FILE.zip [--res RES_DIR] report everything the client would reject; with --res also
|
|
look for each primitive's geometry in brickprimitives/
|
|
|
|
Repacking the shipped brickdb.zip with no overlay gives back the identical file.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import struct
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
import zlib
|
|
import xml.etree.ElementTree as ElementTree
|
|
|
|
SIG_LOCAL = 0x04034B50
|
|
SIG_CENTRAL = 0x02014B50
|
|
SIG_END = 0x06054B50
|
|
SIG_END64 = 0x06064B50
|
|
SIG_END64_LOCATOR = 0x07064B50
|
|
|
|
# Where each file lives in the database and what the client does with it
|
|
ROOT_FILES = ("info.xml", "Materials.xml")
|
|
NUMBERED_DIRS = {"Primitives": ".xml", "Assemblies": ".lxfml"}
|
|
|
|
|
|
def entry_order(name):
|
|
"""The shipped order: info.xml, Materials.xml, then primitives and assemblies together by design ID, then the rest."""
|
|
if name in ROOT_FILES:
|
|
return (0, ROOT_FILES.index(name), 0, name)
|
|
folder, _, file = name.partition("/")
|
|
stem, ext = os.path.splitext(file)
|
|
if folder in NUMBERED_DIRS and ext.lower() == NUMBERED_DIRS[folder] and stem.isdigit():
|
|
return (1, int(stem), 0 if folder == "Primitives" else 1, name)
|
|
return (2, 0, 0, name.lower())
|
|
|
|
|
|
def read_source(src):
|
|
"""{name: (bytes, date_time)} from an extracted folder or a zip."""
|
|
entries = {}
|
|
if os.path.isdir(src):
|
|
for root, dirs, files in os.walk(src):
|
|
dirs.sort()
|
|
for file in files:
|
|
path = os.path.join(root, file)
|
|
name = os.path.relpath(path, src).replace(os.sep, "/")
|
|
with open(path, "rb") as f:
|
|
entries[name] = (f.read(), time.localtime(os.path.getmtime(path))[:6])
|
|
else:
|
|
with zipfile.ZipFile(src) as z:
|
|
for info in z.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
entries[info.filename.replace("\\", "/")] = (z.read(info), info.date_time)
|
|
# drop a "./" prefix or a wrapping folder if the source has one
|
|
entries = {(n[2:] if n.startswith("./") else n): v for n, v in entries.items()}
|
|
tops = {n.split("/", 1)[0] for n in entries}
|
|
if "info.xml" not in entries and len(tops) == 1 and tops.pop() + "/info.xml" in entries:
|
|
top = next(iter(entries)).split("/", 1)[0] + "/"
|
|
entries = {n[len(top):]: v for n, v in entries.items()}
|
|
return entries
|
|
|
|
|
|
def build(src, out, overlays):
|
|
entries = read_source(src)
|
|
for overlay in overlays:
|
|
for name, value in read_source(overlay).items():
|
|
if name.startswith("brickprimitives/"):
|
|
continue
|
|
entries[name] = value
|
|
missing = [n for n in ("info.xml",) if n not in entries]
|
|
if missing or not any(n.startswith("Primitives/") for n in entries):
|
|
sys.exit(f"{src}: no info.xml or Primitives/ at the top of the database")
|
|
tmp = out + ".tmp"
|
|
# Plain deflate at zlib's default level, no directory entries, no extra fields, no data descriptors, no ZIP64
|
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, allowZip64=False) as z:
|
|
for name in sorted(entries, key=entry_order):
|
|
data, date_time = entries[name]
|
|
info = zipfile.ZipInfo(name, date_time=max(tuple(date_time), (1980, 1, 1, 0, 0, 0)))
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
info.create_system = 3
|
|
info.external_attr = 0o600 << 16
|
|
z.writestr(info, data)
|
|
os.replace(tmp, out)
|
|
report(check(out, None), "warning: ")
|
|
print(f"wrote {out}: {len(entries)} entries")
|
|
|
|
|
|
def check(path, res):
|
|
"""Everything the client's zip reader and brick database would reject, as a list of messages."""
|
|
problems = []
|
|
data = open(path, "rb").read()
|
|
end = data.rfind(struct.pack("<I", SIG_END), max(0, len(data) - 0xFFFF - 22))
|
|
if end < 0:
|
|
return ["no end of central directory record"]
|
|
_, disk, cd_disk, count_disk, count, cd_size, cd_offset, _ = struct.unpack_from("<IHHHHIIH", data, end)
|
|
if disk or cd_disk or count_disk != count:
|
|
problems.append("multi-disk archive (disk numbers must be 0)")
|
|
if cd_offset + cd_size != end:
|
|
gap = data[cd_offset + cd_size:end]
|
|
kind = "ZIP64 end records" if gap[:4] == struct.pack("<I", SIG_END64) else f"{len(gap)} unexpected bytes"
|
|
problems.append(f"{kind} between the central directory and its end record (the client needs them adjacent)")
|
|
if cd_offset + cd_size > end or cd_offset > len(data):
|
|
return problems + ["central directory offset is outside the file (data before the zip?)"]
|
|
|
|
names = set()
|
|
owners = {} # design ID or alias -> primitives that claim it
|
|
pos = cd_offset
|
|
for _ in range(count):
|
|
if struct.unpack_from("<I", data, pos)[0] != SIG_CENTRAL:
|
|
problems.append(f"central directory entry at {pos} has a bad signature")
|
|
break
|
|
(_, _, _, flags, method, _, _, crc, csize, usize, name_len, extra_len, comment_len,
|
|
_, _, _, local) = struct.unpack_from("<IHHHHHHIIIHHHHHII", data, pos)
|
|
raw = data[pos + 46:pos + 46 + name_len]
|
|
name = raw.decode("utf-8" if flags & 0x800 else "cp437")
|
|
pos += 46 + name_len + extra_len + comment_len
|
|
where = f"{name!r}:"
|
|
if "\\" in name:
|
|
problems.append(f"{where} backslash in the path (the client only splits on '/')")
|
|
parts = name.rstrip("/").split("/")
|
|
if name.startswith("/") or "" in parts or "." in parts or ".." in parts:
|
|
problems.append(f"{where} empty, '.' or '..' path component")
|
|
if flags & 1:
|
|
problems.append(f"{where} encrypted")
|
|
if method not in (0, 8):
|
|
problems.append(f"{where} compression method {method} (only stored and deflate are read)")
|
|
if struct.unpack_from("<I", data, local)[0] != SIG_LOCAL:
|
|
problems.append(f"{where} local header missing")
|
|
continue
|
|
_, _, lflags, lmethod, _, _, lcrc, lcsize, lusize, lname, lextra = struct.unpack_from("<IHHHHHIIIHH", data, local)
|
|
if lflags != flags or lmethod != method:
|
|
problems.append(f"{where} local header flags/method differ from the central directory")
|
|
if flags & 8:
|
|
if lcrc or lcsize or lusize:
|
|
problems.append(f"{where} data descriptor flag set but the local header has sizes (the client needs zeros)")
|
|
elif (lcrc, lcsize, lusize) != (crc, csize, usize):
|
|
problems.append(f"{where} local header CRC/sizes differ from the central directory")
|
|
if not name.endswith("/"):
|
|
start = local + 30 + lname + lextra
|
|
body = data[start:start + csize]
|
|
try:
|
|
content = body if method == 0 else zlib.decompressobj(-15).decompress(body)
|
|
if zlib.crc32(content) & 0xFFFFFFFF != crc:
|
|
problems.append(f"{where} CRC mismatch (the client does not check it, other tools will)")
|
|
except zlib.error as e:
|
|
problems.append(f"{where} does not inflate ({e})")
|
|
content = None
|
|
names.add(name)
|
|
folder, _, file = name.partition("/")
|
|
if folder in NUMBERED_DIRS and "/" not in file and file.lower().endswith(NUMBERED_DIRS[folder]):
|
|
stem = os.path.splitext(file)[0]
|
|
if not stem.isdigit():
|
|
problems.append(f"{where} name is not a design ID; the client parses the file name as the ID")
|
|
if content is not None:
|
|
try:
|
|
root = ElementTree.fromstring(content)
|
|
except ElementTree.ParseError as e:
|
|
problems.append(f"{where} XML does not parse ({e}); this brick will be skipped")
|
|
root = None
|
|
if root is not None and folder == "Primitives":
|
|
ids = {stem}
|
|
for annotation in root.iter("Annotation"):
|
|
ids.update(a for a in annotation.get("aliases", "").split(";") if a)
|
|
for i in ids:
|
|
owners.setdefault(i, []).append(name)
|
|
|
|
for i, claimed in sorted(owners.items()):
|
|
if len(claimed) > 1:
|
|
problems.append(f"design ID/alias {i} is claimed by {', '.join(claimed)}")
|
|
lower = {n.lower() for n in names}
|
|
if "info.xml" not in lower:
|
|
problems.append("no info.xml at the top level")
|
|
primitives = sorted(n for n in names if n.lower().startswith("primitives/") and n.lower().endswith(".xml"))
|
|
if not primitives:
|
|
problems.append("no Primitives/*.xml at the top level (wrapping folder?); the client drops the whole database")
|
|
if res:
|
|
for n in primitives:
|
|
stem = os.path.splitext(n.split("/")[-1])[0]
|
|
if stem.isdigit() and not os.path.exists(os.path.join(res, "brickprimitives", "lod0", stem + ".g")):
|
|
problems.append(f"{n}: no brickprimitives/lod0/{stem}.g")
|
|
return problems
|
|
|
|
|
|
def report(problems, prefix=""):
|
|
"""Prints the problems, at most three entries for each kind."""
|
|
kinds = {}
|
|
for p in problems:
|
|
kind = p.split(": ", 1)[1] if p.startswith("'") and ": " in p else p
|
|
kinds.setdefault(kind, []).append(p)
|
|
for kind, items in kinds.items():
|
|
for p in items[:3]:
|
|
print(prefix + p)
|
|
if len(items) > 3:
|
|
print(f"{prefix}... and {len(items) - 3} more entries: {kind}")
|
|
print(f"{len(problems)} problem(s)")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
b = sub.add_parser("build", help="write a brickdb.zip the client accepts")
|
|
b.add_argument("src")
|
|
b.add_argument("out")
|
|
b.add_argument("--overlay", action="append", default=[])
|
|
c = sub.add_parser("check", help="list what the client would reject")
|
|
c.add_argument("zip")
|
|
c.add_argument("--res", help="the client's res folder, to look for geometry")
|
|
args = parser.parse_args()
|
|
if args.command == "build":
|
|
build(args.src, args.out, args.overlay)
|
|
else:
|
|
problems = check(args.zip, args.res)
|
|
report(problems)
|
|
sys.exit(1 if problems else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|