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

@@ -603,6 +603,15 @@ namespace GameMessages {
return true;
}
void NotifyClientUGCModelReady::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(blueprintID);
}
bool NotifyClientUGCModelReady::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(blueprintID));
return true;
}
void UGObjectMetadata::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(userModelID);
bitStream.Write(blueprintID);

View File

@@ -242,6 +242,19 @@ namespace GameMessages {
int32_t count{};
};
// Server -> client, to a placed model. Its blueprint's files were made (again) on the UGC server: the client's
// BlueprintComponent with that blueprint flushes its cached NIF, HKX and LXFML and requests the NIF and HKX again
// (LWOBlueprintComponent::OnNotifyClientUGCModelReady, 0x00ca6430; the payload is the blueprint id only,
// GameMessage::NotifyClientUGCIconReady::Serialize 0x00daae30 is shared with it). The client keeps using its
// cached manifest entry, so the new checksum is sent first (UgcManifestResponse). See docs/UgcServer.md.
struct NotifyClientUGCModelReady : public NetGameMsg {
NotifyClientUGCModelReady() : NetGameMsg(MessageType::Game::NOTIFY_CLIENT_UGC_MODEL_READY) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
LWOOBJID blueprintID{};
};
// A model's user generated data (UGObjectMetadata::Serialize, 0x00f5f590)
struct UGObjectMetadata {
LWOOBJID userModelID{};

View File

@@ -1,21 +1,34 @@
#include "UgcManifest.h"
#include <chrono>
#include <optional>
#include <map>
#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 "Game.h"
#include "Logger.h"
#include "MD5.h"
#include "PlayerManager.h"
#include "Sd0.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;
struct Waiting {
SystemAddress sysAddr;
@@ -26,20 +39,17 @@ namespace {
std::vector<Waiting> g_Waiting;
std::chrono::steady_clock::time_point g_NextRetry{};
std::map<std::pair<SystemAddress, LWOOBJID>, std::chrono::steady_clock::time_point> g_LxfmlSent;
std::map<LWOOBJID, IUgc::FileChecksum> g_LxfmlChecksums;
// The UGC server's name for a blueprint's file of this type; nothing for the ones it doesn't make
std::optional<std::string_view> FileName(eUgcResourceType type) {
switch (type) {
case eUgcResourceType::DDS: return "icon.dds";
case eUgcResourceType::NIF: return "model.nif";
default: return std::nullopt;
}
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 Enabled() {
// Off unless set: the 1.10.64 client downloads from http://127.0.0.1:80 whatever its boot.cfg says, and one that
// can't connect there is logged out (docs/UgcServer.md)
return Game::config->GetValue("ugc_manifest") == "1";
bool ModelsOn() {
return Game::config && Game::config->GetValue("ugc_manifest_models") == "1";
}
int HexDigit(char c) {
@@ -49,34 +59,143 @@ namespace {
return -1;
}
// Answers when the file's checksum is known; false when it isn't (yet)
bool TryAnswer(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType) {
const auto file = FileName(resourceType);
if (!file) return false;
const auto checksum = Database::Get()->GetUgcFileChecksum(blueprintId, *file);
if (!checksum || checksum->md5.size() != 32) return false;
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;
response.fileSize = checksum->size;
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) return false;
response.md5[i] = static_cast<uint8_t>(high << 4 | low);
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.valid = true;
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), checksum->md5.c_str(), checksum->size);
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();
std::erase_if(g_LxfmlSent, [now](const auto& sent) { return now - sent.second > LXFML_RESEND_AFTER; });
if (g_LxfmlSent.contains({ sysAddr, blueprintId })) 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_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;
}
}
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 uses an HKX it built before, else it asks and gets 404
return modelsOn && meshMade ? eAction::ANSWER_UNKNOWN : 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::ClientBuildsModel(const LWOOBJID blueprintId) {
return !ServesModels() || !MeshMade(blueprintId);
}
void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS || !Enabled() || !FileName(resourceType)) return;
if (TryAnswer(sysAddr, blueprintId, resourceType)) return;
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);
@@ -93,10 +212,41 @@ void UgcManifest::Update() {
if (now < g_NextRetry) return;
g_NextRetry = now + RETRY_INTERVAL;
std::erase_if(g_Waiting, [now](const Waiting& waiting) {
return now - waiting.since > MAX_WAIT || TryAnswer(waiting.sysAddr, waiting.blueprintId, waiting.resourceType);
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; });
std::erase_if(g_LxfmlSent, [&sysAddr](const auto& sent) { return sent.first.first == sysAddr; });
}
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);
for (const auto id : blueprintIds) {
std::vector<Entity*> shown;
for (auto* const model : models) {
if (model && model->GetVar<LWOOBJID>(u"blueprintid") == id) shown.push_back(model);
}
if (shown.empty()) continue;
const auto checksum = Database::Get()->GetUgcFileChecksum(id, "model.nif");
if (!checksum || checksum->md5.size() != 32) continue;
// The new checksum first: the client keeps its cached one otherwise, and would load the file it has
for (auto* const player : players) {
if (player) Send(player->GetSystemAddress(), id, eUgcResourceType::NIF, checksum);
}
for (auto* const model : shown) {
GameMessages::NotifyClientUGCModelReady ready;
ready.target = model->GetObjectID();
ready.blueprintID = id;
ready.Send(UNASSIGNED_SYSTEM_ADDRESS);
}
LOG("The UGC server made model %llu again: told %zu client(s) about %zu placed model(s)", static_cast<unsigned long long>(id), players.size(), shown.size());
}
}

View File

@@ -1,22 +1,56 @@
#ifndef UGCMANIFEST_H
#define UGCMANIFEST_H
#include <optional>
#include <string>
#include <vector>
#include "dCommonVars.h"
#include "eUgcResourceType.h"
#include "IUgc.h"
#include "RakNetTypes.h"
/**
* Answers the client's REQUEST_UGC_MANIFEST_INFO (sent when its UGCUSE3DSERVICES is off, the default): the MD5 and
* size of a blueprint's file, which the client needs before it downloads the file from the UGC server
* (BrickModels/UserMade/<id % 1000>/<id>.<ext>.sd0). The checksums are the ones the UGC server stored when it made the
* files (ugc_file_checksums); a file it hasn't made yet is answered once it has (Update looks again every few seconds
* for a while). Nothing is answered for files it never makes (LXFML, HKX), or unless `ugc_manifest` is 1: the client
* then downloads from http://127.0.0.1:80/lwoclient/UserBrickModels/ whatever its boot.cfg says, and is logged out when
* it can't connect there.
* size of a blueprint's file, which the client needs before it uses or downloads the file from the UGC server
* (BrickModels/UserMade/<id % 1000>/<id>.<ext>.sd0). Nothing is answered unless `ugc_manifest` is 1.
*
* * Icons (DDS): the checksum the UGC server stored when it made the icon (ugc_file_checksums); one it hasn't made yet
* is answered once it has (Update looks again every few seconds for a while).
* * Player models (NIF, HKX, LXFML): a placed model's client always asks for all three (its BlueprintComponent loads the
* blueprint's NIF and HKX, its ModelBehaviorComponent the LXFML) and waits for the answers without a timeout. With
* `ugc_manifest_models` 1 and the model's mesh made, the NIF is answered with the UGC server's checksum (the client
* downloads the served mesh), the LXFML with the stored LXFML's and the HKX as not known (valid 0: the client uses
* one it has, else asks the UGC server, which answers 404). Otherwise the world sends that client the model's LXFML
* (BlueprintSaveResponse), the client builds the NIF and HKX itself and caches their checksums, which answers its
* own requests: a model is never left waiting.
*
* Main thread only: each lookup is one indexed query and the answers are sent from here. See docs/UgcServer.md.
*/
namespace UgcManifest {
// What a request gets
enum class eAction {
NONE, // not answered (ugc_manifest off, or not a UGC type)
ANSWER, // the stored (or, for LXFML, computed) checksum
ANSWER_UNKNOWN,// valid 0: the client uses the file it has, or downloads it
SEND_LXFML, // the model's LXFML, for the client to build the model itself
WAIT, // icons not made yet: answered once they are
};
// The rule, for a request of `type` when the UGC server has (`meshMade`) or hasn't made the blueprint's mesh
eAction Decide(eUgcResourceType type, bool manifestOn, bool modelsOn, bool meshMade);
// The MD5 (lowercase hex) and size of a stored LXFML (ugc.lxfml: sd0, or plain XML) as the client has it after
// downloading and inflating it; nothing when it can't be read
std::optional<IUgc::FileChecksum> LxfmlChecksum(const std::string& stored);
// ugc_manifest=1 and ugc_manifest_models=1: placed models' meshes come from the UGC server when made
bool ServesModels();
// Whether the client should get this model's LXFML when a property loads (it builds the model itself): not when
// ServesModels() and the UGC server made its mesh
bool ClientBuildsModel(LWOOBJID blueprintId);
void OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType);
// Answers the waiting requests whose files have been made since, and forgets the ones waited on too long
@@ -24,6 +58,10 @@ namespace UgcManifest {
// A client left: its waiting requests are dropped
void OnDisconnect(const SystemAddress& sysAddr);
// The UGC server made these models' meshes (again, with a new checksum): each one placed in this world is sent the
// new checksum and NotifyClientUGCModelReady, so clients load the served mesh
void OnModelsMade(const std::vector<LWOOBJID>& blueprintIds);
}
#endif //!UGCMANIFEST_H