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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 11:16:03 -05:00
parent 0c5ef380d3
commit b118ecfb73
2 changed files with 37 additions and 17 deletions

View File

@@ -14,6 +14,8 @@
#include "eBlueprintSaveResponseType.h" #include "eBlueprintSaveResponseType.h"
#include "Entity.h" #include "Entity.h"
#include "EntityManager.h" #include "EntityManager.h"
#include "GhostComponent.h"
#include "Character.h"
#include "Game.h" #include "Game.h"
#include "Logger.h" #include "Logger.h"
#include "MD5.h" #include "MD5.h"
@@ -240,6 +242,25 @@ void UgcManifest::OnRequest(const SystemAddress& sysAddr, LWOOBJID blueprintId,
} }
namespace { 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<GhostComponent>();
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 // 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) { bool SwitchToServedMesh(Switch& pending, const std::chrono::steady_clock::time_point now) {
auto* const player = PlayerManager::GetPlayer(pending.sysAddr); auto* const player = PlayerManager::GetPlayer(pending.sysAddr);
@@ -255,13 +276,11 @@ namespace {
size_t shown = 0; size_t shown = 0;
for (auto* const model : Game::entityManager->GetEntitiesByLOT(BrickByBrick::MODEL_OBJECT_LOT)) { for (auto* const model : Game::entityManager->GetEntitiesByLOT(BrickByBrick::MODEL_OBJECT_LOT)) {
if (!model || model->GetVar<LWOOBJID>(u"blueprintid") != pending.blueprintId) continue; if (!model || model->GetVar<LWOOBJID>(u"blueprintid") != pending.blueprintId) continue;
GameMessages::NotifyClientUGCModelReady ready; ShowServedMesh(*model, *player, pending.blueprintId);
ready.target = model->GetObjectID();
ready.blueprintID = pending.blueprintId;
ready.Send(pending.sysAddr);
shown++; shown++;
} }
LOG_DEBUG("Switched a client to the served mesh of %llu (%zu placed)", static_cast<unsigned long long>(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<unsigned long long>(pending.blueprintId), shown);
return true; return true;
} }
} }
@@ -304,11 +323,9 @@ void UgcManifest::OnModelsMade(const std::vector<LWOOBJID>& blueprintIds) {
for (auto* const player : players) { for (auto* const player : players) {
if (player) Send(player->GetSystemAddress(), id, eUgcResourceType::NIF, checksum); if (player) Send(player->GetSystemAddress(), id, eUgcResourceType::NIF, checksum);
} }
for (auto* const model : shown) { for (auto* const player : players) {
GameMessages::NotifyClientUGCModelReady ready; if (!player) continue;
ready.target = model->GetObjectID(); for (auto* const model : shown) ShowServedMesh(*model, *player, id);
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()); 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

@@ -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 | | Setting (`ugcconfig.ini`, dashboard: UGC models) | Default | What it writes |
| --- | --- | --- | | --- | --- | --- |
| `shader_metal` | 0 (off) | `S<id>_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_metal` | 0 (off) | `S<id>_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<id>_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<id>_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<id>_Glow_Model` for opaque glowing colors: 46 is LEGO-Emissive (gameValue 53), which draws `lerp(lit, vertex color, vertex alpha * material emissive red)`, opaque. | | `shader_glow` | 0 (off) | `S<id>_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. | | `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). | | `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 * **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 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 `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 sends it the served NIF's checksum, `NotifyClientUGCModelReady`, and takes the model down and constructs it again for
cached for the model and asks again: its cache now has the served NIF's checksum, which its own file doesn't match, that client. `NotifyClientUGCModelReady` alone only flushes the client's cached NIF, HKX and LXFML and loads them
so it downloads the served mesh, and still has its own HKX's, so it keeps the collision it built. The model shows again as preloads (0x00ca6430): an object already drawn keeps its mesh. The object constructed again loads the NIF,
as the client built it for those few seconds. 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 `<id>.nif.sd0` from the UGC server. * **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 * **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. `<id>.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. 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 * **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, 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 which passes it to every world. A world with that model placed sends every player the new NIF checksum, then
`NotifyClientUGCModelReady` to the model, so a client showing the model it built itself switches to the served mesh. `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. 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 ### Waiting while the owner is still building