feat(ugc): placed models from the UGC server without 3D services

A placed model's client (1.10.64, UGCUSE3DSERVICES=7:0) always loads its
blueprint's NIF and HKX (its BlueprintComponent sets renderUserGen and
physicsUserGen itself) and its LXFML, asks the world for each file's
manifest first and waits for the answer with no timeout.

With ugc_manifest=1 and the new ugc_manifest_models=1 (default 0) the world:
- leaves the models whose mesh the UGC server made out of the LXFML it
  sends when a property loads,
- answers their NIF with the UGC server's checksum, their LXFML with the
  stored LXFML's (worked out once and kept) and their HKX as not known,
- sends a model that isn't made its LXFML (once for the three requests),
  so the client builds it itself and no model is left waiting.

When the UGC server writes a model's mesh with a new checksum it sends
UGC_MODELS_MADE (a new master message, appended) to the master, which
passes it to every world; a world with that model placed sends its
players the new NIF checksum and NotifyClientUGCModelReady (game message
909, the blueprint id), so clients switch to the served mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 19:12:48 -05:00
parent dce2037d9d
commit 9d76fe9550
18 changed files with 508 additions and 44 deletions

View File

@@ -441,7 +441,14 @@ void UgcProcessor::Collect() {
void UgcProcessor::StoreChecksums(Kind kind, LWOOBJID storageId, const std::vector<Checksum>& checksums) {
const auto owner = kind == Kind::MODEL ? IUgc::eFileOwner::MODEL : IUgc::eFileOwner::COMBINATION;
for (const auto& checksum : checksums) Database::Get()->SetUgcFileChecksum(owner, storageId, checksum.file, checksum.md5, checksum.size);
for (const auto& checksum : checksums) {
// A player model's mesh that changed: the worlds showing it tell their clients (UGC_MODELS_MADE)
if (kind == Kind::MODEL && checksum.file == "model.nif") {
const auto before = Database::Get()->GetUgcFileChecksum(storageId, checksum.file);
if (!before || before->md5 != checksum.md5 || before->size != checksum.size) m_ChangedMeshes.push_back(storageId);
}
Database::Get()->SetUgcFileChecksum(owner, storageId, checksum.file, checksum.md5, checksum.size);
}
}
void UgcProcessor::Backfill() {

View File

@@ -140,6 +140,10 @@ public:
uint64_t StoredBytes() const { return m_StoredBytes; }
uint64_t MaxStorageBytes() const { return m_Config.maxStorageBytes; }
// Main thread: the models whose mesh (model.nif) was written with a different checksum than before since the last
// call, for the worlds (UGC_MODELS_MADE); a model made again unchanged (e.g. after eviction) isn't listed
std::vector<LWOOBJID> TakeChangedMeshes() { std::vector<LWOOBJID> ids; ids.swap(m_ChangedMeshes); return ids; }
private:
struct Job {
Kind kind{};
@@ -223,6 +227,7 @@ private:
std::chrono::steady_clock::time_point m_NextEviction{};
std::map<std::pair<Kind, LWOOBJID>, std::pair<std::chrono::steady_clock::time_point, Availability>> m_Recent; // answers for missing files
uint64_t m_StoredBytes{};
std::vector<LWOOBJID> m_ChangedMeshes; // TakeChangedMeshes
uint64_t m_Made{};
uint64_t m_Failed{};
uint64_t m_Evicted{};

View File

@@ -21,6 +21,7 @@
#include "Game.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "MasterPackets.h"
#include "Server.h"
#include "ServiceType.h"
#include "Web.h"
@@ -642,6 +643,14 @@ int main(int argc, char** argv) {
packet = g_Server->ReceiveFromMaster();
}
processor.Update();
// Worlds showing a model whose mesh changed tell their clients (docs/UgcServer.md, "Models without 3D services")
if (auto changed = processor.TakeChangedMeshes(); !changed.empty()) {
for (size_t start = 0; start < changed.size(); start += UgcModelsMade::MAX_MODELS) {
UgcModelsMade made;
made.blueprintIds.assign(changed.begin() + start, changed.begin() + std::min(changed.size(), start + UgcModelsMade::MAX_MODELS));
MasterPackets::SendToMaster(made, g_Server);
}
}
// Settings the dashboard changed arrive as a config reload; pick them up
if (now - lastConfigure >= std::chrono::seconds(5)) {
lastConfigure = now;