Files
DarkflameServer/dGame/dUtilities/UgcManifest.cpp
Aaron Kimbrell ef3c74f715 feat(ugc): processing options per make, recorded per model with their times
Staff can make models again with other processing options than the UGC
settings' (ray backend, hidden-face method, denoising) and every make records
what made it, for comparing the options.

- migration mysql 99 / sqlite 82 (ugc_process_options): ugc.process_options
  (picked for the next make, cleared once made), ugc.made_options (what made
  the current files) and ugc_process_runs (every successful make: options,
  wall and CPU time, hidden faces', occlusion's and icon's time, bricks,
  triangles before and after)
- IUgc: ResetUgcModelProcessing and ResetPropertyUgcModelProcessing take the
  options; PendingModel carries them; RecordUgcModelRun, GetUgcRunSummaries;
  list entries have madeOptions and processOptions
- the UGC server applies a job's options over its settings and records the run
- /api/ugc/reprocess takes options ("embree fast oidn"); /api/ugc/options
  lists the choices, the settings' defaults and averages per combination
- /reprocessproperty [builtin|embree|hiprt] [toolbox|fast] [off|oidn], any
  order, all optional

Check: run the migration on MySQL and SQLite; /reprocessproperty embree fast
on a property, then the models' made_options and ugc_process_runs rows;
/api/ugc/options.

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

465 lines
21 KiB
C++

#include "UgcManifest.h"
#include <algorithm>
#include <chrono>
#include <map>
#include <set>
#include <sstream>
#include <string_view>
#include <vector>
#include "BrickByBrick.h"
#include "BuildingMessages.h"
#include "ClientPackets.h"
#include "Database.h"
#include "dConfig.h"
#include "eBlueprintSaveResponseType.h"
#include "Entity.h"
#include "EntityManager.h"
#include "GhostComponent.h"
#include "Character.h"
#include "Game.h"
#include "Logger.h"
#include "MD5.h"
#include "PlayerManager.h"
#include "Sd0.h"
#include "dZoneManager.h"
#include "ChatPackets.h"
#include "ZoneInstanceManager.h"
namespace {
// How often the waiting requests are looked up again, how long they wait at most and how many there can be
constexpr auto RETRY_INTERVAL = std::chrono::seconds(5);
constexpr auto MAX_WAIT = std::chrono::minutes(15);
constexpr size_t MAX_WAITING = 512;
// A client asks for a model's NIF, HKX and LXFML at once: its LXFML is sent once for all three
constexpr auto LXFML_RESEND_AFTER = std::chrono::seconds(10);
// LXFML checksums worked out, kept for the next clients (a model's LXFML only changes with a new save)
constexpr size_t MAX_LXFML_CHECKSUMS = 4096;
// How long after NotifyClientUGCModelReady the model is constructed again: the client flushes the model's files on
// its load thread, and a model constructed before that is done loses the mesh it just loaded
constexpr auto RECONSTRUCT_DELAY = std::chrono::milliseconds(1500);
// How long after taking the model down it is constructed again: the client deletes objects later, and a
// construction of an object it still has is dropped (the model then disappears)
constexpr auto CONSTRUCT_AFTER_DESTRUCT = std::chrono::milliseconds(1000);
struct Waiting {
SystemAddress sysAddr;
LWOOBJID blueprintId{};
eUgcResourceType resourceType{};
std::chrono::steady_clock::time_point since;
};
std::vector<Waiting> g_Waiting;
std::chrono::steady_clock::time_point g_NextRetry{};
// LXFMLs sent to clients (building those models) and the switches to served meshes waiting for them
UgcManifest::ServedMeshSwitches g_Switches;
std::map<LWOOBJID, IUgc::FileChecksum> g_LxfmlChecksums;
// Placed models to construct again for a client once its flush is done
struct Reconstruct {
SystemAddress sysAddr;
LWOOBJID objectId{};
std::chrono::steady_clock::time_point due;
bool destructed{}; // taken down; constructed at `due`
};
std::vector<Reconstruct> g_Reconstructs;
// /reprocessproperty: the models being made again, and when the players are reloaded at the latest
struct PropertyReload {
std::set<LWOOBJID> blueprintIds;
std::chrono::steady_clock::time_point since;
std::chrono::steady_clock::time_point nextCheck;
};
std::optional<PropertyReload> g_PropertyReload;
void RunDueSwitches(std::chrono::steady_clock::time_point now);
bool ManifestOn() {
// Off unless set: a client whose boot.cfg doesn't point it at the UGC server downloads from its built-in
// http://127.0.0.1:80/lwoclient and is logged out when it can't connect there (docs/UgcServer.md)
return Game::config && Game::config->GetValue("ugc_manifest") == "1";
}
bool ModelsOn() {
return Game::config && Game::config->GetValue("ugc_manifest_models") == "1";
}
int HexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
bool MeshMade(LWOOBJID blueprintId) {
const auto checksum = Database::Get()->GetUgcFileChecksum(blueprintId, "model.nif");
return checksum && checksum->md5.size() == 32;
}
void Send(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType, const std::optional<IUgc::FileChecksum>& checksum) {
ClientPackets::UgcManifestResponse response;
response.blueprintId = blueprintId;
response.resourceType = resourceType;
if (checksum && checksum->md5.size() == 32) {
bool valid = true;
for (size_t i = 0; i < response.md5.size(); i++) {
const auto high = HexDigit(checksum->md5[i * 2]);
const auto low = HexDigit(checksum->md5[i * 2 + 1]);
if (high < 0 || low < 0) valid = false;
response.md5[i] = static_cast<uint8_t>((high << 4) | low);
}
if (valid) {
response.valid = true;
response.fileSize = checksum->size;
} else {
response.md5 = {};
}
}
response.Send(sysAddr);
LOG_DEBUG("Sent the UGC manifest of %llu (type %i): %s, %u bytes", static_cast<unsigned long long>(blueprintId),
static_cast<int>(resourceType), response.valid ? checksum->md5.c_str() : "not known", response.fileSize);
}
// Answers an icon's request when its checksum is known; false when it isn't (yet)
bool TryAnswerIcon(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType) {
const auto checksum = Database::Get()->GetUgcFileChecksum(blueprintId, "icon.dds");
if (!checksum || checksum->md5.size() != 32) return false;
Send(sysAddr, blueprintId, resourceType, checksum);
return true;
}
std::optional<IUgc::FileChecksum> StoredLxfmlChecksum(LWOOBJID blueprintId) {
if (const auto known = g_LxfmlChecksums.find(blueprintId); known != g_LxfmlChecksums.end()) return known->second;
const auto model = Database::Get()->GetUgcModel(blueprintId);
if (!model) return std::nullopt;
auto checksum = UgcManifest::LxfmlChecksum(model->lxfmlData.str());
if (!checksum) return std::nullopt;
if (g_LxfmlChecksums.size() >= MAX_LXFML_CHECKSUMS) g_LxfmlChecksums.clear();
g_LxfmlChecksums.emplace(blueprintId, *checksum);
return checksum;
}
// The model's LXFML to one client, which builds the model from it (as when a property loads); false when there is
// no such model
bool SendLxfml(const SystemAddress& sysAddr, LWOOBJID blueprintId) {
const auto now = std::chrono::steady_clock::now();
if (g_Switches.SentWithin(sysAddr, blueprintId, LXFML_RESEND_AFTER, now)) return true;
auto model = Database::Get()->GetUgcModel(blueprintId);
if (!model) return false;
ClientPackets::BlueprintSaveResponse response;
response.localId = LWOOBJID_EMPTY; // zero, like the LXFML sent when a property loads
response.reasonCode = eBlueprintSaveResponseType::EverythingWorked;
response.models.push_back({ blueprintId, model->lxfmlData.str() });
response.Send(sysAddr);
g_Switches.LxfmlSent(sysAddr, blueprintId, now);
// Its mesh is wanted: a model still in its quiet period after a save is made now
Database::Get()->ExpediteUgcModel(blueprintId);
LOG_DEBUG("Sent the LXFML of %llu for the client to build", static_cast<unsigned long long>(blueprintId));
return true;
}
}
void UgcManifest::ServedMeshSwitches::LxfmlSent(const SystemAddress& sysAddr, const LWOOBJID blueprintId, const Clock::time_point now) {
std::erase_if(m_LxfmlSent, [now](const auto& sent) { return now - sent.second >= BUILD_SETTLE; });
const Key key{ sysAddr, blueprintId };
m_LxfmlSent[key] = now;
// A switch now would be undone by the build this starts
if (const auto due = m_Due.find(key); due != m_Due.end()) due->second = std::max(due->second, now + BUILD_SETTLE);
}
bool UgcManifest::ServedMeshSwitches::SentWithin(const SystemAddress& sysAddr, const LWOOBJID blueprintId, const Clock::duration within, const Clock::time_point now) const {
const auto sent = m_LxfmlSent.find({ sysAddr, blueprintId });
return sent != m_LxfmlSent.end() && now - sent->second < within;
}
void UgcManifest::ServedMeshSwitches::Schedule(const SystemAddress& sysAddr, const LWOOBJID blueprintId, const Clock::time_point now) {
const Key key{ sysAddr, blueprintId };
auto due = now;
if (const auto sent = m_LxfmlSent.find(key); sent != m_LxfmlSent.end()) due = std::max(due, sent->second + BUILD_SETTLE);
if (const auto pending = m_Due.find(key); pending != m_Due.end()) due = std::max(due, pending->second);
m_Due[key] = due;
}
std::vector<UgcManifest::ServedMeshSwitches::Switch> UgcManifest::ServedMeshSwitches::TakeDue(const Clock::time_point now) {
std::vector<Switch> due;
std::erase_if(m_Due, [&](const auto& pending) {
if (pending.second > now) return false;
due.push_back({ pending.first.first, pending.first.second });
return true;
});
return due;
}
void UgcManifest::ServedMeshSwitches::Forget(const SystemAddress& sysAddr) {
std::erase_if(m_LxfmlSent, [&sysAddr](const auto& sent) { return sent.first.first == sysAddr; });
std::erase_if(m_Due, [&sysAddr](const auto& pending) { return pending.first.first == sysAddr; });
}
UgcManifest::eAction UgcManifest::Decide(const eUgcResourceType type, const bool manifestOn, const bool modelsOn, const bool meshMade) {
if (!manifestOn) return eAction::NONE;
switch (type) {
case eUgcResourceType::DDS: return eAction::WAIT; // answered at once when the icon is made
case eUgcResourceType::NIF:
case eUgcResourceType::LXFML:
return modelsOn && meshMade ? eAction::ANSWER : eAction::SEND_LXFML;
case eUgcResourceType::HKX:
// The UGC server makes no physics: the client builds it from the LXFML (and is then switched to a served mesh)
return eAction::SEND_LXFML;
default: return eAction::NONE;
}
}
std::optional<IUgc::FileChecksum> UgcManifest::LxfmlChecksum(const std::string& stored) {
std::string lxfml;
if (stored.starts_with("<?xml") || stored.starts_with("<LXFML")) {
lxfml = stored;
} else {
try {
std::stringstream stream(stored);
Sd0 sd0(stream);
lxfml = sd0.GetAsStringUncompressed();
} catch (...) {
return std::nullopt;
}
}
if (lxfml.empty()) return std::nullopt;
MD5 md5;
md5.update(reinterpret_cast<const unsigned char*>(lxfml.data()), static_cast<MD5::size_type>(lxfml.size()));
md5.finalize();
return IUgc::FileChecksum{ md5.hexdigest(), static_cast<uint32_t>(lxfml.size()) };
}
bool UgcManifest::ServesModels() {
return ManifestOn() && ModelsOn();
}
bool UgcManifest::ServesMesh(const LWOOBJID blueprintId) {
return ServesModels() && MeshMade(blueprintId);
}
void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return;
const auto manifestOn = ManifestOn();
const auto modelsOn = ModelsOn();
const bool isModelFile = resourceType == eUgcResourceType::NIF || resourceType == eUgcResourceType::HKX || resourceType == eUgcResourceType::LXFML;
const auto meshMade = manifestOn && modelsOn && isModelFile && MeshMade(blueprintId);
switch (Decide(resourceType, manifestOn, modelsOn, meshMade)) {
case eAction::NONE: return;
case eAction::ANSWER:
Send(sysAddr, blueprintId, resourceType, resourceType == eUgcResourceType::LXFML
? StoredLxfmlChecksum(blueprintId) : Database::Get()->GetUgcFileChecksum(blueprintId, "model.nif"));
return;
case eAction::ANSWER_UNKNOWN:
Send(sysAddr, blueprintId, resourceType, std::nullopt);
return;
case eAction::SEND_LXFML:
// Not a player model (or gone): answered as not known, so the client doesn't wait for ever
if (!SendLxfml(sysAddr, blueprintId)) Send(sysAddr, blueprintId, resourceType, std::nullopt);
return;
case eAction::WAIT:
break;
}
if (TryAnswerIcon(sysAddr, blueprintId, resourceType)) return;
// Not made yet: a client wants it, so a model still in its quiet period after a save is made now (as when a client
// asks the UGC server for it), and the request is answered when it's made
Database::Get()->ExpediteUgcModel(blueprintId);
for (const auto& waiting : g_Waiting) {
if (waiting.sysAddr == sysAddr && waiting.blueprintId == blueprintId && waiting.resourceType == resourceType) return;
}
if (g_Waiting.size() >= MAX_WAITING) g_Waiting.erase(g_Waiting.begin());
g_Waiting.push_back({ sysAddr, blueprintId, resourceType, std::chrono::steady_clock::now() });
}
void UgcManifest::SwitchClient(const SystemAddress& sysAddr, const LWOOBJID blueprintId, const IUgc::FileChecksum& checksum, const std::vector<LWOOBJID>& modelIds) {
// The new checksum first: the client keeps its cached one (its own build's) otherwise, and loads the file it has
Send(sysAddr, blueprintId, eUgcResourceType::NIF, checksum);
const auto due = std::chrono::steady_clock::now() + RECONSTRUCT_DELAY;
for (const auto objectId : modelIds) {
// Flushes the client's cached NIF, HKX and LXFML of the blueprint and preloads the NIF and HKX again
// (LWOBlueprintComponent::OnNotifyClientUGCModelReady, 0x00ca6430); an object already drawn keeps its mesh
GameMessages::NotifyClientUGCModelReady ready;
ready.target = objectId;
ready.blueprintID = blueprintId;
ready.Send(sysAddr);
// So the model is also taken down and constructed again once the flush is done (constructed at once, the flush
// can land after the new object loaded its mesh, which then disappears). Its HKX's cached checksum is still the
// client's own build's, so it keeps its collision.
std::erase_if(g_Reconstructs, [&](const Reconstruct& r) { return r.sysAddr == sysAddr && r.objectId == objectId; });
g_Reconstructs.push_back({ sysAddr, objectId, due });
}
}
namespace {
// Takes a model down for a client whose flush is done, then constructs it again a moment later (not in the same
// batch: the client would drop the construction of an object it hasn't deleted yet); false until it's done
bool ConstructAgain(Reconstruct& pending, const std::chrono::steady_clock::time_point now) {
if (now < pending.due) return false;
if (!Game::entityManager || !PlayerManager::GetPlayer(pending.sysAddr)) return true;
auto* const model = Game::entityManager->GetEntity(pending.objectId);
if (!model) return true;
if (!pending.destructed) {
Game::entityManager->DestructEntity(model, pending.sysAddr);
pending.destructed = true;
pending.due = now + CONSTRUCT_AFTER_DESTRUCT;
return false;
}
Game::entityManager->ConstructEntity(model, pending.sysAddr);
return true;
}
}
namespace {
// Sends every player in this world the made models' checksums, then transfers them back into this zone and clone:
// the client loads the property again, and its manifest cache now has the new meshes' checksums
void ReloadPlayers(const std::set<LWOOBJID>& blueprintIds) {
const auto zoneId = Game::zoneManager->GetZoneID();
for (auto* const player : PlayerManager::GetAllPlayers()) {
if (!player) continue;
const auto sysAddr = player->GetSystemAddress();
for (const auto id : blueprintIds) {
const auto checksum = Database::Get()->GetUgcFileChecksum(id, "model.nif");
if (checksum && checksum->md5.size() == 32) Send(sysAddr, id, eUgcResourceType::NIF, checksum);
}
ChatPackets::SendSystemMessage(sysAddr, u"The property's models were made again: loading the property again.");
const auto objectId = player->GetObjectID();
ZoneInstanceManager::Instance()->RequestZoneTransfer(Game::server, zoneId.GetMapID(), zoneId.GetCloneID(), false,
[objectId](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) {
auto* const entity = Game::entityManager->GetEntity(objectId);
if (!entity || !entity->GetCharacter()) return;
auto* const character = entity->GetCharacter();
character->SetZoneID(zoneID);
character->SetZoneInstance(zoneInstance);
character->SetZoneClone(zoneClone);
character->SaveXMLToDatabase();
ClientPackets::TransferToWorld transfer;
transfer.serverIP = LUString(serverIP);
transfer.serverPort = serverPort;
transfer.mythranShift = mythranShift;
transfer.Send(entity->GetSystemAddress());
});
}
}
}
size_t UgcManifest::ReprocessProperty(const LWOOBJID propertyId, const std::string& options) {
std::set<LWOOBJID> blueprintIds;
for (const auto& model : Database::Get()->GetPropertyModels(propertyId)) {
if (model.ugcId != 0) blueprintIds.insert(model.ugcId);
}
if (blueprintIds.empty()) return 0;
Database::Get()->ResetPropertyUgcModelProcessing(propertyId, options);
const auto now = std::chrono::steady_clock::now();
g_PropertyReload = PropertyReload{ std::move(blueprintIds), now, now + RETRY_INTERVAL };
LOG("Making the %zu models of property %llu again%s; players are reloaded when they're made", g_PropertyReload->blueprintIds.size(), static_cast<unsigned long long>(propertyId),
options.empty() ? "" : (" with " + options).c_str());
return g_PropertyReload->blueprintIds.size();
}
void UgcManifest::Update() {
const auto now = std::chrono::steady_clock::now();
if (g_PropertyReload && now >= g_PropertyReload->nextCheck) {
g_PropertyReload->nextCheck = now + RETRY_INTERVAL;
bool waiting = false;
for (const auto id : g_PropertyReload->blueprintIds) {
const auto info = Database::Get()->GetUgcProcessInfo(id);
waiting = waiting || (info && info->state == IUgc::eProcessState::PENDING);
}
if (!waiting || now - g_PropertyReload->since > MAX_WAIT) {
ReloadPlayers(g_PropertyReload->blueprintIds);
g_PropertyReload.reset();
}
}
RunDueSwitches(now);
std::erase_if(g_Reconstructs, [now](Reconstruct& pending) { return ConstructAgain(pending, now); });
if (g_Waiting.empty() || now < g_NextRetry) return;
g_NextRetry = now + RETRY_INTERVAL;
std::erase_if(g_Waiting, [now](const Waiting& waiting) {
return now - waiting.since > MAX_WAIT || TryAnswerIcon(waiting.sysAddr, waiting.blueprintId, waiting.resourceType);
});
}
void UgcManifest::OnDisconnect(const SystemAddress& sysAddr) {
std::erase_if(g_Waiting, [&sysAddr](const Waiting& waiting) { return waiting.sysAddr == sysAddr; });
g_Switches.Forget(sysAddr);
std::erase_if(g_Reconstructs, [&sysAddr](const Reconstruct& pending) { return pending.sysAddr == sysAddr; });
}
namespace {
// The switches that are due: each client is sent the served checksum and its models shown to it are switched
void RunDueSwitches(const std::chrono::steady_clock::time_point now) {
if (g_Switches.Pending() == 0) return;
const auto due = g_Switches.TakeDue(now);
if (due.empty() || !Game::entityManager) return;
const auto models = Game::entityManager->GetEntitiesByLOT(BrickByBrick::MODEL_OBJECT_LOT);
for (const auto& pending : due) {
// Models of a /reprocessproperty: the players are reloaded once they're all made
if (g_PropertyReload && g_PropertyReload->blueprintIds.contains(pending.blueprintId)) continue;
auto* const player = PlayerManager::GetPlayer(pending.sysAddr);
if (!player) continue;
auto* const ghost = player->GetComponent<GhostComponent>();
std::vector<LWOOBJID> shown;
for (auto* const model : models) {
if (!model || model->GetVar<LWOOBJID>(u"blueprintid") != pending.blueprintId) continue;
// Not shown to this client (yet): it loads the served mesh when it's constructed
if (model->GetIsGhostingCandidate() && (!ghost || !ghost->IsObserved(model->GetObjectID()))) continue;
shown.push_back(model->GetObjectID());
}
if (shown.empty()) continue;
const auto checksum = Database::Get()->GetUgcFileChecksum(pending.blueprintId, "model.nif");
if (!checksum || checksum->md5.size() != 32) continue;
UgcManifest::SwitchClient(pending.sysAddr, pending.blueprintId, *checksum, shown);
LOG("Switching %s to the served mesh of model %llu (%zu placed)", pending.sysAddr.ToString(), static_cast<unsigned long long>(pending.blueprintId), shown.size());
}
}
}
size_t UgcManifest::OnPropertyLoading(const SystemAddress& sysAddr, const LWOOBJID propertyId) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS || !ServesModels()) return 0;
std::set<LWOOBJID> blueprintIds;
for (const auto& model : Database::Get()->GetPropertyModels(propertyId)) {
if (model.ugcId != 0) blueprintIds.insert(model.ugcId);
}
size_t sent = 0;
for (const auto id : blueprintIds) {
const auto checksum = Database::Get()->GetUgcFileChecksum(id, "model.nif");
if (!checksum || checksum->md5.size() != 32) continue; // not made: its LXFML is sent
Send(sysAddr, id, eUgcResourceType::NIF, checksum);
sent++;
}
return sent;
}
void UgcManifest::OnLxfmlSent(const SystemAddress& sysAddr, const LWOOBJID blueprintId) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return;
g_Switches.LxfmlSent(sysAddr, blueprintId, std::chrono::steady_clock::now());
}
void UgcManifest::OnModelsMade(const std::vector<LWOOBJID>& blueprintIds) {
for (const auto id : blueprintIds) g_LxfmlChecksums.erase(id); // made again after its LXFML changed, maybe
if (!ServesModels() || !Game::entityManager) return;
const auto& players = PlayerManager::GetAllPlayers();
if (players.empty()) return;
const auto models = Game::entityManager->GetEntitiesByLOT(BrickByBrick::MODEL_OBJECT_LOT);
const auto now = std::chrono::steady_clock::now();
for (const auto id : blueprintIds) {
// Models of a /reprocessproperty: the players are reloaded once they're all made
if (g_PropertyReload && g_PropertyReload->blueprintIds.contains(id)) continue;
const auto placed = std::count_if(models.begin(), models.end(), [id](Entity* model) { return model && model->GetVar<LWOOBJID>(u"blueprintid") == id; });
if (placed == 0) continue;
// A client sent the model's LXFML lately may still be building it, and its build would undo the switch: it's
// switched once it can't be building it any more, the others now
for (auto* const player : players) {
if (player) g_Switches.Schedule(player->GetSystemAddress(), id, now);
}
LOG("The UGC server made model %llu (%zu placed here): switching %zu client(s) to it", static_cast<unsigned long long>(id), static_cast<size_t>(placed), players.size());
}
RunDueSwitches(now);
}