From b118ecfb73de931ccc15d5bd0047e307934bdf27 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 11:16:03 -0500 Subject: [PATCH] fix(ugc): swap to the served mesh by constructing the model again NotifyClientUGCModelReady only flushes the client's cached NIF, HKX and LXFML and loads them again as preloads (0x00ca6430): an object already drawn keeps its mesh, so the switch to the served mesh after a property load (and a model made again) changed nothing on screen. Now each player also has the model taken down and constructed again: the new object loads the NIF, whose cached checksum is the served one (sent first), and the HKX, whose checksum is still the one the client built, so it keeps collision. Only models shown to that player are constructed again. Each switch is logged. Also corrects the address of the client's brushed steel texture registration in the docs: 0x00467090 (RegisterBrushedSteelTextures) in the 1.10.64 client. Co-Authored-By: Claude Opus 5.5 --- dGame/dUtilities/UgcManifest.cpp | 37 +++++++++++++++++++++++--------- docs/UgcServer.md | 17 +++++++++------ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/dGame/dUtilities/UgcManifest.cpp b/dGame/dUtilities/UgcManifest.cpp index 1109989db..283c5debc 100644 --- a/dGame/dUtilities/UgcManifest.cpp +++ b/dGame/dUtilities/UgcManifest.cpp @@ -14,6 +14,8 @@ #include "eBlueprintSaveResponseType.h" #include "Entity.h" #include "EntityManager.h" +#include "GhostComponent.h" +#include "Character.h" #include "Game.h" #include "Logger.h" #include "MD5.h" @@ -240,6 +242,25 @@ void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId, } namespace { + // Shows a placed model to one client again with the served mesh. NotifyClientUGCModelReady only flushes the + // client's cached NIF, HKX and LXFML and loads them again as preloads (LWOBlueprintComponent:: + // OnNotifyClientUGCModelReady, 0x00ca6430): an object already drawn keeps its mesh. So the object is also taken + // down and constructed again for that client, and its new render component loads the NIF, whose cached checksum + // is now the served one (sent first). The HKX's is still the one the client built, so it keeps its collision. + void ShowServedMesh(Entity& model, Entity& player, const LWOOBJID blueprintId) { + const auto sysAddr = player.GetSystemAddress(); + if (model.GetIsGhostingCandidate()) { + auto* const ghost = player.GetComponent(); + if (!ghost || !ghost->IsObserved(model.GetObjectID())) return; // not shown to this client (yet) + } + GameMessages::NotifyClientUGCModelReady ready; + ready.target = model.GetObjectID(); + ready.blueprintID = blueprintId; + ready.Send(sysAddr); + Game::entityManager->DestructEntity(&model, sysAddr); + Game::entityManager->ConstructEntity(&model, sysAddr); + } + // 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); @@ -255,13 +276,11 @@ namespace { 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); + ShowServedMesh(*model, *player, pending.blueprintId); shown++; } - LOG_DEBUG("Switched a client to the served mesh of %llu (%zu placed)", static_cast(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; } } @@ -304,11 +323,9 @@ void UgcManifest::OnModelsMade(const std::vector& blueprintIds) { 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); + for (auto* const player : players) { + if (!player) continue; + for (auto* const model : shown) ShowServedMesh(*model, *player, id); } LOG("The UGC server made model %llu again: told %zu client(s) about %zu placed model(s)", static_cast(id), players.size(), shown.size()); } diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 0ff187f47..b56ac3cf5 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -225,7 +225,7 @@ all of its levels, so each look needs a group of its own. | Setting (`ugcconfig.ini`, dashboard: UGC models) | Default | What it writes | | --- | --- | --- | | `shader_metal` | 0 (off) | `S_Metal_Model` for metal colors: 88 is Polished Metal (gameValue 98). The client loads `textures/metal/metal_reflection_polished.dds` itself and tints it by the vertex color (`Metallic.fx`, `Technique_Lighting_PolishedMetal_VertColor`). | -| `shader_brushed` | 0 (off) | `S_Brushed_Model` for brushed steel colors: 89 is Brushed Steel (gameValue 99; it loads `metal_reflection_brushed.dds` and `_noise.dds`, the noise in object space). The textures are registered by the client (0x00453730) as global shader textures 6 and 7, so the .nif needs none. The client's Materials.xml has no brushed types, so this needs `brushed_colors` or a Materials.xml that names them. | +| `shader_brushed` | 0 (off) | `S_Brushed_Model` for brushed steel colors: 89 is Brushed Steel (gameValue 99; it loads `metal_reflection_brushed.dds` and `_noise.dds`, the noise in object space). The textures are registered by the client (`RegisterBrushedSteelTextures`, 0x00467090) as global shader textures 6 and 7, so the .nif needs none. The client's Materials.xml has no brushed types, so this needs `brushed_colors` or a Materials.xml that names them. | | `shader_glow` | 0 (off) | `S_Glow_Model` for opaque glowing colors: 46 is LEGO-Emissive (gameValue 53), which draws `lerp(lit, vertex color, vertex alpha * material emissive red)`, opaque. | | `glow_emissive` | 1 | The glow shapes' `NiMaterialProperty` emissive (grey): how far the shader goes from lit to the plain color. | | `metal_material_types` | `shinySteel` | Materials.xml `MaterialType`s that are metal (empty: the default; `none`: none). | @@ -408,10 +408,12 @@ 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 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. + 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 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. * **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. @@ -423,8 +425,9 @@ serving), the worlds: never left waiting. A blueprint that isn't a player model gets valid 0. * **Made again**: when the UGC server writes a model's mesh with a different checksum than before (made for the first time, or remade after a change), it sends `UGC_MODELS_MADE` (master message 37, the blueprint ids) to the master, - which passes it to every world. A world with that model placed sends every player the new NIF checksum and then - `NotifyClientUGCModelReady` to the model, so a client showing the model it built itself switches to the served mesh. + which passes it to every world. A world with that model placed sends every player the new NIF checksum, then + `NotifyClientUGCModelReady` and the model constructed again to each player it's shown to, so the served mesh + replaces what the client showed. A model made again unchanged (after eviction) isn't sent. Nothing polls: one message per batch of made models. ### Waiting while the owner is still building