diff --git a/dUgcServer/CMakeLists.txt b/dUgcServer/CMakeLists.txt index a63912848..0babdde7d 100644 --- a/dUgcServer/CMakeLists.txt +++ b/dUgcServer/CMakeLists.txt @@ -4,6 +4,7 @@ # Render/ the software rasterizer, ambient occlusion, hidden-face removal, rays, icon parameters and pose # Formats/ the files written for the client and the dashboard (NIF, DDS, PNG, sd0) # Processing/ the job queue and workers, the CPU budget, stored files +# Toolbox/ processor=toolbox-blender: LU Toolbox itself in a headless Blender (an external program), its worker # UgcServer.cpp the server: settings, the master link, HTTP routes # The pure part of the UGC server (brick data, meshes, rendering, file formats, storage), shared with the tests @@ -22,10 +23,13 @@ set(DUGC_SOURCES "Render/UgcIconPose.cpp" "Render/UgcRays.cpp" "Render/UgcRender.cpp" + "Toolbox/UgcToolbox.cpp" + "Toolbox/UgcToolboxJob.cpp" + "Toolbox/UgcToolboxProtocol.cpp" ) add_library(dUgc STATIC ${DUGC_SOURCES}) -target_include_directories(dUgc PUBLIC "." "Bricks" "Formats" "Model" "Processing" "Render" +target_include_directories(dUgc PUBLIC "." "Bricks" "Formats" "Model" "Processing" "Render" "Toolbox" "${PROJECT_SOURCE_DIR}/thirdparty/MD5" "${PROJECT_SOURCE_DIR}/thirdparty/nlohmann") target_link_libraries(dUgc PUBLIC dCommon glm::glm tinyxml2 MD5 PRIVATE embree) # Intel Open Image Denoise, when built with DLU_OIDN (thirdparty/CMakeLists.txt): the denoise=oidn setting @@ -77,6 +81,9 @@ if(DLU_HIPRT) target_compile_definitions(dUgc PRIVATE DLU_HIPRT DLU_HIPRT_INCLUDE_DIR="${DLU_HIPRT_INCLUDE_DIR}") endif() +# The script Blender runs for processor=toolbox-blender, next to the servers (Blender is never linked in) +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Toolbox/dlu_toolbox_worker.py" "${PROJECT_BINARY_DIR}/ugc-toolbox/dlu_toolbox_worker.py" COPYONLY) + add_executable(UgcServer "UgcServer.cpp" "Processing/UgcProcessor.cpp" "Bricks/UgcCdClient.cpp") target_include_directories(UgcServer PRIVATE diff --git a/dUgcServer/Processing/UgcJobs.cpp b/dUgcServer/Processing/UgcJobs.cpp index 081510921..3c5224ba1 100644 --- a/dUgcServer/Processing/UgcJobs.cpp +++ b/dUgcServer/Processing/UgcJobs.cpp @@ -179,6 +179,7 @@ namespace UgcJobs { void ApplyOptions(Settings& settings, const UgcProcessOptions::Choice& choice) { if (const auto rays = UgcRays::Parse(choice.rays)) settings.ao.rays = settings.icon.ao.rays = *rays; if (const auto denoise = UgcRender::ParseDenoise(choice.denoise)) settings.icon.denoise = *denoise; + if (UgcProcessOptions::Contains(UgcProcessOptions::PROCESSOR, choice.processor)) settings.processor = choice.processor; } UgcProcessOptions::Choice MadeWith(const Settings& settings) { diff --git a/dUgcServer/Processing/UgcJobs.h b/dUgcServer/Processing/UgcJobs.h index c564aeb60..cc795ab40 100644 --- a/dUgcServer/Processing/UgcJobs.h +++ b/dUgcServer/Processing/UgcJobs.h @@ -16,6 +16,10 @@ #include "UgcRender.h" #include "UgcStorage.h" +namespace UgcToolbox { + class Worker; +} + /** * The work a UGC worker thread does for one item: everything from the stored LXFML (or a modular build's modules) to * the finished files. No database, network or CDClient: the main thread gathers the input and stores the result. @@ -63,6 +67,9 @@ namespace UgcJobs { UgcRender::AoOptions ao; // Bake Lighting (AO Only) UgcRender::IconOptions icon; // from the icon_* settings (UgcIconParams); presets and overrides go over it uint32_t maxBricks{}; // a model with more fails; 0: no limit + // processor: what makes a player model's files, UgcProcessOptions::PROCESSOR (native: ProcessModel; + // toolbox-blender: ProcessModelToolbox, when UgcToolbox can run, else native) + std::string processor{ UgcProcessOptions::NATIVE }; }; /** @@ -98,6 +105,17 @@ namespace UgcJobs { Outcome ProcessModel(const std::string& blob, UgcBricks::BrickLibrary& library, const Settings& settings, uint64_t seed = 0, const UgcIconParams::Values& iconValues = {}); + /** + * A player model made by LU Toolbox itself in Blender (processor=toolbox-blender, docs/UgcServer.md): its .nif is + * LU Toolbox's (import, Process Model, Bake Lighting, niftools export), checked by reading it back; the icon is + * drawn from it by the icon renderer, as a native model's is from its .nif (not denoised: there is no .nif before + * the bake). stats.json has the bricks and triangles before (the bricks' own meshes) and after (the .nif's), and + * LU Toolbox's steps' times. Blender's CPU time is charged to the calling thread (UgcThrottle::Charge), which keeps + * the CPU budget by pausing Blender. `id` names the worker's files. + */ + Outcome ProcessModelToolbox(const std::string& blob, UgcBricks::BrickLibrary& library, const Settings& settings, UgcToolbox::Worker& toolbox, + uint64_t id, const UgcIconParams::Values& iconValues = {}); + // A model's icon files (icon.png, icon.dds download) drawn from its .nif (LOD 0), its metal and glow groups by // `tagLooks` (Shaders::TagLooks); false (and `error`) when the .nif can't be read // (the groups drawn over others, `overlayTags` (Shaders::OverlayTags), left out). `plainNif`: its model.noao.nif, the diff --git a/dUgcServer/Processing/UgcThrottle.cpp b/dUgcServer/Processing/UgcThrottle.cpp index c35bdbe4b..cc8606f5c 100644 --- a/dUgcServer/Processing/UgcThrottle.cpp +++ b/dUgcServer/Processing/UgcThrottle.cpp @@ -75,6 +75,10 @@ namespace UgcThrottle { bool IsCancelled() { return g_Cancel; } void Checkpoint() { + Checkpoint({}); + } + + void Checkpoint(const std::function& pausing) { if (g_Cancel) throw Cancelled{}; const double budget = g_Budget; if (budget <= 0.0) return; @@ -99,10 +103,15 @@ namespace UgcThrottle { g_LastSleep = UnixMs(); // In short sleeps, so a cancel isn't held up by a long wait const auto until = std::chrono::steady_clock::now() + std::chrono::duration_cast(std::chrono::duration(wait)); + if (pausing) pausing(true); while (std::chrono::steady_clock::now() < until) { - if (g_Cancel) throw Cancelled{}; + if (g_Cancel) { + if (pausing) pausing(false); + throw Cancelled{}; + } std::this_thread::sleep_for(std::min(until - std::chrono::steady_clock::now(), std::chrono::milliseconds(100))); } + if (pausing) pausing(false); // Time asleep costs no CPU; don't count this call's own bookkeeping twice t_LastCpu = ThreadCpuSeconds(); } diff --git a/dUgcServer/Processing/UgcThrottle.h b/dUgcServer/Processing/UgcThrottle.h index 0581a67fb..b2036c250 100644 --- a/dUgcServer/Processing/UgcThrottle.h +++ b/dUgcServer/Processing/UgcThrottle.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include /** @@ -18,6 +19,10 @@ namespace UgcThrottle { // called, so a job stops in moments when the server shuts down. void Checkpoint(); + // The same, for work done by another process for this thread (LU Toolbox's Blender, charged with Charge first): + // `pausing` is called with true before the sleep and false after it, to stop that process meanwhile + void Checkpoint(const std::function& pausing); + // Thrown by Checkpoint while cancelled: the job is abandoned, not failed (its row stays waiting) struct Cancelled {}; // Stop (true) or allow (false) every job's work: set when the server stops, cleared when it starts diff --git a/dUgcServer/Toolbox/UgcToolbox.cpp b/dUgcServer/Toolbox/UgcToolbox.cpp new file mode 100644 index 000000000..1b0303d6b --- /dev/null +++ b/dUgcServer/Toolbox/UgcToolbox.cpp @@ -0,0 +1,504 @@ +#include "UgcToolbox.h" + +#include +#include +#include +#include +#include +#include + +#include "UgcKeys.h" + +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__linux__) +#include +#endif +extern char** environ; +#endif + +namespace UgcToolbox { + namespace { + bool IsFile(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path, ec); + } + + bool IsDirectory(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_directory(path, ec); + } + + std::string Quote(const std::filesystem::path& path) { return "\"" + path.string() + "\""; } + + double Since(std::chrono::steady_clock::time_point start) { + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + } + + // How long Blender may take to be ready: the first start unpacks the brick database and links the primitives + constexpr auto START_TIMEOUT = std::chrono::seconds(180); + // How often a waiting worker looks at Blender (the CPU budget's pace) + constexpr int PACE_MS = 100; + // After this many failed starts in a row, no new start is tried for RETRY_AFTER (each make falls back meanwhile) + constexpr uint32_t MAX_FAILED_STARTS = 3; + constexpr auto RETRY_AFTER = std::chrono::minutes(10); + } + + std::string Problem(const Config& config) { +#if defined(_WIN32) + (void)config; + return "toolbox-blender runs Blender as a worker on Linux (and other POSIX systems) only for now"; +#else + if (config.blender.empty()) return "toolbox_blender (the Blender executable) is not set"; + if (!IsFile(config.blender) || access(config.blender.c_str(), X_OK) != 0) return "toolbox_blender " + Quote(config.blender) + " is not an executable file"; + if (config.standalone.empty()) return "toolbox_standalone_dir (LU-Toolbox-Standalone) is not set"; + if (!IsFile(config.standalone / "lu_batch_driver.py")) return "toolbox_standalone_dir " + Quote(config.standalone) + " has no lu_batch_driver.py"; + if (!config.scripts.empty()) { + for (const auto* addon : { "lu_toolbox", "io_scene_niftools" }) { + if (!IsFile(config.scripts / "addons" / addon / "__init__.py")) return "toolbox_scripts_dir " + Quote(config.scripts) + " has no addons/" + addon; + } + } + if (!IsFile(config.worker)) return "the worker script " + Quote(config.worker) + " is missing (the build copies it next to the servers)"; + if (std::find(std::begin(DEVICES), std::end(DEVICES), config.device) == std::end(DEVICES)) return "toolbox_device \"" + config.device + "\" isn't cpu, cuda, optix, hip or auto"; + if (config.brickdb.empty()) return "toolbox_brickdb_dir is not set"; + if (!IsDirectory(config.brickdb / "Assemblies") && !IsFile(config.res / "brickdb.zip")) return "the client's res folder " + Quote(config.res) + " has no brickdb.zip for LU Toolbox's brick folder"; + if (config.work.empty()) return "toolbox_work_dir is not set"; + return {}; +#endif + } + + std::string_view Resolve(std::string_view wanted, const std::string& problem, std::string& why) { + why.clear(); + if (wanted != UgcProcessOptions::TOOLBOX_BLENDER) return UgcProcessOptions::NATIVE; + if (!problem.empty()) { + why = problem; + return UgcProcessOptions::NATIVE; + } + return UgcProcessOptions::TOOLBOX_BLENDER; + } + +#if defined(_WIN32) + struct Worker::Process {}; + Worker::~Worker() = default; + void Worker::Configure(const Config& config) { std::lock_guard lock(m_ConfigMutex); m_Config = config; } + Config Worker::GetConfig() const { std::lock_guard lock(m_ConfigMutex); return m_Config; } + Result Worker::Make(uint64_t, const std::string&, const std::vector&, const std::function&) { + return { false, Problem(GetConfig()) }; + } + void Worker::Stop() {} + bool Worker::StartLocked(std::string& error) { error = Problem(m_Config); return false; } + void Worker::StopLocked(bool) {} + std::string Worker::LogTail() const { return {}; } + void Worker::PublishStatus() {} + nlohmann::json Worker::Status() const { return { { "running", false }, { "problem", Problem(GetConfig()) } }; } +#else + struct Worker::Process { + pid_t pid{ -1 }; + int toWorker{ -1 }; // its stdin + int fromWorker{ -1 }; // its replies (its original stdout) + UgcToolboxProtocol::LineReader reader; + std::filesystem::path log; + double lastCpu{}; + bool paused{}; + int status{}; // its exit status once reaped + bool exited{}; + + // Its CPU time (all threads) in seconds; 0 where it can't be read + double CpuSeconds() const { +#if defined(__linux__) + std::ifstream stat("/proc/" + std::to_string(pid) + "/stat"); + std::string text; + std::getline(stat, text); + // The command is in parentheses and may hold spaces; utime and stime are fields 14 and 15 + const auto close = text.rfind(')'); + if (close == std::string::npos) return 0.0; + std::istringstream fields(text.substr(close + 2)); + std::string field; + uint64_t utime = 0, stime = 0; + for (int i = 3; i <= 15 && fields >> field; i++) { + if (i == 14) utime = std::strtoull(field.c_str(), nullptr, 10); + if (i == 15) stime = std::strtoull(field.c_str(), nullptr, 10); + } + const auto ticks = sysconf(_SC_CLK_TCK); + return ticks > 0 ? static_cast(utime + stime) / static_cast(ticks) : 0.0; +#else + return 0.0; +#endif + } + + bool Alive() { + if (exited) return false; + const auto reaped = waitpid(pid, &status, WNOHANG); + if (reaped == pid) exited = true; + return !exited; + } + + std::string ExitReason() const { + if (!exited) return "it stopped answering"; + if (WIFSIGNALED(status)) return "it was killed by signal " + std::to_string(WTERMSIG(status)); + if (WIFEXITED(status)) return "it exited with code " + std::to_string(WEXITSTATUS(status)); + return "it stopped"; + } + + void Pause(bool pause) { + if (pause == paused || exited) return; + kill(pid, pause ? SIGSTOP : SIGCONT); + paused = pause; + } + + bool Send(const nlohmann::json& message) { + const auto line = UgcToolboxProtocol::Frame(message); + size_t sent = 0; + while (sent < line.size()) { + // A socket, so a worker that is gone gives an error instead of SIGPIPE + const auto n = send(toWorker, line.data() + sent, line.size() - sent, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + sent += static_cast(n); + } + return true; + } + + /** + * The next message, waiting at most until `deadline`; `tick` is called every PACE_MS meanwhile. nullopt when the + * time is up, the worker is gone (`gone` set) or closed its side. + */ + std::optional Receive(std::chrono::steady_clock::time_point deadline, bool& gone, const std::function& tick) { + gone = false; + while (true) { + while (const auto line = reader.Next()) { + if (auto message = UgcToolboxProtocol::Parse(*line)) return message; + } + if (std::chrono::steady_clock::now() >= deadline) return std::nullopt; + pollfd fd{ fromWorker, POLLIN, 0 }; + const auto ready = poll(&fd, 1, PACE_MS); + if (ready > 0) { + char buffer[4096]; + const auto n = read(fromWorker, buffer, sizeof(buffer)); + if (n > 0) { + reader.Feed(std::string_view(buffer, static_cast(n))); + continue; + } + if (n == 0 || (errno != EINTR && errno != EAGAIN)) { + // Its side closed: it exited (or is about to) + for (int i = 0; i < 20 && Alive(); i++) std::this_thread::sleep_for(std::chrono::milliseconds(50)); + gone = true; + return std::nullopt; + } + } + if (!Alive()) { + gone = true; + return std::nullopt; + } + if (tick) tick(); + } + } + + void Close() { + if (toWorker >= 0) close(toWorker); + if (fromWorker >= 0) close(fromWorker); + toWorker = fromWorker = -1; + } + }; + + Worker::~Worker() { + std::lock_guard lock(m_JobMutex); + StopLocked(false); + } + + void Worker::Configure(const Config& config) { + std::lock_guard lock(m_ConfigMutex); + m_Config = config; + } + + Config Worker::GetConfig() const { + std::lock_guard lock(m_ConfigMutex); + return m_Config; + } + + std::string Worker::LogTail() const { + if (!m_Process) return {}; + std::ifstream file(m_Process->log, std::ios::binary); + if (!file) return {}; + file.seekg(0, std::ios::end); + const auto size = static_cast(file.tellg()); + file.seekg(std::max(0, size - 4096)); + std::string text((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + // The last few lines with something in them + std::deque lines; + std::istringstream stream(text); + std::string line; + while (std::getline(stream, line)) { + if (line.find_first_not_of(" \t\r") == std::string::npos) continue; + lines.push_back(line); + if (lines.size() > 6) lines.pop_front(); + } + std::string out; + for (const auto& kept : lines) out += (out.empty() ? "" : " | ") + kept; + return out.substr(0, 600); + } + + bool Worker::StartLocked(std::string& error) { + m_Running = GetConfig(); + const auto& config = m_Running; + if (error = Problem(config); !error.empty()) return false; + std::error_code ec; + std::filesystem::create_directories(config.work, ec); + if (ec) { + error = "can't make toolbox_work_dir " + Quote(config.work) + ": " + ec.message(); + return false; + } + auto process = std::make_unique(); + process->log = config.work / "blender.log"; + int input[2]{ -1, -1 }, output[2]{ -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, input) != 0 || socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, output) != 0) { + error = std::string("socketpair: ") + std::strerror(errno); + for (const int fd : { input[0], input[1], output[0], output[1] }) if (fd >= 0) close(fd); + return false; + } + const int logFd = open(process->log.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + + std::vector args{ config.blender.string(), "-b", "--factory-startup", "-t", std::to_string(std::max(config.threads, 1)), + "--python", config.worker.string(), "--", "--standalone", config.standalone.string(), "--brickdb", config.brickdb.string(), + "--res", config.res.string(), "--device", config.device }; + std::vector argv; + for (auto& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + // The environment, with BLENDER_USER_SCRIPTS when the add-ons are in a folder of their own (made before fork: the + // child may only make async-signal-safe calls) + std::vector environment; + for (char** entry = environ; entry && *entry; entry++) { + if (!config.scripts.empty() && std::strncmp(*entry, "BLENDER_USER_SCRIPTS=", 21) == 0) continue; + environment.emplace_back(*entry); + } + if (!config.scripts.empty()) environment.push_back("BLENDER_USER_SCRIPTS=" + config.scripts.string()); + std::vector envp; + for (auto& entry : environment) envp.push_back(entry.data()); + envp.push_back(nullptr); + const int nice = config.nice; + rlimit files{}; + const int maxFd = getrlimit(RLIMIT_NOFILE, &files) == 0 && files.rlim_cur != RLIM_INFINITY ? static_cast(std::min(files.rlim_cur, 65536)) : 4096; + + const pid_t pid = fork(); + if (pid == 0) { + // The child: only async-signal-safe calls until exec + dup2(input[1], 0); + dup2(output[1], 1); + if (logFd >= 0) dup2(logFd, 2); + // None of the server's sockets and files (its listening port above all) go to Blender + for (int fd = 3; fd < maxFd; fd++) close(fd); +#if defined(__linux__) + // Blender goes when the UGC server goes, however it goes + prctl(PR_SET_PDEATHSIG, SIGKILL); +#endif + if (nice > 0) setpriority(PRIO_PROCESS, 0, nice); + execve(argv[0], argv.data(), envp.data()); + _exit(127); + } + close(input[1]); + close(output[1]); + if (logFd >= 0) close(logFd); + if (pid < 0) { + error = std::string("fork: ") + std::strerror(errno); + close(input[0]); + close(output[0]); + return false; + } + process->pid = pid; + process->toWorker = input[0]; + process->fromWorker = output[0]; + m_Process = process.release(); + m_Jobs = 0; + m_Starts++; + + bool gone = false; + const auto deadline = std::chrono::steady_clock::now() + START_TIMEOUT; + while (true) { + const auto message = m_Process->Receive(deadline, gone, {}); + if (!message) { + error = gone ? "Blender stopped while starting (" + m_Process->ExitReason() + ")" : "Blender wasn't ready in " + std::to_string(START_TIMEOUT.count()) + " s"; + break; + } + const auto type = UgcToolboxProtocol::TypeOf(*message); + if (type == UgcToolboxProtocol::eType::READY) { + m_Versions = "Blender " + message->value("blender", std::string("?")) + ", LU Toolbox " + message->value("toolbox", std::string("?")) + + ", niftools " + message->value("niftools", std::string("?")) + ", " + message->value("device", std::string("?")); + m_FailedStarts = 0; + return true; + } + if (type == UgcToolboxProtocol::eType::FAILED) { + error = "the worker couldn't start: " + message->value("error", std::string("no reason")); + break; + } + } + const auto tail = LogTail(); + if (!tail.empty()) error += " (Blender's log: " + tail + ")"; + StopLocked(true); + return false; + } + + void Worker::StopLocked(bool kill) { + if (!m_Process) return; + auto& process = *m_Process; + process.Pause(false); + if (!kill && process.Alive()) { + process.Send({ { "cmd", "quit" } }); + for (int i = 0; i < 100 && process.Alive(); i++) std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + if (process.Alive()) { + ::kill(process.pid, SIGKILL); + waitpid(process.pid, &process.status, 0); + process.exited = true; + } + process.Close(); + delete m_Process; + m_Process = nullptr; + m_Jobs = 0; + } + + void Worker::Stop() { + std::lock_guard lock(m_JobMutex); + StopLocked(false); + PublishStatus(); + } + + void Worker::PublishStatus() { + nlohmann::json status{ { "running", m_Process != nullptr }, { "pid", m_Process ? m_Process->pid : 0 }, { "models", m_Jobs }, { "starts", m_Starts }, + { "versions", m_Versions }, { "lastError", m_LastError } }; + std::lock_guard lock(m_StatusMutex); + m_Status = std::move(status); + } + + nlohmann::json Worker::Status() const { + const auto config = GetConfig(); + std::lock_guard lock(m_StatusMutex); + auto status = m_Status; + status["problem"] = Problem(config); + status["device"] = config.device; + status["threads"] = config.threads; + return status; + } + + Result Worker::Make(uint64_t id, const std::string& lxfml, const std::vector& lods, const std::function& pace) { + Result result; + const auto asked = std::chrono::steady_clock::now(); + std::unique_lock lock(m_JobMutex); + // Given up while waiting for Blender (the server stopping): before starting one + if (pace) pace(0.0, [](bool) {}); + const auto config = GetConfig(); + // New settings, or it made its share of models: a new Blender + if (m_Process && (!(config == m_Running) || (m_Running.restartAfterJobs > 0 && m_Jobs >= m_Running.restartAfterJobs) || !m_Process->Alive())) StopLocked(false); + if (!m_Process) { + if (m_FailedStarts >= MAX_FAILED_STARTS && std::chrono::steady_clock::now() < m_RetryAfter) { + result.error = "Blender failed to start " + std::to_string(m_FailedStarts) + " times; trying again later (" + m_LastError + ")"; + return result; + } + std::string error; + result.started = true; + if (!StartLocked(error)) { + m_FailedStarts++; + if (m_FailedStarts >= MAX_FAILED_STARTS) m_RetryAfter = std::chrono::steady_clock::now() + RETRY_AFTER; + m_LastError = error; + result.error = "Blender couldn't be started: " + error; + PublishStatus(); + return result; + } + PublishStatus(); + } + result.waitedMs = Since(asked); + result.blender = m_Versions; + auto& process = *m_Process; + + const auto base = m_Running.work / ("model-" + std::to_string(id)); + const auto input = base.string() + ".lxfml", output = base.string() + ".nif"; + std::error_code ec; + std::filesystem::remove(output, ec); + { + std::ofstream file(input, std::ios::binary); + file.write(lxfml.data(), static_cast(lxfml.size())); + if (!file) { + result.error = "can't write " + input; + return result; + } + } + const auto cleanUp = [&] { + std::filesystem::remove(input, ec); + std::filesystem::remove(output, ec); + }; + + const auto requestId = m_NextId++; + if (!process.Send(UgcToolboxProtocol::MakeRequest(requestId, input, output, lods))) { + result.error = "Blender isn't taking work (" + process.ExitReason() + ")"; + StopLocked(true); + cleanUp(); + PublishStatus(); + return result; + } + const double cpuStart = process.CpuSeconds(); + process.lastCpu = cpuStart; + const Pause pause = [&process](bool paused) { process.Pause(paused); }; + const auto tick = [&] { + if (!pace) return; + const double cpu = process.CpuSeconds(); + const double used = std::max(0.0, cpu - process.lastCpu); + process.lastCpu = cpu; + pace(used, pause); + }; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(m_Running.timeoutSeconds, 1)); + try { + bool gone = false; + while (true) { + const auto message = process.Receive(deadline, gone, tick); + if (!message) { + result.blenderCpuSeconds = std::max(0.0, process.lastCpu - cpuStart); + const auto tail = LogTail(); + if (gone) { + result.error = "Blender stopped while making the model (" + process.ExitReason() + ")"; + } else { + result.error = "LU Toolbox took longer than toolbox_timeout_seconds (" + std::to_string(m_Running.timeoutSeconds) + " s)"; + } + if (!tail.empty()) result.error += "; Blender's log: " + tail; + StopLocked(true); + break; + } + const auto done = UgcToolboxProtocol::ParseDone(*message); + if (!done || done->id != requestId) continue; + process.Pause(false); + result.blenderCpuSeconds = std::max(0.0, process.CpuSeconds() - cpuStart); + m_Jobs++; + result.ms = nlohmann::json::object(); + for (const auto& [name, ms] : done->ms) result.ms[name] = ms; + if (!done->ok) { + result.error = "LU Toolbox failed: " + done->error; + break; + } + std::ifstream file(output, std::ios::binary); + result.nif.assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + if (result.nif.empty()) { + result.error = "LU Toolbox wrote no .nif"; + break; + } + result.ok = true; + break; + } + } catch (...) { + // Given up (the server stopping): Blender stops too, mid-model + StopLocked(true); + cleanUp(); + PublishStatus(); + throw; + } + if (!result.ok) m_LastError = result.error; + cleanUp(); + PublishStatus(); + return result; + } +#endif +} diff --git a/dUgcServer/Toolbox/UgcToolbox.h b/dUgcServer/Toolbox/UgcToolbox.h new file mode 100644 index 000000000..6e96aa67c --- /dev/null +++ b/dUgcServer/Toolbox/UgcToolbox.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "json.hpp" +#include "UgcToolboxProtocol.h" + +/** + * The processor=toolbox-blender option (docs/UgcServer.md, "LU Toolbox in Blender"): models made by LU Toolbox itself + * in a headless Blender, an external program the UGC server starts and talks to (never linked in). One Blender stays up + * and makes one model after another (dlu_toolbox_worker.py, which runs LU-Toolbox-Standalone's steps); it is started + * when first needed, again after it crashes or hangs, and after `restartAfterJobs` models. The UGC workers take turns + * on it. Linux and other POSIX systems only for now. + */ +namespace UgcToolbox { + struct Config { + std::filesystem::path blender; // toolbox_blender: the Blender executable + std::filesystem::path standalone; // toolbox_standalone_dir: LU-Toolbox-Standalone (lu_batch_driver.py) + std::filesystem::path scripts; // toolbox_scripts_dir: a Blender scripts folder whose addons/ has lu_toolbox and + // io_scene_niftools (empty: the add-ons installed in Blender's own user folder) + std::filesystem::path worker; // dlu_toolbox_worker.py (next to the servers, ugc-toolbox/) + std::filesystem::path brickdb; // toolbox_brickdb_dir: LU Toolbox's brick folder, made from `res` by the worker + std::filesystem::path res; // the client's res folder + std::filesystem::path work; // toolbox_work_dir: the LXFML and .nif of the model being made, Blender's log + std::string device{ "cpu" }; // toolbox_device: cpu, cuda, optix, hip or auto (Cycles, for the bakes) + uint32_t threads{ 4 }; // toolbox_threads: Blender's threads (-t) + uint32_t timeoutSeconds{ 1800 }; // toolbox_timeout_seconds: a model taking longer fails and Blender is restarted + uint32_t restartAfterJobs{ 100 }; // Blender is started again after this many models (0: never) + int nice{}; // worker_nice + + bool operator==(const Config&) const = default; + }; + + inline constexpr std::string_view DEVICES[] = { "cpu", "cuda", "optix", "hip", "auto" }; + + /** + * Why toolbox-blender can't be used with `config` (empty: it can, as far as can be told without starting Blender): a + * missing setting, program, folder or add-on. Reads the filesystem only. + */ + std::string Problem(const Config& config); + + /** + * The processor a make uses: `wanted` (UgcProcessOptions::PROCESSOR) when it can be, else native; `why` gets the + * reason when it falls back. Unknown names are native too. + */ + std::string_view Resolve(std::string_view wanted, const std::string& problem, std::string& why); + + struct Result { + bool ok{}; + std::string error; + std::string nif; // the .nif made + nlohmann::json ms; // the worker's steps' times: reset, import, process, bake, export + double blenderCpuSeconds{}; // Blender's CPU time for it (all its threads) + double waitedMs{}; // waiting for Blender (another model, or it starting) + bool started{}; // Blender was started for it + std::string blender; // "Blender 3.1.2, LU Toolbox 2.4.0, niftools 0.1.1, cpu" + }; + + /** + * The Blender worker. Make is called by the UGC workers (any thread; they take turns); Configure and Stop by the main + * thread. + */ + class Worker { + public: + Worker() = default; + ~Worker(); + Worker(const Worker&) = delete; + Worker& operator=(const Worker&) = delete; + + // New settings: used from the next model on (Blender is started again when they changed) + void Configure(const Config& config); + Config GetConfig() const; + + /** + * Makes a model's .nif from its LXFML with LU Toolbox, importing `lods`. `pace` is called about every 100 ms while + * Blender works, with the CPU seconds it used since the last call and a function that pauses (true) or resumes + * (false) it: the CPU budget (UgcThrottle) is kept through it, and it may throw to give up (the server stopping), + * which stops Blender. `id` names the files in the work folder. + */ + using Pause = std::function; + Result Make(uint64_t id, const std::string& lxfml, const std::vector& lods, + const std::function& pace = {}); + + // Stops Blender (asks it to quit, then kills it); the next Make starts it again + void Stop(); + + // For /status: running, pid, models made by this Blender, starts, the versions it reported, the last error + nlohmann::json Status() const; + + private: + struct Process; + bool StartLocked(std::string& error); + void StopLocked(bool kill); + std::string LogTail() const; + + mutable std::mutex m_ConfigMutex; + Config m_Config; + std::mutex m_JobMutex; // one model at a time; guards everything below + Process* m_Process{}; + Config m_Running; // the settings the running Blender was started with + uint64_t m_Jobs{}; // made by the running Blender + uint64_t m_Starts{}; + uint64_t m_NextId{ 1 }; + std::string m_Versions; + std::string m_LastError; + uint32_t m_FailedStarts{}; + std::chrono::steady_clock::time_point m_RetryAfter{}; + mutable std::mutex m_StatusMutex; + nlohmann::json m_Status = nlohmann::json::object(); + void PublishStatus(); + }; +} diff --git a/dUgcServer/Toolbox/UgcToolboxJob.cpp b/dUgcServer/Toolbox/UgcToolboxJob.cpp new file mode 100644 index 000000000..36a20dffe --- /dev/null +++ b/dUgcServer/Toolbox/UgcToolboxJob.cpp @@ -0,0 +1,146 @@ +// UgcJobs::ProcessModelToolbox: a player model made by LU Toolbox in Blender (UgcToolbox), checked and turned into the +// files a native make writes + +#include +#include +#include + +#include "json.hpp" + +#include "NifFile.h" +#include "UgcJobs.h" +#include "UgcModel.h" +#include "UgcThrottle.h" +#include "UgcToolbox.h" + +namespace UgcJobs { + namespace { + double Since(std::chrono::steady_clock::time_point start) { + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + } + + size_t Triangles(const NifFile::Model& model) { + size_t triangles = 0; + for (const auto& mesh : model.meshes) triangles += mesh.indices.size() / 3; + return triangles; + } + } + + Outcome ProcessModelToolbox(const std::string& blob, UgcBricks::BrickLibrary& library, const Settings& settings, UgcToolbox::Worker& toolbox, + uint64_t id, const UgcIconParams::Values& iconValues) { + Outcome outcome; + const auto started = std::chrono::steady_clock::now(); + const auto lxfml = LxfmlFromBlob(blob); + if (lxfml.empty()) { + outcome.error = "the stored LXFML can't be read"; + return outcome; + } + std::string error; + const auto parts = UgcModel::ParseLxfml(lxfml, error); + if (parts.empty()) { + outcome.error = error; + outcome.empty = UgcModel::HasNoBricks(lxfml); + return outcome; + } + if (settings.maxBricks > 0 && parts.size() > settings.maxBricks) { + outcome.error = "the model has " + std::to_string(parts.size()) + " bricks, more than max_model_bricks (" + std::to_string(settings.maxBricks) + ")"; + return outcome; + } + auto lods = settings.lods; + std::erase_if(lods, [](uint32_t lod) { return lod > 3; }); + std::sort(lods.begin(), lods.end()); + lods.erase(std::unique(lods.begin(), lods.end()), lods.end()); + if (lods.empty()) lods.push_back(0); + + // The triangles before: the bricks' own meshes, as LU Toolbox's importer reads them from the same files + nlohmann::json stats; + stats["version"] = 1; + stats["bricks"] = parts.size(); + auto& lodStats = stats["lods"] = nlohmann::json::array(); + auto step = std::chrono::steady_clock::now(); + for (size_t i = 0; i < lods.size(); i++) { + auto options = settings.build; + options.seed = id; + options.lod = lods[i]; + const auto model = UgcModel::Build(parts, library, options); + if (i == 0 && !model.missingDesigns.empty()) { + outcome.note = "no geometry for design(s)"; + for (const auto design : model.missingDesigns) outcome.note += " " + std::to_string(design); + stats["missingDesigns"] = model.missingDesigns; + } + if (i == 0 && model.Empty()) { + outcome.error = "none of the model's bricks have geometry"; + if (!outcome.note.empty()) outcome.error += " (" + outcome.note + ")"; + return outcome; + } + lodStats.push_back({ { "lod", lods[i] }, { "opaqueBefore", model.opaque.TriangleCount() }, { "transparent", model.transparent.TriangleCount() } }); + } + const double countMs = Since(step); + + // LU Toolbox makes the .nif. Blender's CPU time is this thread's (the budget, the make's CPU time), and the + // budget is kept by pausing Blender. + step = std::chrono::steady_clock::now(); + const auto made = toolbox.Make(id, lxfml, lods, [](double cpuSeconds, const UgcToolbox::Worker::Pause& pause) { + UgcThrottle::Charge(cpuSeconds); + UgcThrottle::Checkpoint(pause); + }); + const double toolboxMs = Since(step); + if (!made.ok) { + outcome.error = made.error; + return outcome; + } + + // Read back as the icon renderer and the dashboard read it: every level, for the triangles after + for (size_t i = 0; i < lods.size(); i++) { + std::string nifError; + const auto read = NifFile::Parse(made.nif, static_cast(i), nifError); + if (!read) { + outcome.error = "LU Toolbox's .nif can't be read: " + nifError; + return outcome; + } + auto& entry = lodStats[i]; + const auto triangles = Triangles(*read); + const auto transparent = entry.value("transparent", size_t{ 0 }); + entry["opaqueAfter"] = triangles > transparent ? triangles - transparent : 0; + entry["trianglesInNif"] = triangles; + entry["shapes"] = read->meshes.size(); + size_t vertices = 0; + for (const auto& mesh : read->meshes) vertices += mesh.positions.size() / 3; + entry["vertices"] = vertices; + if (i == 0 && read->meshes.empty()) { + outcome.error = "LU Toolbox's .nif has no meshes"; + return outcome; + } + } + AddDownload(outcome.files, "model.nif", made.nif); + + // The icon from the .nif, as a native model's (its baked light as it is: no denoising without a .nif before the bake) + const auto iconStart = std::chrono::steady_clock::now(); + auto iconOptions = settings.icon; + UgcIconParams::Apply(iconOptions, iconValues); + iconOptions.denoise = UgcRender::eDenoise::OFF; + std::string nifError; + if (!IconFromNif(made.nif, iconOptions, outcome.files, nifError, settings.shaders.TagLooks(), settings.shaders.OverlayTags())) { + outcome.error = "LU Toolbox's .nif can't be read back for the icon: " + nifError; + return outcome; + } + const double iconMs = Since(iconStart); + + // The steps as the native ones are named, so the comparison lines up: Process Model (hidden faces removed, and + // LU Toolbox's other preparations) as hidden surfaces, Bake Lighting as ambient occlusion + const auto part = [&made](const char* name) { return std::lround(made.ms.value(name, 0.0)); }; + stats["ms"] = { { "build", part("import") }, { "hiddenSurfaces", part("process") }, { "ambientOcclusion", part("bake") }, { "export", part("export") }, + { "reset", part("reset") }, { "icon", std::lround(iconMs) }, { "count", std::lround(countMs) }, { "toolbox", std::lround(toolboxMs) }, + { "waited", std::lround(made.waitedMs) }, { "blenderCpu", std::lround(made.blenderCpuSeconds * 1000.0) }, { "total", std::lround(Since(started)) } }; + const auto& config = toolbox.GetConfig(); + stats["settings"] = { { "processor", std::string(UgcProcessOptions::TOOLBOX_BLENDER) }, { "toolbox", made.blender }, { "device", config.device }, + { "threads", config.threads } }; + if (made.started) stats["blenderStarted"] = true; + outcome.options = std::string(UgcProcessOptions::TOOLBOX_BLENDER); + outcome.aoBaked = true; + outcome.stats = stats.dump(); + outcome.files["stats.json"] = outcome.stats; + outcome.ok = true; + return outcome; + } +} diff --git a/dUgcServer/Toolbox/UgcToolboxProtocol.cpp b/dUgcServer/Toolbox/UgcToolboxProtocol.cpp new file mode 100644 index 000000000..2ff1b3f17 --- /dev/null +++ b/dUgcServer/Toolbox/UgcToolboxProtocol.cpp @@ -0,0 +1,82 @@ +#include "UgcToolboxProtocol.h" + +namespace UgcToolboxProtocol { + std::string Frame(nlohmann::json message) { + message["dlutb"] = VERSION; + // dump() escapes control characters, so a message never has a newline of its own + return message.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace) + "\n"; + } + + nlohmann::json MakeRequest(uint64_t id, const std::string& input, const std::string& output, const std::vector& lods) { + return { { "cmd", "make" }, { "id", id }, { "input", input }, { "output", output }, { "lods", lods } }; + } + + void LineReader::Feed(std::string_view data) { + // Drop what was read already before growing + if (m_Start > 0 && m_Start >= m_Buffer.size() / 2) { + m_Buffer.erase(0, m_Start); + m_Start = 0; + } + m_Buffer.append(data); + } + + std::optional LineReader::Next() { + while (true) { + const auto end = m_Buffer.find('\n', m_Start); + if (end == std::string::npos) { + // A line longer than a message can be: dropped up to its newline + if (Pending() > MAX_LINE) { + if (!m_Skipping) m_Overflows++; + m_Skipping = true; + m_Buffer.clear(); + m_Start = 0; + } + return std::nullopt; + } + const auto start = m_Start; + m_Start = end + 1; + if (m_Skipping || end - start > MAX_LINE) { + if (!m_Skipping) m_Overflows++; + m_Skipping = false; + continue; + } + auto line = m_Buffer.substr(start, end - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + return line; + } + } + + std::optional Parse(std::string_view line) { + auto parsed = nlohmann::json::parse(line, nullptr, false); + if (!parsed.is_object()) return std::nullopt; + const auto version = parsed.find("dlutb"); + if (version == parsed.end() || !version->is_number_integer() || version->get() != VERSION) return std::nullopt; + return parsed; + } + + eType TypeOf(const nlohmann::json& message) { + const auto type = message.value("type", std::string()); + if (type == "ready") return eType::READY; + if (type == "failed") return eType::FAILED; + if (type == "done") return eType::DONE; + if (type == "pong") return eType::PONG; + return eType::OTHER; + } + + std::optional ParseDone(const nlohmann::json& message) { + if (TypeOf(message) != eType::DONE) return std::nullopt; + const auto id = message.find("id"); + if (id == message.end() || !id->is_number_unsigned()) return std::nullopt; + Done done; + done.id = id->get(); + done.ok = message.value("ok", false); + done.error = message.contains("error") && message["error"].is_string() ? message["error"].get() : std::string(); + if (!done.ok && done.error.empty()) done.error = "the worker gave no reason"; + if (const auto ms = message.find("ms"); ms != message.end() && ms->is_object()) { + for (const auto& [name, value] : ms->items()) { + if (value.is_number()) done.ms.emplace_back(name, value.get()); + } + } + return done; + } +} diff --git a/dUgcServer/Toolbox/UgcToolboxProtocol.h b/dUgcServer/Toolbox/UgcToolboxProtocol.h new file mode 100644 index 000000000..fcbfa2137 --- /dev/null +++ b/dUgcServer/Toolbox/UgcToolboxProtocol.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "json.hpp" + +/** + * The messages between the UGC server and its LU Toolbox worker (dlu_toolbox_worker.py, running in Blender): one JSON + * object per line, each with "dlutb": VERSION. Pure, so the framing is tested without Blender. + */ +namespace UgcToolboxProtocol { + inline constexpr int VERSION = 1; + // A line longer than this is not a message (the worker's replies are a few hundred bytes) + inline constexpr size_t MAX_LINE = 1024 * 1024; + + // A message as its line: compact JSON with "dlutb" set, and a newline + std::string Frame(nlohmann::json message); + + // The request to make one model: `input` the LXFML file, `output` the .nif to write, `lods` the brickprimitives + // levels to import (LU Toolbox's importLOD0..3) + nlohmann::json MakeRequest(uint64_t id, const std::string& input, const std::string& output, const std::vector& lods); + + /** + * Splits what is read from the worker into lines, however it arrives (parts of lines, many at once). A line longer + * than MAX_LINE is dropped up to its newline and counted in Overflows. + */ + class LineReader { + public: + void Feed(std::string_view data); + // The next whole line without its newline (and a '\r' before it), or nullopt until one has arrived + std::optional Next(); + size_t Pending() const { return m_Buffer.size() - m_Start; } + size_t Overflows() const { return m_Overflows; } + + private: + std::string m_Buffer; + size_t m_Start{}; + bool m_Skipping{}; // inside a line that was too long + size_t m_Overflows{}; + }; + + // A line as a message: a JSON object with this "dlutb" version; nullopt for anything else (blank lines too) + std::optional Parse(std::string_view line); + + enum class eType { READY, FAILED, DONE, PONG, OTHER }; + eType TypeOf(const nlohmann::json& message); + + // A "done" reply: whether it made the model, why not, and its steps' times in milliseconds + struct Done { + uint64_t id{}; + bool ok{}; + std::string error; + std::vector> ms; // reset, import, process, bake, export + }; + std::optional ParseDone(const nlohmann::json& message); +} diff --git a/dUgcServer/Toolbox/dlu_toolbox_worker.py b/dUgcServer/Toolbox/dlu_toolbox_worker.py new file mode 100644 index 000000000..83e638f8c --- /dev/null +++ b/dUgcServer/Toolbox/dlu_toolbox_worker.py @@ -0,0 +1,244 @@ +# 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 --python dlu_toolbox_worker.py -- \ +# --standalone --brickdb --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) diff --git a/tests/dUgcTests/CMakeLists.txt b/tests/dUgcTests/CMakeLists.txt index d5e062e2a..af32aeae1 100644 --- a/tests/dUgcTests/CMakeLists.txt +++ b/tests/dUgcTests/CMakeLists.txt @@ -1,8 +1,9 @@ -add_executable(dUgcTests "UgcTests.cpp") +add_executable(dUgcTests "UgcTests.cpp" "UgcToolboxTests.cpp") target_include_directories(dUgcTests PRIVATE "${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables") target_link_libraries(dUgcTests ${COMMON_LIBRARIES} dUgc GTest::gtest_main) gtest_discover_tests(dUgcTests) -target_compile_definitions(dUgcTests PRIVATE UGC_POSE_FIXTURE="${CMAKE_CURRENT_SOURCE_DIR}/ugc-pose-fixture.json") +target_compile_definitions(dUgcTests PRIVATE UGC_POSE_FIXTURE="${CMAKE_CURRENT_SOURCE_DIR}/ugc-pose-fixture.json" + DLU_TOOLBOX_WORKER="${PROJECT_SOURCE_DIR}/dUgcServer/Toolbox/dlu_toolbox_worker.py") # The dashboard's pose editor math (static/js/ugc-pose-math.js) against the same fixture, when node is there find_program(NODE_EXECUTABLE node) diff --git a/tests/dUgcTests/UgcToolboxTests.cpp b/tests/dUgcTests/UgcToolboxTests.cpp new file mode 100644 index 000000000..c97ae4e30 --- /dev/null +++ b/tests/dUgcTests/UgcToolboxTests.cpp @@ -0,0 +1,385 @@ +// processor=toolbox-blender: the option, the fallback, the worker protocol and the Blender worker's process handling +// (with a stand-in for Blender), and LU Toolbox itself when Blender and LU Toolbox are there (skipped otherwise) + +#include + +#include +#include +#include +#include +#include + +#include "NifFile.h" +#include "UgcBricks.h" +#include "UgcJobs.h" +#include "UgcKeys.h" +#include "UgcToolbox.h" +#include "UgcToolboxProtocol.h" +#include "json.hpp" + +namespace { + std::filesystem::path Folder(const std::string& name) { + const auto* test = ::testing::UnitTest::GetInstance()->current_test_info(); + auto path = std::filesystem::temp_directory_path() / ("dlu_toolbox_test_" + name + "_" + (test ? std::string(test->name()) : std::string()) + "_" + std::to_string(::getpid())); + std::filesystem::remove_all(path); + std::filesystem::create_directories(path); + return path; + } + + void WriteFile(const std::filesystem::path& path, const std::string& text) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + } + + std::optional FindProgram(const std::string& name) { + const char* path = std::getenv("PATH"); + if (!path) return std::nullopt; + std::string paths(path); + size_t start = 0; + while (start <= paths.size()) { + auto end = paths.find(':', start); + if (end == std::string::npos) end = paths.size(); + const auto candidate = std::filesystem::path(paths.substr(start, end - start)) / name; + if (::access(candidate.c_str(), X_OK) == 0) return candidate; + start = end + 1; + } + return std::nullopt; + } + + // Everything toolbox-blender needs, with `blender` as the executable (a stand-in unless given) + UgcToolbox::Config LayOut(const std::filesystem::path& root, const std::filesystem::path& blender) { + UgcToolbox::Config config; + config.blender = blender; + config.standalone = root / "standalone"; + WriteFile(config.standalone / "lu_batch_driver.py", "# LU-Toolbox-Standalone\n"); + config.scripts = root / "scripts"; + WriteFile(config.scripts / "addons" / "lu_toolbox" / "__init__.py", ""); + WriteFile(config.scripts / "addons" / "io_scene_niftools" / "__init__.py", ""); + config.worker = root / "dlu_toolbox_worker.py"; + WriteFile(config.worker, "# worker\n"); + config.res = root / "res"; + WriteFile(config.res / "brickdb.zip", "PK"); + config.brickdb = root / "brickdb"; + config.work = root / "work"; + config.timeoutSeconds = 30; + return config; + } + + // A stand-in for Blender running dlu_toolbox_worker.py: the same protocol, and it "makes" a .nif from the LXFML's + // text. An LXFML saying CRASH makes it exit, HANG makes it stop answering, FAIL makes LU Toolbox fail. + const char* FAKE_BLENDER = R"(#!/usr/bin/env python3 +import json, os, sys, time +out = os.fdopen(os.dup(1), "w") +os.dup2(2, 1) +def send(m): + m["dlutb"] = 1 + out.write(json.dumps(m) + "\n"); out.flush() +print("blender chatter that must not reach the replies") +send({"type": "ready", "blender": "fake", "toolbox": "2.4.0", "niftools": "0.1.1", "device": sys.argv[sys.argv.index("--device") + 1]}) +for line in sys.stdin: + request = json.loads(line) + if request.get("cmd") == "quit": + break + text = open(request["input"]).read() + if "CRASH" in text: + os._exit(3) + if "HANG" in text: + time.sleep(3600) + if "FAIL" in text: + send({"type": "done", "id": request["id"], "ok": False, "error": "Process Model failed"}) + continue + sys.stdout.write("partial line without a newline ") + sys.stdout.flush() + open(request["output"], "w").write("NIF:" + text + ":" + ",".join(str(l) for l in request["lods"])) + send({"type": "done", "id": request["id"], "ok": True, "ms": {"process": 5, "bake": 2}}) +)"; +} + +TEST(UgcToolboxOptions, ProcessorIsAnOption) { + UgcProcessOptions::Choice choice; + ASSERT_TRUE(UgcProcessOptions::Parse("toolbox-blender", choice)); + EXPECT_EQ(choice.processor, "toolbox-blender"); + EXPECT_TRUE(choice.rays.empty()); + EXPECT_EQ(UgcProcessOptions::ToString(choice), "toolbox-blender"); + ASSERT_TRUE(UgcProcessOptions::Parse("toolbox-blender oidn,embree", choice)); + EXPECT_EQ(UgcProcessOptions::ToString(choice), "embree oidn toolbox-blender"); + ASSERT_TRUE(UgcProcessOptions::Parse("native", choice)); + EXPECT_EQ(UgcProcessOptions::ToString(choice), "native"); + EXPECT_FALSE(UgcProcessOptions::Parse("native toolbox-blender", choice)); // two processors + EXPECT_FALSE(UgcProcessOptions::Parse("blender", choice)); + // Options stored before there was a processor still read the same; the retired hidden-face method "toolbox" is + // not the processor + for (const auto* old : { "embree off", "embree toolbox off", "builtin fast", "hiprt oidn", "" }) { + ASSERT_TRUE(UgcProcessOptions::Parse(old, choice)) << old; + EXPECT_TRUE(choice.processor.empty()) << old; + } + ASSERT_TRUE(UgcProcessOptions::Parse("embree toolbox off", choice)); + EXPECT_EQ(UgcProcessOptions::ToString(choice), "embree off"); + + // Applied over the settings; left out, the setting's stays + UgcJobs::Settings settings; + EXPECT_EQ(settings.processor, "native"); + ASSERT_TRUE(UgcProcessOptions::Parse("toolbox-blender", choice)); + UgcJobs::ApplyOptions(settings, choice); + EXPECT_EQ(settings.processor, "toolbox-blender"); + ASSERT_TRUE(UgcProcessOptions::Parse("oidn", choice)); + UgcJobs::ApplyOptions(settings, choice); + EXPECT_EQ(settings.processor, "toolbox-blender"); + ASSERT_TRUE(UgcProcessOptions::Parse("native", choice)); + UgcJobs::ApplyOptions(settings, choice); + EXPECT_EQ(settings.processor, "native"); + // A native make records what it did as before (the processor isn't named), so old and new rows compare + EXPECT_EQ(UgcProcessOptions::ToString(UgcJobs::MadeWith(settings)), "embree off"); +} + +TEST(UgcToolboxOptions, FallsBackToNativeAndSaysWhy) { + const auto root = Folder("fallback"); + std::string why; + // Nothing set up: not usable, and the reason names the setting + UgcToolbox::Config config; + auto problem = UgcToolbox::Problem(config); +#if defined(_WIN32) + EXPECT_FALSE(problem.empty()); +#else + EXPECT_NE(problem.find("toolbox_blender"), std::string::npos) << problem; + EXPECT_EQ(UgcToolbox::Resolve("toolbox-blender", problem, why), "native"); + EXPECT_EQ(why, problem); + // Native asked for: native, no reason given + EXPECT_EQ(UgcToolbox::Resolve("native", problem, why), "native"); + EXPECT_TRUE(why.empty()); + EXPECT_EQ(UgcToolbox::Resolve("", "", why), "native"); + + // Everything there: usable + const auto blender = root / "blender"; + WriteFile(blender, "#!/bin/sh\n"); + std::filesystem::permissions(blender, std::filesystem::perms::owner_all); + config = LayOut(root, blender); + EXPECT_EQ(UgcToolbox::Problem(config), ""); + EXPECT_EQ(UgcToolbox::Resolve("toolbox-blender", UgcToolbox::Problem(config), why), "toolbox-blender"); + EXPECT_TRUE(why.empty()); + + // Each missing piece is named + auto broken = config; + std::filesystem::permissions(blender, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); + EXPECT_NE(UgcToolbox::Problem(broken).find("not an executable"), std::string::npos); + std::filesystem::permissions(blender, std::filesystem::perms::owner_all); + std::filesystem::remove(config.scripts / "addons" / "io_scene_niftools" / "__init__.py"); + EXPECT_NE(UgcToolbox::Problem(config).find("io_scene_niftools"), std::string::npos); + broken.scripts.clear(); // the add-ons in Blender's own folder: not checked + EXPECT_EQ(UgcToolbox::Problem(broken), ""); + broken.standalone = root / "nowhere"; + EXPECT_NE(UgcToolbox::Problem(broken).find("lu_batch_driver.py"), std::string::npos); + broken = LayOut(root, blender); + broken.device = "metal"; + EXPECT_NE(UgcToolbox::Problem(broken).find("toolbox_device"), std::string::npos); + broken = LayOut(root, blender); + std::filesystem::remove(broken.worker); + EXPECT_NE(UgcToolbox::Problem(broken).find("worker script"), std::string::npos); + broken = LayOut(root, blender); + std::filesystem::remove(broken.res / "brickdb.zip"); + EXPECT_NE(UgcToolbox::Problem(broken).find("brickdb.zip"), std::string::npos); + std::filesystem::create_directories(broken.brickdb / "Assemblies"); // made already: the zip isn't needed + EXPECT_EQ(UgcToolbox::Problem(broken), ""); +#endif + std::filesystem::remove_all(root); +} + +TEST(UgcToolboxProtocol, FramesOneMessagePerLine) { + // Framed: compact, versioned, one line whatever the strings hold + const auto line = UgcToolboxProtocol::Frame({ { "cmd", "make" }, { "input", "a\nb\r\"c\"" } }); + ASSERT_FALSE(line.empty()); + EXPECT_EQ(line.back(), '\n'); + EXPECT_EQ(line.find('\n'), line.size() - 1); + const auto parsed = UgcToolboxProtocol::Parse(std::string_view(line).substr(0, line.size() - 1)); + ASSERT_TRUE(parsed); + EXPECT_EQ((*parsed)["dlutb"], UgcToolboxProtocol::VERSION); + EXPECT_EQ((*parsed)["input"], "a\nb\r\"c\""); + const auto request = UgcToolboxProtocol::MakeRequest(7, "/w/7.lxfml", "/w/7.nif", { 0, 2 }); + EXPECT_EQ(request["cmd"], "make"); + EXPECT_EQ(request["lods"], nlohmann::json::array({ 0, 2 })); + + // Lines arrive in pieces and several at once + UgcToolboxProtocol::LineReader reader; + reader.Feed("{\"dlutb\":1,\"ty"); + EXPECT_FALSE(reader.Next()); + reader.Feed("pe\":\"ready\"}\r\n\n{\"dlutb\":1,\"type\":\"pong\",\"id\":3}\n{\"dlu"); + auto first = reader.Next(); + ASSERT_TRUE(first); + EXPECT_EQ(*first, "{\"dlutb\":1,\"type\":\"ready\"}"); + auto blank = reader.Next(); + ASSERT_TRUE(blank); + EXPECT_TRUE(blank->empty()); + EXPECT_FALSE(UgcToolboxProtocol::Parse(*blank)); + auto second = reader.Next(); + ASSERT_TRUE(second); + EXPECT_EQ(UgcToolboxProtocol::TypeOf(*UgcToolboxProtocol::Parse(*second)), UgcToolboxProtocol::eType::PONG); + EXPECT_FALSE(reader.Next()); + EXPECT_EQ(reader.Pending(), 5u); + EXPECT_EQ(UgcToolboxProtocol::TypeOf(*UgcToolboxProtocol::Parse(*first)), UgcToolboxProtocol::eType::READY); + + // Not messages: other versions, other JSON, not JSON + EXPECT_FALSE(UgcToolboxProtocol::Parse("{\"dlutb\":2,\"type\":\"ready\"}")); + EXPECT_FALSE(UgcToolboxProtocol::Parse("{\"type\":\"ready\"}")); + EXPECT_FALSE(UgcToolboxProtocol::Parse("[1,2]")); + EXPECT_FALSE(UgcToolboxProtocol::Parse("Info: Exporting NiNode block")); + + // A line too long to be a message is dropped up to its newline, and what follows still reads + UgcToolboxProtocol::LineReader flooded; + flooded.Feed(std::string(UgcToolboxProtocol::MAX_LINE / 2, 'x')); + EXPECT_FALSE(flooded.Next()); + flooded.Feed(std::string(UgcToolboxProtocol::MAX_LINE, 'x')); + EXPECT_FALSE(flooded.Next()); + EXPECT_LE(flooded.Pending(), UgcToolboxProtocol::MAX_LINE); + flooded.Feed(std::string(100, 'x') + "\n{\"dlutb\":1,\"type\":\"pong\"}\n"); + auto after = flooded.Next(); + ASSERT_TRUE(after); + EXPECT_EQ(UgcToolboxProtocol::TypeOf(*UgcToolboxProtocol::Parse(*after)), UgcToolboxProtocol::eType::PONG); + EXPECT_EQ(flooded.Overflows(), 1u); + + // A done reply + auto done = UgcToolboxProtocol::ParseDone(*UgcToolboxProtocol::Parse(R"({"dlutb":1,"type":"done","id":9,"ok":true,"ms":{"process":1200,"bake":300}})")); + ASSERT_TRUE(done); + EXPECT_EQ(done->id, 9u); + EXPECT_TRUE(done->ok); + ASSERT_EQ(done->ms.size(), 2u); + done = UgcToolboxProtocol::ParseDone(*UgcToolboxProtocol::Parse(R"({"dlutb":1,"type":"done","id":9,"ok":false})")); + ASSERT_TRUE(done); + EXPECT_FALSE(done->ok); + EXPECT_FALSE(done->error.empty()); + EXPECT_FALSE(UgcToolboxProtocol::ParseDone(*UgcToolboxProtocol::Parse(R"({"dlutb":1,"type":"done"})"))); // no id + EXPECT_FALSE(UgcToolboxProtocol::ParseDone(*UgcToolboxProtocol::Parse(R"({"dlutb":1,"type":"ready"})"))); +} + +#if !defined(_WIN32) +TEST(UgcToolboxWorker, StaysUpRestartsAndGivesUp) { + if (!FindProgram("python3")) GTEST_SKIP() << "no python3 for the stand-in Blender"; + const auto root = Folder("worker"); + const auto blender = root / "blender"; + WriteFile(blender, FAKE_BLENDER); + std::filesystem::permissions(blender, std::filesystem::perms::owner_all); + auto config = LayOut(root, blender); + config.device = "hip"; + ASSERT_EQ(UgcToolbox::Problem(config), ""); + + UgcToolbox::Worker worker; + worker.Configure(config); + // Two models on one Blender, started for the first + auto result = worker.Make(1, "", { 0, 2 }); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_TRUE(result.started); + EXPECT_EQ(result.nif, "NIF::0,2"); + EXPECT_EQ(result.ms.value("process", 0.0), 5.0); + EXPECT_EQ(result.blender, "Blender fake, LU Toolbox 2.4.0, niftools 0.1.1, hip"); + result = worker.Make(2, "", { 0 }); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.started); + EXPECT_EQ(result.nif, "NIF::0"); + auto status = worker.Status(); + EXPECT_EQ(status["running"], true); + EXPECT_EQ(status["starts"], 1); + EXPECT_EQ(status["models"], 2); + // Its files are cleaned up + EXPECT_TRUE(std::filesystem::is_empty(config.work) || !std::filesystem::exists(config.work / "model-2.lxfml")); + + // LU Toolbox failing fails the model; Blender stays up + result = worker.Make(3, "FAIL", { 0 }); + EXPECT_FALSE(result.ok); + EXPECT_NE(result.error.find("Process Model failed"), std::string::npos) << result.error; + EXPECT_EQ(worker.Status()["starts"], 1); + + // Blender crashing fails the model, and the next one gets a new Blender + result = worker.Make(4, "CRASH", { 0 }); + EXPECT_FALSE(result.ok); + EXPECT_NE(result.error.find("exited with code 3"), std::string::npos) << result.error; + result = worker.Make(5, "", { 0 }); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_TRUE(result.started); + EXPECT_EQ(worker.Status()["starts"], 2); + + // A model taking too long fails, and Blender is started again + config.timeoutSeconds = 1; + worker.Configure(config); + const auto start = std::chrono::steady_clock::now(); + result = worker.Make(6, "HANG", { 0 }); + EXPECT_FALSE(result.ok); + EXPECT_NE(result.error.find("toolbox_timeout_seconds"), std::string::npos) << result.error; + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(20)); + EXPECT_EQ(worker.Status()["running"], false); + + // The pace is called while it works; throwing from it gives up and stops Blender + config.timeoutSeconds = 30; + worker.Configure(config); + struct GiveUp {}; + int paced = 0; + EXPECT_THROW(worker.Make(7, "HANG", { 0 }, [&paced](double cpuSeconds, const UgcToolbox::Worker::Pause& pause) { + EXPECT_GE(cpuSeconds, 0.0); + pause(true); + pause(false); + if (++paced >= 3) throw GiveUp{}; + }), GiveUp); + EXPECT_GE(paced, 3); + EXPECT_EQ(worker.Status()["running"], false); + + // Stopped, it starts again when needed + result = worker.Make(8, "", { 0 }); + ASSERT_TRUE(result.ok) << result.error; + worker.Stop(); + EXPECT_EQ(worker.Status()["running"], false); + + // A Blender that isn't one fails to start, with its reason + WriteFile(blender, "#!/bin/sh\necho 'Error: no such add-on' >&2\nexit 7\n"); + result = worker.Make(9, "", { 0 }); + EXPECT_FALSE(result.ok); + EXPECT_NE(result.error.find("no such add-on"), std::string::npos) << result.error; + std::filesystem::remove_all(root); +} +#endif + +/** + * LU Toolbox itself: runs only when Blender, LU-Toolbox-Standalone and a client's res folder are given + * (DLU_TEST_TOOLBOX_BLENDER, DLU_TEST_TOOLBOX_STANDALONE, DLU_TEST_CLIENT_RES; and DLU_TEST_TOOLBOX_SCRIPTS when the + * add-ons aren't in Blender's own folder). Makes a small model and reads its .nif back. + */ +TEST(UgcToolboxIntegration, MakesAModelWithLuToolbox) { + const auto env = [](const char* name) { const char* value = std::getenv(name); return value ? std::string(value) : std::string(); }; + UgcToolbox::Config config; + config.blender = env("DLU_TEST_TOOLBOX_BLENDER"); + config.standalone = env("DLU_TEST_TOOLBOX_STANDALONE"); + config.scripts = env("DLU_TEST_TOOLBOX_SCRIPTS"); + config.res = env("DLU_TEST_CLIENT_RES"); + config.worker = DLU_TOOLBOX_WORKER; + const auto root = std::filesystem::temp_directory_path() / "dlu_toolbox_integration"; + config.brickdb = env("DLU_TEST_TOOLBOX_BRICKDB").empty() ? root / "brickdb" : std::filesystem::path(env("DLU_TEST_TOOLBOX_BRICKDB")); + config.work = root / "work"; + config.threads = 2; + config.timeoutSeconds = 600; + if (config.blender.empty() || config.standalone.empty() || config.res.empty()) { + GTEST_SKIP() << "set DLU_TEST_TOOLBOX_BLENDER, DLU_TEST_TOOLBOX_STANDALONE and DLU_TEST_CLIENT_RES to run LU Toolbox"; + } + if (const auto problem = UgcToolbox::Problem(config); !problem.empty()) GTEST_SKIP() << problem; + + // Two bricks: a 2x4 brick (3001) on a 2x2 (3003) + const std::string lxfml = R"( + + + +)"; + UgcBricks::BrickLibrary library(config.res, 0); + library.LoadMaterials(); + UgcJobs::Settings settings; + settings.icon.size = 64; + UgcToolbox::Worker worker; + worker.Configure(config); + const auto outcome = UgcJobs::ProcessModelToolbox(lxfml, library, settings, worker, 1); + worker.Stop(); + ASSERT_TRUE(outcome.ok) << outcome.error; + EXPECT_EQ(outcome.options, "toolbox-blender"); + ASSERT_TRUE(outcome.files.contains("model.nif.gz")); + ASSERT_TRUE(outcome.files.contains("icon.png")); + const auto stats = nlohmann::json::parse(outcome.stats); + EXPECT_EQ(stats["bricks"], 2); + EXPECT_EQ(stats["settings"]["processor"], "toolbox-blender"); + ASSERT_EQ(stats["lods"].size(), 2u); + EXPECT_GT(stats["lods"][0]["trianglesInNif"].get(), 0u); + EXPECT_LE(stats["lods"][0]["opaqueAfter"].get(), stats["lods"][0]["opaqueBefore"].get()); +}