From 539659469094bcfe4bbccd79c2716999381baf29 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 11:57:29 -0500 Subject: [PATCH] fix(ugc): served models keep the served mesh; physics from the LXFML sent for the HKX Sending every model's LXFML at property load and then switching to the served mesh (served checksum, NotifyClientUGCModelReady, the model constructed again) left the client drawing its own build. Now made models' LXFML is left out of the property load again: the client downloads and draws the served mesh, and when it asks for the HKX it gets the LXFML, builds the model and loads its own HKX, so the model has collision while the mesh drawn stays the served one. The load-time switch is removed; the switch for a model made again while players are there stays. Co-Authored-By: Claude Opus 5.5 --- dGame/dUtilities/UgcManifest.cpp | 58 --------------------------- dGame/dUtilities/UgcManifest.h | 18 +++------ dWorldServer/WorldServer.cpp | 12 ++++-- docs/UgcServer.md | 22 ++++------ tests/dGameTests/UgcManifestTests.cpp | 2 - 5 files changed, 21 insertions(+), 91 deletions(-) diff --git a/dGame/dUtilities/UgcManifest.cpp b/dGame/dUtilities/UgcManifest.cpp index 3b1179c37..39a571f61 100644 --- a/dGame/dUtilities/UgcManifest.cpp +++ b/dGame/dUtilities/UgcManifest.cpp @@ -31,10 +31,6 @@ 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; // 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); @@ -54,17 +50,6 @@ 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; - // Placed models to construct again for a client once its flush is done struct Reconstruct { SystemAddress sysAddr; @@ -154,7 +139,6 @@ 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)); @@ -204,23 +188,6 @@ 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) { if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return; const auto manifestOn = ManifestOn(); @@ -295,33 +262,10 @@ namespace { return true; } - // 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; - ShowServedMesh(*model, *player, pending.blueprintId); - shown++; - } - LOG("Showed %s the served mesh of model %llu (%zu placed)", player->GetCharacter() ? player->GetCharacter()->GetName().c_str() : "a player", - static_cast(pending.blueprintId), shown); - return true; - } } void UgcManifest::Update() { const auto now = std::chrono::steady_clock::now(); - std::erase_if(g_Switches, [now](Switch& pending) { return now - pending.since > MAX_WAIT || SwitchToServedMesh(pending, now); }); std::erase_if(g_Reconstructs, [now](Reconstruct& pending) { return ConstructAgain(pending, now); }); if (g_Waiting.empty() || now < g_NextRetry) return; @@ -334,8 +278,6 @@ 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; }); std::erase_if(g_Reconstructs, [&sysAddr](const Reconstruct& pending) { return pending.sysAddr == sysAddr; }); } diff --git a/dGame/dUtilities/UgcManifest.h b/dGame/dUtilities/UgcManifest.h index 68fc721cc..b69968b51 100644 --- a/dGame/dUtilities/UgcManifest.h +++ b/dGame/dUtilities/UgcManifest.h @@ -23,10 +23,10 @@ * 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. + * * Served meshes with the client's own physics: the client shows the served NIF it downloaded, and asks for the HKX, + * which is answered with the model's LXFML: it builds the model from it and loads its own HKX. The mesh it has drawn + * stays the served one. (Built from the LXFML first, the client kept showing its own build even after + * NotifyClientUGCModelReady and the model constructed again.) * * Main thread only: each lookup is one indexed query and the answers are sent from here. See docs/UgcServer.md. */ @@ -50,16 +50,10 @@ namespace UgcManifest { // ugc_manifest=1 and ugc_manifest_models=1: placed models' meshes come from the UGC server when made bool ServesModels(); - // 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) + // ServesModels() and the UGC server made this model's mesh: the model's LXFML isn't sent when a property loads, the + // client downloads the served mesh, and builds the model's physics from the LXFML sent when it asks for the HKX 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, forgets the ones waited on too long, and switches diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 62d180c6e..47ce9a847 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 then switched to the served mesh, keeping the client's - // own physics (UgcManifest, docs/UgcServer.md). + // the models whose mesh the UGC server made are left out: the client downloads their mesh, and gets the + // LXFML (for its physics) when it asks for the HKX (UgcManifest, docs/UgcServer.md). auto bbbModels = Database::Get()->GetUgcModels(propertyId); if (bbbModels.empty()) { @@ -1047,7 +1047,11 @@ void LoadPlayer(const SystemAddress& sysAddr) { response.reasonCode = eBlueprintSaveResponseType::EverythingWorked; size_t served = 0; for (auto& bbbModel : bbbModels) { - if (UgcManifest::ScheduleServedMesh(sysAddr, bbbModel.id)) served++; + // Its served mesh is downloaded; its LXFML is sent when the client asks for the HKX (UgcManifest) + if (UgcManifest::ServesMesh(bbbModel.id)) { + served++; + continue; + } LOG("Getting lxfml ugcID: %llu", bbbModel.id); bbbModel.lxfmlData.seekg(0, std::ios::end); @@ -1059,7 +1063,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 get their mesh from the UGC server once built", served, bbbModels.size()); + if (served > 0) LOG("%zu of the property's %zu models come from the UGC server", served, bbbModels.size()); if (!response.models.empty()) response.Send(sysAddr); } diff --git a/docs/UgcServer.md b/docs/UgcServer.md index cde0a2055..57abc0c6d 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -405,24 +405,16 @@ 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 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, `NotifyClientUGCModelReady`, and takes the model down and constructs it again for - that client. `NotifyClientUGCModelReady` alone only flushes the client's cached NIF, HKX and LXFML and loads them - again as preloads (0x00ca6430): an object already drawn keeps its mesh. The object is taken down 1.5 seconds - later, after the client's load thread has done the flush, and constructed again 1 second after that (the client - deletes objects later and drops the construction of an object it still has; in the same batch, most models - disappeared). The object constructed again loads the NIF, - whose cached checksum is now the served one, which the client's own file doesn't match, so it downloads the served - mesh; the HKX's is still its own, so it keeps the collision it built. The model shows as the client built it for - those few seconds. +* **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`). Made ones are left out: the client asks for their files, downloads + the served mesh and draws it. Tried and dropped: sending every model's LXFML (the client builds its own NIF and HKX) + and then switching to the served mesh with the served NIF's checksum, `NotifyClientUGCModelReady` and the model + constructed again: the client kept drawing its own build. * **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 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. +* **HKX** of any model: the model's LXFML (the UGC server makes no physics). The client builds the model from it and + loads its own HKX, so served models have collision; the mesh already drawn stays the served one. * **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/tests/dGameTests/UgcManifestTests.cpp b/tests/dGameTests/UgcManifestTests.cpp index 83b567a3c..c644bbf3e 100644 --- a/tests/dGameTests/UgcManifestTests.cpp +++ b/tests/dGameTests/UgcManifestTests.cpp @@ -72,7 +72,6 @@ TEST_F(UgcManifestRequestTests, NothingWhenOff) { EXPECT_TRUE(sent.empty()); EXPECT_FALSE(UgcManifest::ServesModels()); 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 @@ -80,7 +79,6 @@ TEST_F(UgcManifestRequestTests, UnknownModelFilesAreAnsweredNotKnown) { Settings("1", "1"); EXPECT_TRUE(UgcManifest::ServesModels()); 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);