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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 11:57:29 -05:00
parent 92a87fa735
commit 5396594690
5 changed files with 21 additions and 91 deletions

View File

@@ -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::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;
// 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<unsigned long long>(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<LWOOBJID>(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<unsigned long long>(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; });
}

View File

@@ -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

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 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);
}

View File

@@ -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 `<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 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.

View File

@@ -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);