fix(ugc): served player models keep the client's own collision

With ugc_manifest_models the world left made models out of the LXFML it
sends when a property loads, so the client never built them and had no HKX:
the UGC server makes no physics, and the HKX request got a 404, so served
models had no collision.

Now every model's LXFML is sent and the client builds each one (NIF and
HKX). For the models whose mesh the UGC server made, once the client has
loaded and 3 seconds after, the world sends it the served NIF's checksum and
NotifyClientUGCModelReady: the client drops what it cached for the model and
asks again, downloads the served mesh (its own NIF no longer matches) and
keeps its own HKX (still matching). An HKX request is answered with the LXFML
too, followed by the same switch, at most 3 times per client and model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 11:02:29 -05:00
parent 87e47bcaa9
commit 2384f46a57
7 changed files with 112 additions and 35 deletions

View File

@@ -424,7 +424,7 @@ namespace {
c.Add(Port(UGC, "net_port", "Master connection port", "UDP; the next port is used too.", "2012"));
c.Add(Text(UGC, "client_path", "Download path", "The game client's UGCSERVERDIR.", "/ugc", true));
c.Add(Bool(SHARED, "ugc_manifest", "Answer clients without 3D services", "Worlds tell game clients with UGCUSE3DSERVICES=7:0 (the default) the checksums of the files the UGC server made, so they download icons of cars, rockets and models from UGCSERVERIP, UGCSERVERPORT and UGCSERVERDIR in their boot.cfg (default: the patch server's). A client that can't connect there is logged out, so only turn on when every player's boot.cfg points at the UGC server.", false));
c.Add(When(Bool(SHARED, "ugc_manifest_models", "Placed models from the UGC server", "With the answer to clients without 3D services on: placed player models the UGC server has made are downloaded from it (its mesh) instead of built by each client from the LXFML the world sends. It makes no physics: a client that never built a model itself has no collision for it. Models not made yet are still sent as LXFML.", false), SHARED, "ugc_manifest", { "1" }));
c.Add(When(Bool(SHARED, "ugc_manifest_models", "Placed models from the UGC server", "With the answer to clients without 3D services on: placed player models the UGC server has made are shown with its mesh. Each client still gets every model's LXFML and builds it first (for its collision; the UGC server makes no physics), then is switched to the served mesh a few seconds after it has loaded. Models not made yet stay as the client built them.", false), SHARED, "ugc_manifest", { "1" }));
c.Add(Format(Text(DASHBOARD, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL));
c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), eFormat::URL));
c.AddSection("UGC processing", "When models are made and how much of the machine the workers may use.", eLayout::ROWS, Condition{ MASTER, "enable_ugc_server", { "1" } });

View File

@@ -29,6 +29,10 @@ namespace {
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 a client has loaded it is switched to a served mesh (it builds the model from the LXFML first)
constexpr auto SERVED_MESH_DELAY = std::chrono::seconds(3);
constexpr size_t MAX_SWITCHES = 4096;
constexpr int MAX_SWITCHES_PER_MODEL = 3;
struct Waiting {
SystemAddress sysAddr;
@@ -42,6 +46,17 @@ namespace {
std::map<std::pair<SystemAddress, LWOOBJID>, std::chrono::steady_clock::time_point> g_LxfmlSent;
std::map<LWOOBJID, IUgc::FileChecksum> g_LxfmlChecksums;
// Clients given a served model's LXFML, to be switched to its served mesh: since when the client is loaded (unset
// while it isn't)
struct Switch {
SystemAddress sysAddr;
LWOOBJID blueprintId{};
std::chrono::steady_clock::time_point since;
std::optional<std::chrono::steady_clock::time_point> loadedAt;
};
std::vector<Switch> g_Switches;
std::map<std::pair<SystemAddress, LWOOBJID>, int> g_SwitchCount;
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)
@@ -122,6 +137,7 @@ namespace {
response.models.push_back({ blueprintId, model->lxfmlData.str() });
response.Send(sysAddr);
g_LxfmlSent[{ sysAddr, blueprintId }] = now;
UgcManifest::ScheduleServedMesh(sysAddr, blueprintId);
// 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));
@@ -137,8 +153,8 @@ UgcManifest::eAction UgcManifest::Decide(const eUgcResourceType type, const bool
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;
// 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;
}
}
@@ -167,8 +183,25 @@ bool UgcManifest::ServesModels() {
return ManifestOn() && ModelsOn();
}
bool UgcManifest::ClientBuildsModel(const LWOOBJID blueprintId) {
return !ServesModels() || !MeshMade(blueprintId);
bool UgcManifest::ServesMesh(const LWOOBJID blueprintId) {
return ServesModels() && MeshMade(blueprintId);
}
bool UgcManifest::ScheduleServedMesh(const SystemAddress& sysAddr, const LWOOBJID blueprintId) {
if (!ServesMesh(blueprintId)) return false;
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return true;
for (auto& pending : g_Switches) {
// Sent again (built again): the switch waits for the new build
if (pending.sysAddr == sysAddr && pending.blueprintId == blueprintId) {
pending.since = std::chrono::steady_clock::now();
pending.loadedAt.reset();
return true;
}
}
if (++g_SwitchCount[{ sysAddr, blueprintId }] > MAX_SWITCHES_PER_MODEL) return true;
if (g_Switches.size() >= MAX_SWITCHES) g_Switches.erase(g_Switches.begin());
g_Switches.push_back({ sysAddr, blueprintId, std::chrono::steady_clock::now(), std::nullopt });
return true;
}
void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId, eUgcResourceType resourceType) {
@@ -206,10 +239,38 @@ void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId,
g_Waiting.push_back({ sysAddr, blueprintId, resourceType, std::chrono::steady_clock::now() });
}
namespace {
// Switches one client to a model's served mesh; false while the client isn't ready for it yet
bool SwitchToServedMesh(Switch& pending, const std::chrono::steady_clock::time_point now) {
auto* const player = PlayerManager::GetPlayer(pending.sysAddr);
if (!player) return true; // gone
if (!player->GetPlayerReadyForUpdates()) return false;
if (!pending.loadedAt) pending.loadedAt = now;
if (now - *pending.loadedAt < SERVED_MESH_DELAY) return false;
const auto checksum = Database::Get()->GetUgcFileChecksum(pending.blueprintId, "model.nif");
if (!checksum || checksum->md5.size() != 32 || !Game::entityManager) return true;
// The served checksum first: NotifyClientUGCModelReady has the client ask again, and its cache answers
Send(pending.sysAddr, pending.blueprintId, eUgcResourceType::NIF, checksum);
size_t shown = 0;
for (auto* const model : Game::entityManager->GetEntitiesByLOT(BrickByBrick::MODEL_OBJECT_LOT)) {
if (!model || model->GetVar<LWOOBJID>(u"blueprintid") != pending.blueprintId) continue;
GameMessages::NotifyClientUGCModelReady ready;
ready.target = model->GetObjectID();
ready.blueprintID = pending.blueprintId;
ready.Send(pending.sysAddr);
shown++;
}
LOG_DEBUG("Switched a client to the served mesh of %llu (%zu placed)", static_cast<unsigned long long>(pending.blueprintId), shown);
return true;
}
}
void UgcManifest::Update() {
if (g_Waiting.empty()) return;
const auto now = std::chrono::steady_clock::now();
if (now < g_NextRetry) return;
std::erase_if(g_Switches, [now](Switch& pending) { return now - pending.since > MAX_WAIT || SwitchToServedMesh(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);
@@ -219,6 +280,8 @@ void UgcManifest::Update() {
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; });
std::erase_if(g_Switches, [&sysAddr](const Switch& pending) { return pending.sysAddr == sysAddr; });
std::erase_if(g_SwitchCount, [&sysAddr](const auto& count) { return count.first.first == sysAddr; });
}
void UgcManifest::OnModelsMade(const std::vector<LWOOBJID>& blueprintIds) {

View File

@@ -20,10 +20,13 @@
* * 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.
* downloads the served mesh) and the LXFML with the stored LXFML's. Otherwise, and always for the HKX (the UGC server
* makes no physics), 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, so a model is never left waiting.
* * Served meshes with the client's own physics: a client given the LXFML of a model whose mesh is served builds its
* own NIF and HKX, then (once it has loaded and a moment after) gets the served NIF's checksum and
* NotifyClientUGCModelReady for that model. It drops what it cached for the model and asks again: the NIF's checksum
* is now the served one, so it downloads the served mesh; the HKX's is still its own, so it keeps its collision.
*
* Main thread only: each lookup is one indexed query and the answers are sent from here. See docs/UgcServer.md.
*/
@@ -47,13 +50,20 @@ namespace UgcManifest {
// 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);
// ServesModels() and the UGC server made this model's mesh: a client that built the model from its LXFML (for its
// physics) is then switched to the served mesh (ScheduleServedMesh)
bool ServesMesh(LWOOBJID blueprintId);
// The client was sent this model's LXFML: when the mesh is served, once the client has loaded (and a moment after,
// for it to build the model) it gets the served NIF's checksum and NotifyClientUGCModelReady. At most a few times
// per client and model (a client that keeps asking for the HKX isn't switched back and forth for ever). Returns
// whether the mesh is served.
bool ScheduleServedMesh(const SystemAddress& sysAddr, 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
// Answers the waiting requests whose files have been made since, forgets the ones waited on too long, and switches
// clients to served meshes that are due
void Update();
// A client left: its waiting requests are dropped

View File

@@ -1033,8 +1033,8 @@ void LoadPlayer(const SystemAddress& sysAddr) {
}
// The models' LXFML, for the client to build each model's NIF and HKX itself. With ugc_manifest_models
// the models whose mesh the UGC server made are left out: the client asks for their files and downloads
// the mesh (UgcManifest, docs/UgcServer.md).
// the models whose mesh the UGC server made are then switched to the served mesh, keeping the client's
// own physics (UgcManifest, docs/UgcServer.md).
auto bbbModels = Database::Get()->GetUgcModels(propertyId);
if (bbbModels.empty()) {
@@ -1047,10 +1047,7 @@ void LoadPlayer(const SystemAddress& sysAddr) {
response.reasonCode = eBlueprintSaveResponseType::EverythingWorked;
size_t served = 0;
for (auto& bbbModel : bbbModels) {
if (!UgcManifest::ClientBuildsModel(bbbModel.id)) {
served++;
continue;
}
if (UgcManifest::ScheduleServedMesh(sysAddr, bbbModel.id)) served++;
LOG("Getting lxfml ugcID: %llu", bbbModel.id);
bbbModel.lxfmlData.seekg(0, std::ios::end);
@@ -1062,7 +1059,7 @@ void LoadPlayer(const SystemAddress& sysAddr) {
model.blueprintId = bbbModel.id;
model.data = bbbModel.lxfmlData.str().substr(0, lxfmlSize);
}
if (served > 0) LOG("%zu of the property's %zu models come from the UGC server", served, bbbModels.size());
if (served > 0) LOG("%zu of the property's %zu models get their mesh from the UGC server once built", served, bbbModels.size());
if (!response.models.empty()) response.Send(sysAddr);
}

View File

@@ -401,14 +401,19 @@ What the 1.10.64 client does with a placed player model (LOT 14, spawned with `b
With `ugc_manifest=1` and `ugc_manifest_models=1` (`sharedconfig.ini`, default 0; the dashboard shows it under UGC
serving), the worlds:
* **Property load**: send the LXFML (one `BlueprintSaveResponse`) only for the models whose mesh the UGC server hasn't
made (no `model.nif` in `ugc_file_checksums`: pending, failed, empty or the UGC server not running). Made ones are
left out; the client asks for their files.
* **Property load**: send the LXFML of every model (one `BlueprintSaveResponse`): the client builds each model's NIF
and HKX (its collision; the UGC server makes no physics). For the models whose mesh the UGC server made (a
`model.nif` in `ugc_file_checksums`), once the client has loaded (`PlayerLoaded`) and 3 seconds after, the world
sends it the served NIF's checksum and `NotifyClientUGCModelReady` for each placed copy. The client drops what it
cached for the model and asks again: its cache now has the served NIF's checksum, which its own file doesn't match,
so it downloads the served mesh, and still has its own HKX's, so it keeps the collision it built. The model shows
as the client built it for those few seconds.
* **NIF** of a made model: the UGC server's checksum; the client downloads `<id>.nif.sd0` from the UGC server.
* **LXFML** of a made model: the MD5 and size of the stored LXFML inflated (what the UGC server serves as
`<id>.lxfml.sd0`), worked out once per model and kept.
* **HKX** of a made model: valid 0. The UGC server makes no physics: a client that built the model itself before keeps
using its HKX; others ask the UGC server, get 404 (not a logout) and have no collision for that model.
* **HKX** of any model: the model's LXFML (the UGC server makes no physics), and for a made model the same switch to
the served mesh afterwards. At most 3 switches per client and model, so a client that keeps asking isn't switched
back and forth for ever.
* **Any model file of a model that isn't made** (e.g. a model someone else just placed, or `ugc_manifest_models=0`):
the model's LXFML to that client (once per 10 seconds for the three requests), which builds it itself, so a model is
never left waiting. A blueprint that isn't a player model gets valid 0.

View File

@@ -100,8 +100,7 @@ ugc_debounce_seconds=120
# out, so only set 1 when every player's boot.cfg points at the UGC server. See docs/UgcServer.md.
ugc_manifest=0
# 0 or 1, with ugc_manifest=1: placed player models the UGC server has made are downloaded from it (their mesh) instead
# of being built by each client from the LXFML the world sends; models not made yet are still sent as LXFML. The UGC
# server makes no physics (.hkx): a client that never built a model itself has no collision for it. See
# docs/UgcServer.md.
# 0 or 1, with ugc_manifest=1: placed player models the UGC server has made are shown with its mesh. Each client still
# gets every model's LXFML and builds it first (for its collision: the UGC server makes no physics), then is switched to
# the served mesh a few seconds after it has loaded. See docs/UgcServer.md.
ugc_manifest_models=0

View File

@@ -23,7 +23,8 @@ namespace {
// What each request gets. A placed model's client asks for its NIF, HKX and LXFML and waits for every answer without a
// timeout (LWOResMgr2Interface::RequestBlueprintManifestThenLoad, 0x0105a910), so model files are always answered:
// from the UGC server when it made the mesh and models are served, else with the LXFML for the client to build.
// from the UGC server when it made the mesh and models are served, else with the LXFML for the client to build. The HKX
// always gets the LXFML: the UGC server makes no physics, the client builds its own (then gets the served mesh).
TEST(UgcManifestTests, DecideCoversEveryType) {
using T = eUgcResourceType;
for (const auto type : { T::LXFML, T::NIF, T::HKX, T::DDS }) {
@@ -34,7 +35,7 @@ TEST(UgcManifestTests, DecideCoversEveryType) {
EXPECT_EQ(UgcManifest::Decide(T::NIF, true, true, true), eAction::ANSWER);
EXPECT_EQ(UgcManifest::Decide(T::LXFML, true, true, true), eAction::ANSWER);
EXPECT_EQ(UgcManifest::Decide(T::HKX, true, true, true), eAction::ANSWER_UNKNOWN);
EXPECT_EQ(UgcManifest::Decide(T::HKX, true, true, true), eAction::SEND_LXFML);
for (const auto type : { T::LXFML, T::NIF, T::HKX }) {
EXPECT_EQ(UgcManifest::Decide(type, true, true, false), eAction::SEND_LXFML); // not made yet
@@ -70,14 +71,16 @@ TEST_F(UgcManifestRequestTests, NothingWhenOff) {
const auto sent = Capture([] { UgcManifest::OnRequest(Client(), 1234, eUgcResourceType::NIF); });
EXPECT_TRUE(sent.empty());
EXPECT_FALSE(UgcManifest::ServesModels());
EXPECT_TRUE(UgcManifest::ClientBuildsModel(1234));
EXPECT_FALSE(UgcManifest::ServesMesh(1234));
EXPECT_FALSE(UgcManifest::ScheduleServedMesh(Client(), 1234));
}
// A model file of a blueprint that isn't a player model: answered as not known (37 bytes, valid 0), never left waiting
TEST_F(UgcManifestRequestTests, UnknownModelFilesAreAnsweredNotKnown) {
Settings("1", "1");
EXPECT_TRUE(UgcManifest::ServesModels());
EXPECT_TRUE(UgcManifest::ClientBuildsModel(1234)); // its mesh isn't made
EXPECT_FALSE(UgcManifest::ServesMesh(1234)); // its mesh isn't made
EXPECT_FALSE(UgcManifest::ScheduleServedMesh(Client(), 1234));
for (const auto type : { eUgcResourceType::NIF, eUgcResourceType::HKX, eUgcResourceType::LXFML }) {
const auto sent = Capture([type] { UgcManifest::OnRequest(Client(), 0x0102030405060708, type); });
ASSERT_EQ(sent.size(), 1);