From e5a230e6a3d70169bff82418f025b3abc57e1bcf Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 11:02:29 -0500 Subject: [PATCH] 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 --- dDashboardServer/routes/SettingsCatalog.cpp | 2 +- dGame/dUtilities/UgcManifest.cpp | 75 +++++++++++++++++++-- dGame/dUtilities/UgcManifest.h | 26 ++++--- dWorldServer/WorldServer.cpp | 11 ++- docs/UgcServer.md | 15 +++-- resources/sharedconfig.ini | 7 +- tests/dGameTests/UgcManifestTests.cpp | 11 +-- 7 files changed, 112 insertions(+), 35 deletions(-) diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 4dc91ace6..22c0134e8 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -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" } }); diff --git a/dGame/dUtilities/UgcManifest.cpp b/dGame/dUtilities/UgcManifest.cpp index 509180b4b..1109989db 100644 --- a/dGame/dUtilities/UgcManifest.cpp +++ b/dGame/dUtilities/UgcManifest.cpp @@ -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::chrono::steady_clock::time_point> g_LxfmlSent; std::map 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 loadedAt; + }; + std::vector g_Switches; + std::map, 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(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(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(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& blueprintIds) { diff --git a/dGame/dUtilities/UgcManifest.h b/dGame/dUtilities/UgcManifest.h index 26fdb7c60..9c561f84c 100644 --- a/dGame/dUtilities/UgcManifest.h +++ b/dGame/dUtilities/UgcManifest.h @@ -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 diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 231bcbd8d..373a5fd90 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -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); } diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 09e821b9c..b5dd5f27a 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -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 `.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 `.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. diff --git a/resources/sharedconfig.ini b/resources/sharedconfig.ini index a9152cf25..1b3d5c762 100644 --- a/resources/sharedconfig.ini +++ b/resources/sharedconfig.ini @@ -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 diff --git a/tests/dGameTests/UgcManifestTests.cpp b/tests/dGameTests/UgcManifestTests.cpp index cf2e26505..83b567a3c 100644 --- a/tests/dGameTests/UgcManifestTests.cpp +++ b/tests/dGameTests/UgcManifestTests.cpp @@ -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);