Files
DarkflameServer/dUgcServer/Toolbox/dlu_toolbox_worker.py
Aaron Kimbrell 6ae6eac64e feat(ugc): LU Toolbox worker in a headless Blender
UgcToolbox runs LU Toolbox itself in an external Blender that stays up
and makes one model after another (dlu_toolbox_worker.py runs
LU-Toolbox-Standalone's steps: import, Process Model, Bake Lighting,
niftools export). JSON lines over its stdin and original stdout; it is
restarted after a crash, a timeout, 100 models or new settings, runs at
worker_nice with toolbox_threads, and its CPU time is charged to the
worker, which pauses it (SIGSTOP) while the CPU budget is overdrawn.
ProcessModelToolbox reads LU Toolbox's .nif back, counts its triangles
and draws the icon from it. Tests: option parsing, the fallback, the
protocol framing, the worker with a stand-in Blender (crash, hang,
failure, give-up), and LU Toolbox itself when DLU_TEST_TOOLBOX_* are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:47:40 -05:00

245 lines
10 KiB
Python

# The UGC server's LU Toolbox worker (docs/UgcServer.md, "LU Toolbox in Blender"). Runs inside a headless Blender
# started by the UGC server and stays up, making one model after another:
#
# blender -b --factory-startup -t <threads> --python dlu_toolbox_worker.py -- \
# --standalone <LU-Toolbox-Standalone> --brickdb <folder> --res <client res> --device cpu
#
# Each model goes through LU Toolbox itself, as LU-Toolbox-Standalone's lu_batch_driver.py runs it (its functions are
# used, not copied): the LU Toolbox importer, Process Model, Bake Lighting and the niftools .nif export for LEGO
# Universe. Nothing of LU Toolbox is changed or re-implemented here; this file only keeps Blender up between models.
#
# The protocol: one JSON object per line. Requests come on stdin; replies go to the original stdout, which only this
# script writes to (everything Blender and the add-ons print is moved to stderr). Every message has "dlutb": 1.
# -> {"dlutb":1,"cmd":"make","id":7,"input":"/x/7.lxfml","output":"/x/7.nif","lods":[0,2]}
# <- {"dlutb":1,"type":"done","id":7,"ok":true,"ms":{"reset":..,"import":..,"process":..,"bake":..,"export":..}}
# <- {"dlutb":1,"type":"done","id":7,"ok":false,"error":"..."}
# -> {"dlutb":1,"cmd":"ping","id":8} <- {"dlutb":1,"type":"pong","id":8}
# -> {"dlutb":1,"cmd":"quit"} (the worker exits; it also exits when stdin closes)
# When it is ready for work it sends {"dlutb":1,"type":"ready","blender":..,"toolbox":..,"niftools":..,"device":..},
# or {"dlutb":1,"type":"failed","error":..} and exits when it can't be.
import argparse
import json
import os
import sys
import time
import traceback
import zipfile
PROTOCOL = 1
# The replies' channel is the original stdout; stdout itself now goes to stderr, so nothing else can write into it
_reply = os.fdopen(os.dup(1), "w", encoding="utf-8", newline="\n")
os.dup2(2, 1)
def send(message):
message = dict(message)
message["dlutb"] = PROTOCOL
_reply.write(json.dumps(message, separators=(",", ":")) + "\n")
_reply.flush()
def log(*parts):
print("[dlu-toolbox]", *parts, file=sys.stderr, flush=True)
def script_args():
argv = sys.argv
return argv[argv.index("--") + 1:] if "--" in argv else []
def prepare_brickdb(brickdb, res):
"""
LU Toolbox reads the brick data from a folder with the brick database unpacked (Assemblies/, Primitives/,
Materials.xml) and the client's brickprimitives/. Given the client's res folder it unpacks brickdb.zip there
itself, so a folder of its own is made instead: brickdb.zip unpacked into it and brickprimitives/ linked file by
file (LU Toolbox lists the folder without following linked folders).
"""
os.makedirs(brickdb, exist_ok=True)
if not os.path.isdir(os.path.join(brickdb, "Assemblies")):
with zipfile.ZipFile(os.path.join(res, "brickdb.zip")) as archive:
archive.extractall(brickdb)
log("unpacked brickdb.zip into", brickdb)
source = os.path.join(res, "brickprimitives")
target = os.path.join(brickdb, "brickprimitives")
if os.path.isdir(source) and not os.path.isdir(target):
linked = 0
for root, _, files in os.walk(source):
folder = os.path.join(target, os.path.relpath(root, source))
os.makedirs(folder, exist_ok=True)
for name in files:
src, dst = os.path.join(root, name), os.path.join(folder, name)
if os.path.lexists(dst):
continue
try:
os.symlink(src, dst)
except OSError:
import shutil
shutil.copyfile(src, dst)
linked += 1
log("linked", linked, "brickprimitives files into", target)
def set_device(driver, device):
import bpy
if device == "hip":
# lu_batch_driver's device choice knows CPU, CUDA and OptiX; AMD GPUs through HIP the same way
try:
bpy.ops.preferences.addon_enable(module="cycles")
except Exception:
pass
cp = bpy.context.preferences.addons["cycles"].preferences
try:
cp.compute_device_type = "HIP"
driver._refresh_cycles_devices(cp)
found = False
for d in cp.devices:
d.use = d.type in ("HIP", "CPU")
found = found or d.type == "HIP"
if found:
bpy.context.scene.cycles.device = "GPU"
return "hip"
except Exception as ex:
log("HIP unavailable:", ex)
bpy.context.scene.cycles.device = "CPU"
return "cpu"
if device == "auto":
return driver.set_cycles_device_auto()
return driver.set_cycles_device_forced(device)
def addon_version(name):
import addon_utils
for module in addon_utils.modules():
if module.__name__ == name:
return ".".join(str(part) for part in module.bl_info.get("version", ()))
return ""
class Worker:
def __init__(self, args):
self.args = args
sys.path.insert(0, args.standalone)
import lu_batch_driver # LU-Toolbox-Standalone's driver: its steps, run as it runs them
self.driver = lu_batch_driver
self.device = "cpu"
def setup(self):
"""What lu_batch_driver's main does before importing, for a scene just loaded"""
import bpy
for module in ("lu_toolbox", "io_scene_niftools"):
try:
bpy.ops.preferences.addon_enable(module=module)
except Exception as ex:
log("enabling", module, "failed:", ex)
if "lu_toolbox" not in bpy.context.preferences.addons:
raise RuntimeError("the lu_toolbox add-on can't be enabled (is it in the Blender scripts folder's addons?)")
if "io_scene_niftools" not in bpy.context.preferences.addons:
raise RuntimeError("the io_scene_niftools add-on can't be enabled (is it in the Blender scripts folder's addons?)")
bpy.context.preferences.addons["lu_toolbox"].preferences.brickdbpath = self.args.brickdb
self.driver._apply_headless_patches()
self.device = set_device(self.driver, self.args.device)
self.driver.set_lu_gpu_flags(use_gpu=(self.device != "cpu"))
def reset(self):
"""Every model starts from a fresh scene, as a Blender started for it would"""
import bpy
# niftools keeps the last export operator to report through, which is gone once it has finished; a second
# export (or enabling the add-on again) would report through it and fail. Its default reporter instead.
try:
from io_scene_niftools.utils import logging as nif_logging
nif_logging.NifLog.op = nif_logging._MockOperator()
except Exception:
pass
bpy.ops.wm.read_factory_settings(use_empty=False)
self.setup()
def make(self, request):
ms = {}
step = time.perf_counter()
def lap(name):
nonlocal step
now = time.perf_counter()
ms[name] = round((now - step) * 1000)
step = now
self.reset()
lap("reset")
lods = request.get("lods") or []
lod_kwargs = {f"importLOD{n}": n in lods for n in range(4)} if lods else None
self.driver.try_import_lxf(os.path.abspath(request["input"]), op_override=None, lod_kwargs=lod_kwargs)
lap("import")
self.driver.call_op("lutb.process_model", "Process Model")
lap("process")
self.driver.ensure_vertex_colors_exist()
self.driver.call_op("lutb.bake_lighting", "Bake Lighting")
lap("bake")
output = os.path.abspath(request["output"])
os.makedirs(os.path.dirname(output), exist_ok=True)
self.driver.set_niftools_game_to_lu()
self.driver.export_nif(output)
lap("export")
if not os.path.isfile(output):
raise RuntimeError("the .nif export wrote nothing")
return ms
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--standalone", required=True)
parser.add_argument("--brickdb", required=True)
parser.add_argument("--res", default="")
parser.add_argument("--device", default="cpu", choices=["auto", "cpu", "cuda", "optix", "hip"])
args = parser.parse_args(script_args())
try:
import bpy
if args.res:
prepare_brickdb(args.brickdb, args.res)
worker = Worker(args)
worker.setup()
send({"type": "ready", "blender": bpy.app.version_string, "toolbox": addon_version("lu_toolbox"),
"niftools": addon_version("io_scene_niftools"), "device": worker.device})
except Exception as ex:
traceback.print_exc()
send({"type": "failed", "error": f"{type(ex).__name__}: {ex}"})
return 2
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except ValueError:
log("not JSON:", line[:200])
continue
if not isinstance(request, dict) or request.get("dlutb") != PROTOCOL:
log("unknown message:", line[:200])
continue
cmd = request.get("cmd")
if cmd == "quit":
break
if cmd == "ping":
send({"type": "pong", "id": request.get("id")})
continue
if cmd != "make":
send({"type": "done", "id": request.get("id"), "ok": False, "error": f"unknown command {cmd}"})
continue
log("making", request.get("input"))
try:
ms = worker.make(request)
send({"type": "done", "id": request.get("id"), "ok": True, "ms": ms})
except BaseException as ex: # SystemExit too: the driver's helpers exit on failures
traceback.print_exc()
send({"type": "done", "id": request.get("id"), "ok": False, "error": f"{type(ex).__name__}: {ex}"})
return 0
if __name__ == "__main__":
code = main()
_reply.close()
# Blender would go on to its own shutdown, which is slow with add-ons loaded and not needed
os._exit(code)