fix(ugc): send the served mesh checksums when a player loads a property

A client asks for a served model's HKX and is answered with the model's
LXFML, so it builds its own collision. That build also writes the
client's own .nif over the served one it downloaded and caches that
file's MD5 as the NIF's manifest info (client 1.10.64:
LWOBBBInterface::GenerateModelFromLxfml 0x00b6c220, MainThread_Process-
ModelResponse 0x00b5a1e0; valid entries never expire, 0x010186d0). On the
next load of the property the client used its cached checksum and file
without asking, and drew its own build instead of the served mesh.

Now, before the property's objects are constructed for a player, the
world sends them the served NIF checksum of every made model placed there
(UgcManifest::OnPropertyLoading). A client whose cached checksum is its
own build's downloads the served mesh again; one that has it already
downloads nothing. Nothing is sent unless ugc_manifest and
ugc_manifest_models are 1.

Check in game (ugc_manifest=1, ugc_manifest_models=1): visit a property
with made models, leave, come back in the same session (and after a
client restart): the models show the served mesh every time, and still
have collision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:05:14 -05:00
parent bd1d76193f
commit 266f8b81c5
5 changed files with 73 additions and 2 deletions

View File

@@ -418,6 +418,22 @@ namespace {
}
}
size_t UgcManifest::OnPropertyLoading(const SystemAddress& sysAddr, const LWOOBJID propertyId) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS || !ServesModels()) return 0;
std::set<LWOOBJID> blueprintIds;
for (const auto& model : Database::Get()->GetPropertyModels(propertyId)) {
if (model.ugcId != 0) blueprintIds.insert(model.ugcId);
}
size_t sent = 0;
for (const auto id : blueprintIds) {
const auto checksum = Database::Get()->GetUgcFileChecksum(id, "model.nif");
if (!checksum || checksum->md5.size() != 32) continue; // not made: its LXFML is sent
Send(sysAddr, id, eUgcResourceType::NIF, checksum);
sent++;
}
return sent;
}
void UgcManifest::OnLxfmlSent(const SystemAddress& sysAddr, const LWOOBJID blueprintId) {
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) return;
g_Switches.LxfmlSent(sysAddr, blueprintId, std::chrono::steady_clock::now());

View File

@@ -110,6 +110,12 @@ namespace UgcManifest {
// to is switched to the served mesh (SwitchClient), at once or once it can't be building that model any more
void OnModelsMade(const std::vector<LWOOBJID>& blueprintIds);
// A player is loading a property (before its models are constructed for them): the client is sent the served NIF
// checksum of every placed model whose mesh the UGC server made. Its cached one can be its own build's (the LXFML it
// was sent for the HKX, or before the model was made, replaced the served .nif and its checksum), which the client
// would use as it is; with the served checksum it downloads the served mesh again. Returns how many were sent.
size_t OnPropertyLoading(const SystemAddress& sysAddr, LWOOBJID propertyId);
// A model's LXFML was sent to a client another way (a brick by brick save, a property load): it builds the model
void OnLxfmlSent(const SystemAddress& sysAddr, LWOOBJID blueprintId);

View File

@@ -1062,6 +1062,15 @@ void LoadPlayer(const SystemAddress& sysAddr) {
respawnCheckpoint.SendToClient(player->GetSystemAddress());
}
// Before the models are constructed: the served meshes' checksums, so a client whose cached checksum is its own
// build's downloads the served mesh again (UgcManifest, docs/UgcServer.md)
if (g_CloneID != 0 && UgcManifest::ServesModels()) {
const auto mapId = Game::zoneManager->GetZone()->GetZoneID().GetMapID();
if (const auto property = Database::Get()->GetPropertyInfo(mapId, g_CloneID)) {
UgcManifest::OnPropertyLoading(sysAddr, property->id);
}
}
Game::entityManager->ConstructAllEntities(sysAddr);
characterComponent->RocketUnEquip(player);

View File

@@ -569,7 +569,10 @@ 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`). 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)
the served mesh and draws it. Before the models are constructed for the player, the world sends them the served NIF
checksum of every made model placed there (`UgcManifest::OnPropertyLoading`): after an earlier visit the client's
cached NIF checksum is its own build's (from the LXFML it got for the HKX, or before the model was made), which it
would use without asking; with the served one it downloads the served mesh again. Tried and dropped: sending every model's LXFML (the client builds its own NIF and HKX)
and switching to the served mesh 3 s later with the served NIF's checksum, `NotifyClientUGCModelReady` and the model
constructed again: the client kept drawing its own build (most likely because its builds of those LXFMLs finished
after the switch and cached their own NIFs again; see "Waiting for the client's own build").
@@ -577,7 +580,10 @@ serving), the worlds:
* **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). 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.
loads its own HKX, so served models have collision; the mesh already drawn stays the served one. The build also
replaces the served `.nif` on disk and its cached checksum (see above), so the next load needs the served checksum
again (Property load). The HKX's checksum stays cached (valid entries never expire), so the client asks for the
HKX, and rebuilds, only while it has no entry for it.
* **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

@@ -221,3 +221,37 @@ TEST_F(UgcManifestRequestTests, SwitchSendsTheChecksumThenModelReady) {
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 88 77 66 55 44 33 22 11 8d 03 08 07 06 05 04 03 02 01"), FromCapture(sent[1]));
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 99 77 66 55 44 33 22 11 8d 03 08 07 06 05 04 03 02 01"), FromCapture(sent[2]));
}
namespace {
// A property with four placed models: 1234 (made) twice, 5678 (not made) and a prefab without a blueprint
class PropertyDatabase : public TestSQLDatabase {
public:
std::vector<IPropertyContents::Model> GetPropertyModels(const LWOOBJID&) override {
std::vector<IPropertyContents::Model> models(4);
models[0].ugcId = 1234;
models[1].ugcId = 1234;
models[2].ugcId = 5678;
return models;
}
std::optional<IUgc::FileChecksum> GetUgcFileChecksum(const LWOOBJID blueprintId, const std::string_view file) override {
if (blueprintId == 1234 && file == "model.nif") return IUgc::FileChecksum{ "00112233445566778899aabbccddeeff", 0x01020304 };
return std::nullopt;
}
};
}
// A property load sends the served NIF checksum of each made model once, before the models are constructed: the
// client's cached one can be its own build's, which it would use as it is
TEST_F(UgcManifestRequestTests, PropertyLoadSendsTheServedChecksums) {
Database::_setDatabase(new PropertyDatabase());
Settings("1", "1");
const auto sent = Capture([] { EXPECT_EQ(UgcManifest::OnPropertyLoading(Client(), 42), 1); });
ASSERT_EQ(sent.size(), 1);
EXPECT_EQ(sent[0].sysAddr, Client());
EXPECT_PACKET_EQ(FromHex("53 05 00 3c 00 00 00 00 d2 04 00 00 00 00 00 00 01 01 04 03 02 01 "
"00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff"), FromCapture(sent[0]));
// Models not served: nothing
Settings("1", "0");
EXPECT_TRUE(Capture([] { EXPECT_EQ(UgcManifest::OnPropertyLoading(Client(), 42), 0); }).empty());
}