From 8ab7e496b00064dcaec5c3550df86528ebc76847 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 14:47:29 -0500 Subject: [PATCH] feat(bbb): answer FetchModelMetadataRequest for brick built models The client asks for a model's metadata (name, owner, behaviors and its blueprint's bricks and box) when it shows a brick built model item's tooltip, a model on a property or an exhibit, and shows BBB_LOADING_BLUEPRINT until it gets it. The world server never answered. It now does as live did: UG data for the model (found among the player's items by subkey, else among placed models) and, for a brick built model, the blueprint data from its ugc row and LXFML. Co-Authored-By: Claude Opus 5.5 --- dCommon/Lxfml.cpp | 43 ++++++ dCommon/Lxfml.h | 10 ++ dGame/dGameMessages/BuildingMessages.cpp | 104 +++++++++++++++ dGame/dGameMessages/BuildingMessages.h | 64 +++++++++ dGame/dGameMessages/GameMessageHandler.cpp | 1 + dGame/dUtilities/BrickByBrick.cpp | 125 ++++++++++++++++++ dGame/dUtilities/BrickByBrick.h | 12 ++ docs/BuildWorkflow.md | 35 +++++ tests/dCommonTests/LxfmlTests.cpp | 19 +++ tests/dGameTests/BrickByBrickTests.cpp | 11 ++ .../BuildingMessagesTests.cpp | 55 ++++++++ 11 files changed, 479 insertions(+) diff --git a/dCommon/Lxfml.cpp b/dCommon/Lxfml.cpp index b950d0371..f93d6d657 100644 --- a/dCommon/Lxfml.cpp +++ b/dCommon/Lxfml.cpp @@ -4,6 +4,7 @@ #include "StringifiedEnum.h" #include "TinyXmlUtils.h" +#include #include #include #include @@ -30,6 +31,48 @@ namespace { )"; } +Lxfml::Contents Lxfml::ReadContents(const std::string_view data) { + Contents contents; + if (data.empty()) return contents; + + tinyxml2::XMLDocument doc; + if (doc.Parse(data.data(), data.size()) != tinyxml2::XML_SUCCESS) return contents; + const auto* bricks = doc.FirstChildElement("LXFML") ? doc.FirstChildElement("LXFML")->FirstChildElement("Bricks") : nullptr; + if (!bricks) return contents; + + bool anyBone = false; + for (const auto* brick = bricks->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) { + // designID may carry a suffix ("3001;A") + const std::string_view design = brick->Attribute("designID") ? brick->Attribute("designID") : ""; + const auto digits = design.substr(0, design.find_first_not_of("0123456789")); + const auto designId = GeneralUtils::TryParse(digits); + if (designId) contents.designIds.push_back(*designId); + + for (const auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) { + for (const auto* bone = part->FirstChildElement("Bone"); bone; bone = bone->NextSiblingElement("Bone")) { + const auto* transformation = bone->Attribute("transformation"); + if (!transformation) continue; + const auto split = GeneralUtils::SplitString(transformation, ','); + if (split.size() < 12) continue; + const auto x = GeneralUtils::TryParse(split[9]); + const auto y = GeneralUtils::TryParse(split[10]); + const auto z = GeneralUtils::TryParse(split[11]); + if (!x || !y || !z) continue; + const NiPoint3 position{ *x, *y, *z }; + if (!anyBone) { + contents.boxMin = position; + contents.boxMax = position; + anyBone = true; + continue; + } + contents.boxMin = NiPoint3(std::min(contents.boxMin.x, position.x), std::min(contents.boxMin.y, position.y), std::min(contents.boxMin.z, position.z)); + contents.boxMax = NiPoint3(std::max(contents.boxMax.x, position.x), std::max(contents.boxMax.y, position.y), std::max(contents.boxMax.z, position.z)); + } + } + } + return contents; +} + Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) { Result toReturn; diff --git a/dCommon/Lxfml.h b/dCommon/Lxfml.h index 1baaeeed3..9503bcf24 100644 --- a/dCommon/Lxfml.h +++ b/dCommon/Lxfml.h @@ -21,6 +21,16 @@ namespace Lxfml { [[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO); [[nodiscard]] std::vector Split(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO); + // What a model is made of: every brick's design id (one per Brick, in file order) and the box around the bricks' + // origins (their bones' positions; zero with no bricks). Brick shapes aren't known here, so the box is the + // smallest one holding every brick's origin. + struct Contents { + std::vector designIds; + NiPoint3 boxMin{}; + NiPoint3 boxMax{}; + }; + [[nodiscard]] Contents ReadContents(const std::string_view data); + // these are only for the migrations due to a bug in one of the implementations. [[nodiscard]] Result NormalizePositionOnlyFirstPart(const std::string_view data); [[nodiscard]] Result NormalizePositionAfterFirstPart(const std::string_view data, const NiPoint3& position); diff --git a/dGame/dGameMessages/BuildingMessages.cpp b/dGame/dGameMessages/BuildingMessages.cpp index 19eea22fd..e11f2e451 100644 --- a/dGame/dGameMessages/BuildingMessages.cpp +++ b/dGame/dGameMessages/BuildingMessages.cpp @@ -603,6 +603,110 @@ namespace GameMessages { return true; } + void UGObjectMetadata::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(userModelID); + bitStream.Write(blueprintID); + BitStreamUtils::WriteLengthPrefixed(bitStream, userModelName); + BitStreamUtils::WriteLengthPrefixed(bitStream, userModelDesc); + bitStream.Write(owningPlayerID); + bitStream.Write(accountID); + BitStreamUtils::WriteLengthPrefixed(bitStream, owningPlayerName); + const auto count = static_cast(std::min(userModelBehaviors.size(), UINT8_MAX)); + bitStream.Write(count); + for (uint8_t i = 0; i < count; i++) bitStream.Write(userModelBehaviors[i]); + } + + bool UGObjectMetadata::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(userModelID)); + VALIDATE_READ(bitStream.Read(blueprintID)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, userModelName)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, userModelDesc)); + VALIDATE_READ(bitStream.Read(owningPlayerID)); + VALIDATE_READ(bitStream.Read(accountID)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, owningPlayerName)); + uint8_t count{}; + VALIDATE_READ(bitStream.Read(count)); + userModelBehaviors.resize(count); + for (auto& behavior : userModelBehaviors) VALIDATE_READ(bitStream.Read(behavior)); + return true; + } + + void BlueprintMetadata::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(blueprintID); + bitStream.Write(blueprintCreationTimestamp); + bitStream.Write(userModelMod); + bitStream.Write(modelBoxMins); + bitStream.Write(modelBoxMaxs); + bitStream.Write(userModelOpt); + bitStream.Write(ugcIconReady); + BitStreamUtils::WriteLengthPrefixed(bitStream, brickListColonDelim); + bitStream.Write(neverFalseIfPresentInCaps); + bitStream.Write(numberOfBricks); + } + + bool BlueprintMetadata::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(blueprintID)); + VALIDATE_READ(bitStream.Read(blueprintCreationTimestamp)); + VALIDATE_READ(bitStream.Read(userModelMod)); + VALIDATE_READ(bitStream.Read(modelBoxMins)); + VALIDATE_READ(bitStream.Read(modelBoxMaxs)); + VALIDATE_READ(bitStream.Read(userModelOpt)); + VALIDATE_READ(bitStream.Read(ugcIconReady)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, brickListColonDelim)); + VALIDATE_READ(bitStream.Read(neverFalseIfPresentInCaps)); + VALIDATE_READ(bitStream.Read(numberOfBricks)); + return true; + } + + void FetchModelMetadataRequest::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(context); + bitStream.Write(objectID); + bitStream.Write(requestorID); + bitStream.Write(ugID); + } + + bool FetchModelMetadataRequest::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(context)); + VALIDATE_READ(bitStream.Read(objectID)); + VALIDATE_READ(bitStream.Read(requestorID)); + VALIDATE_READ(bitStream.Read(ugID)); + return true; + } + + void FetchModelMetadataRequest::Handle(Entity& entity, const SystemAddress& sysAddr) { + FetchModelMetadataResponse response; + response.target = entity.GetObjectID(); + response.ugID = ugID; + response.objectID = objectID; + response.requestorID = requestorID; + response.context = context; + BrickByBrick::FillModelMetadata(entity, ugID, response); + response.Send(sysAddr); + } + + void FetchModelMetadataResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(ugID); + bitStream.Write(objectID); + bitStream.Write(requestorID); + bitStream.Write(context); + bitStream.Write(bHasUGData); + bitStream.Write(bHasBPData); + if (bHasUGData) ugData.Serialize(bitStream); + if (bHasBPData) bpData.Serialize(bitStream); + } + + bool FetchModelMetadataResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(ugID)); + VALIDATE_READ(bitStream.Read(objectID)); + VALIDATE_READ(bitStream.Read(requestorID)); + VALIDATE_READ(bitStream.Read(context)); + VALIDATE_READ(bitStream.Read(bHasUGData)); + VALIDATE_READ(bitStream.Read(bHasBPData)); + if (bHasUGData) VALIDATE_READ(ugData.Deserialize(bitStream)); + if (bHasBPData) VALIDATE_READ(bpData.Deserialize(bitStream)); + return true; + } + void SetModelToBuild::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(modelLot != -1); if (modelLot != -1) bitStream.Write(modelLot); diff --git a/dGame/dGameMessages/BuildingMessages.h b/dGame/dGameMessages/BuildingMessages.h index 264b7c48d..3417e8960 100644 --- a/dGame/dGameMessages/BuildingMessages.h +++ b/dGame/dGameMessages/BuildingMessages.h @@ -242,6 +242,70 @@ namespace GameMessages { int32_t count{}; }; + // A model's user generated data (UGObjectMetadata::Serialize, 0x00f5f590) + struct UGObjectMetadata { + LWOOBJID userModelID{}; + LWOOBJID blueprintID{}; + std::u16string userModelName; + std::u16string userModelDesc; + LWOOBJID owningPlayerID{}; + LWOOBJID accountID{}; + std::u16string owningPlayerName; + std::vector userModelBehaviors; // a u8 count on the wire; live always sent 5 + + void Serialize(RakNet::BitStream& bitStream) const; + bool Deserialize(RakNet::BitStream& bitStream); + }; + + // A brick built model's blueprint data (BlueprintMetadata::Serialize, 0x00f5f760) + struct BlueprintMetadata { + LWOOBJID blueprintID{}; + int64_t blueprintCreationTimestamp{}; // Unix seconds + int32_t userModelMod{ 1 }; + NiPoint3 modelBoxMins{}; // the model's box, relative to its origin (the exhibit checks its size) + NiPoint3 modelBoxMaxs{}; + bool userModelOpt{ true }; + bool ugcIconReady{ true }; + std::u16string brickListColonDelim; // every brick's LOT, each followed by ':' + bool neverFalseIfPresentInCaps{ true }; + int32_t numberOfBricks{}; + + void Serialize(RakNet::BitStream& bitStream) const; + bool Deserialize(RakNet::BitStream& bitStream); + }; + + // Client -> server. The client wants the name, owner, behaviors and blueprint of a model (its UGID) it has no + // metadata for: a brick built model item's tooltip (LWOInventoryComponent_Client::FillinDetailsBlueprint, 0x00cddb40, via + // UGModelMetadataCache::GetModelMetadata, 0x00b5e680), a model on the property, an exhibit. + struct FetchModelMetadataRequest : public NetGameMsg { + FetchModelMetadataRequest() : NetGameMsg(MessageType::Game::FETCH_MODEL_METADATA_REQUEST) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + int32_t context{}; + LWOOBJID objectID{}; + LWOOBJID requestorID{}; + LWOOBJID ugID{}; + }; + + // Server -> client. The answer: the client caches it by UGID and blueprint id and writes it into the config of + // the inventory item objectID (LWOInventoryComponent_Client::msgFetchModelMetadataResponse, 0x00c64300). + struct FetchModelMetadataResponse : public NetGameMsg { + FetchModelMetadataResponse() : NetGameMsg(MessageType::Game::FETCH_MODEL_METADATA_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + LWOOBJID ugID{}; + LWOOBJID objectID{}; + LWOOBJID requestorID{}; + int32_t context{}; + bool bHasUGData{}; + bool bHasBPData{}; + UGObjectMetadata ugData; // when bHasUGData + BlueprintMetadata bpData; // when bHasBPData + }; + struct SetModelToBuild : public NetGameMsg { SetModelToBuild() : NetGameMsg(MessageType::Game::SET_MODEL_TO_BUILD) {} void Serialize(RakNet::BitStream& bitStream) const override; diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index 5f6fdc23f..4799cc401 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -141,6 +141,7 @@ namespace { { SET_BUILD_MODE, []() { return std::make_unique(); } }, { BUILD_MODE_SET, []() { return std::make_unique(); } }, { UN_USE_BBB_MODEL, []() { return std::make_unique(); } }, + { FETCH_MODEL_METADATA_REQUEST, []() { return std::make_unique(); } }, { BBB_LOAD_ITEM_REQUEST, []() { return std::make_unique(); } }, { BBB_SAVE_REQUEST, []() { return std::make_unique(); } }, { SET_BBB_AUTOSAVE, []() { return std::make_unique(); } }, diff --git a/dGame/dUtilities/BrickByBrick.cpp b/dGame/dUtilities/BrickByBrick.cpp index a1a7f68ef..79ed43064 100644 --- a/dGame/dUtilities/BrickByBrick.cpp +++ b/dGame/dUtilities/BrickByBrick.cpp @@ -1,6 +1,8 @@ #include "BrickByBrick.h" #include "BuildingMessages.h" +#include "CDBrickIDTableTable.h" +#include "CDClientManager.h" #include "Character.h" #include "ClientPackets.h" #include "DashboardNotify.h" @@ -33,6 +35,7 @@ #include #include #include +#include namespace { // Every item in one inventory, copied so the inventory can change while going through them @@ -128,6 +131,45 @@ namespace { } return saved; } + + // A config value as text (empty when missing) + std::string ConfigText(const LwoNameValue& config, const std::u16string& key) { + const auto it = config.find(key); + if (it == config.end() || !it->second) return ""; + return it->second->GetValueAsString(); + } + + // A brick's LOT by its LEGO design id (BrickIDTable); 0 when the design has no item + LOT BrickLot(const uint32_t designId) { + static std::unordered_map lots; + if (lots.empty()) { + for (const auto& entry : CDClientManager::GetTable()->Query([](const CDBrickIDTable&) { return true; })) { + lots.try_emplace(entry.LEGOBrickID, static_cast(entry.NDObjectID)); + } + } + const auto it = lots.find(designId); + return it == lots.end() ? 0 : it->second; + } + + // The blueprint data of a stored brick built model (its ugc row); false when there is no such row + bool FillBlueprintMetadata(const LWOOBJID blueprintId, GameMessages::BlueprintMetadata& data) { + auto model = Database::Get()->GetUgcModel(blueprintId); + if (!model) return false; + + data.blueprintID = blueprintId; + Sd0 sd0(model->lxfmlData); + const auto contents = Lxfml::ReadContents(sd0.GetAsStringUncompressed()); + std::vector lots; + for (const auto designId : contents.designIds) { + const auto lot = BrickLot(designId); + if (lot != 0) lots.push_back(lot); + } + data.brickListColonDelim = BrickByBrick::BrickList(lots); + data.numberOfBricks = static_cast(contents.designIds.size()); + data.modelBoxMins = contents.boxMin; + data.modelBoxMaxs = contents.boxMax; + return true; + } } bool BrickByBrick::IsEmptyModel(const std::string_view sd0) { @@ -393,3 +435,86 @@ uint32_t BrickByBrick::RecoverUnfinishedBuild(Entity& player) { } return rebuilt; } + +std::vector BrickByBrick::BehaviorIds(const std::string_view behaviors) { + std::vector ids(5, LWOOBJID_EMPTY); + size_t index = 0; + for (const auto& part : GeneralUtils::SplitString(std::string(behaviors), ',')) { + if (index >= ids.size()) break; + ids[index++] = GeneralUtils::TryParse(part).value_or(LWOOBJID_EMPTY); + } + return ids; +} + +std::u16string BrickByBrick::BrickList(const std::vector& brickLots) { + std::u16string list; + for (const auto lot : brickLots) { + list += GeneralUtils::to_u16string(lot); + list += u':'; + } + return list; +} + +void BrickByBrick::FillModelMetadata(Entity& player, const LWOOBJID ugId, GameMessages::FetchModelMetadataResponse& response) { + if (ugId == LWOOBJID_EMPTY) return; + + bool found = false; + LWOOBJID blueprintId = LWOOBJID_EMPTY; + std::string behaviors; + std::u16string name; + std::u16string description; + + // One of the player's model items: a brick built model keeps its UGID as the item's subkey + auto* inventory = player.GetComponent(); + if (inventory) { + for (auto* bag : inventory->GetInventories() | std::views::values) { + for (const auto* item : bag->GetItems() | std::views::values) { + if (item->GetSubKey() != ugId) continue; + const auto& config = item->GetConfig(); + blueprintId = ConfigObjectId(config, u"blueprintid"); + behaviors = ConfigText(config, u"userModelBehaviors"); + name = GeneralUtils::UTF8ToUTF16(ConfigText(config, u"userModelName")); + description = GeneralUtils::UTF8ToUTF16(ConfigText(config, u"userModelDesc")); + found = true; + break; + } + if (found) break; + } + } + + // A model placed on a property (this one or one the player visits) + if (!found) { + const auto model = Database::Get()->GetModel(ugId); + if (model) { + found = true; + blueprintId = model->ugcId; + for (size_t i = 0; i < model->behaviors.size(); i++) { + if (i != 0) behaviors += ','; + behaviors += std::to_string(model->behaviors[i]); + } + } + } + if (!found) { + LOG_DEBUG("Player %llu asked for the metadata of model %llu, which is neither theirs nor placed", player.GetObjectID(), ugId); + return; + } + + auto& ug = response.ugData; + response.bHasUGData = true; + ug.userModelID = ugId; + ug.userModelName = name; + ug.userModelDesc = description; + ug.userModelBehaviors = BehaviorIds(behaviors); + + // A premade model (no blueprint) has no owner or blueprint data, as live sent it + if (blueprintId == LWOOBJID_EMPTY) return; + ug.blueprintID = blueprintId; + const auto owner = Database::Get()->GetUgcProcessInfo(blueprintId); + if (owner) { + ug.owningPlayerID = owner->characterId; + ug.owningPlayerName = GeneralUtils::UTF8ToUTF16(owner->characterName); + const auto character = Database::Get()->GetCharacterInfo(owner->characterId); + if (character) ug.accountID = character->accountId; + } + response.bHasBPData = FillBlueprintMetadata(blueprintId, response.bpData); +} diff --git a/dGame/dUtilities/BrickByBrick.h b/dGame/dUtilities/BrickByBrick.h index 56d4d03b8..5a2ae7beb 100644 --- a/dGame/dUtilities/BrickByBrick.h +++ b/dGame/dUtilities/BrickByBrick.h @@ -18,6 +18,7 @@ class InventoryComponent; namespace GameMessages { struct MoveInventoryBatch; + struct FetchModelMetadataResponse; } // Brick by brick building and the model items it works on, as the 1.10.64 client expects them (docs/BuildWorkflow.md). @@ -70,8 +71,19 @@ namespace BrickByBrick { // An object id kept in a config (blueprintid, userModelID), or LWOOBJID_EMPTY LWOOBJID ConfigObjectId(const LwoNameValue& config, const std::u16string& key); + // A model's behavior ids from its userModelBehaviors config ("id,id,id,id,id"): always 5, missing ones 0 (live + // always sent 5 in FetchModelMetadataResponse) + std::vector BehaviorIds(std::string_view behaviors); + + // BlueprintMetadata's brick list: every brick's LOT followed by ':' ("66:" for one 1x4 plate), as live sent it + std::u16string BrickList(const std::vector& brickLots); + // ---- Handlers ---- + // FetchModelMetadataRequest: fills in the UG data (and, for a brick built model, the blueprint data) of the model + // ugId: one of the player's model items (its subkey), else a model placed on any property. Neither: both left out. + void FillModelMetadata(Entity& player, LWOOBJID ugId, GameMessages::FetchModelMetadataResponse& response); + // BBBLoadItemRequest: moves the model item from MODELS to MODELS_IN_BBB keeping its object id (as live did). // Returns the id in MODELS_IN_BBB, or LWOOBJID_EMPTY when the player has no such model. LWOOBJID LoadModel(Entity& player, LWOOBJID itemId); diff --git a/docs/BuildWorkflow.md b/docs/BuildWorkflow.md index 1f343d49a..4e42a1125 100644 --- a/docs/BuildWorkflow.md +++ b/docs/BuildWorkflow.md @@ -39,6 +39,35 @@ A brick built model item's config (as live sent it): `blueprintid`, `userModelID `userModelHasBhvr`, `userModelBehaviors` ("id,id,id,id,id"), `userModelBehaviorSourceIDs`, `userModelOpt`, `userModelMod`, `userModelPhysicsType` (`BrickByBrick::ModelItemConfig`). +### Brick built models in the inventory + +How the client shows a brick built model item (LOT 6662) in the backpack, and what it asks the server: + +- **Icon**: `LWOInventoryComponent_Client::LoadBlueprintIcon` (`0x00c64980`) loads the item's `blueprintid` config + (else its subkey) as a 3D services resource (`ResourceSpecifier(blueprint id, Dds)`: the UGC server's + `IMAGE128DDS/` file, docs/UgcServer.md) and shows it with `InventoryLoadCustomIcon`; until then the item shows + `RenderComponent.icon_asset`. Nothing is sent to the world server for the icon. +- **Tooltip** (`LWOInventoryComponent_Client::FillinDetailsBlueprint`, `0x00cddb40`): for LOT 6662 with a persistent + subkey (the UGID) it reads the model's metadata from the client's cache (`UGModelMetadataCache::GetModelMetadata`, + `0x00b5e680`). Not cached: `C->S FetchModelMetadataRequest(context, objectID, requestorID, ugID)` and the name shows + `BBB_LOADING_BLUEPRINT` until the answer. With no name the tooltip says whose model it is (`UGG_MODEL_owner_model` + with `owningPlayerName`). Exhibits need both parts too: they compare the model's box to the exhibit's size. +- **Answer**: `S->C FetchModelMetadataResponse(ugID, objectID, requestorID, context, bHasUGData, bHasBPData, UG data, + blueprint data)` (`0x00e3ccb0`, `0x00f5f590`, `0x00f5f760`). The client caches it and writes `blueprintid`, + `userModelMod`, `userModelOpt`, `userModelID`, `userModelName`, `userModelDesc`, `userModelHasBhvr` and + `userModelBehaviors` into the item's config (`msgFetchModelMetadataResponse`, `0x00c64300`), then refreshes the item. + +What live sent (2014 captures, one request per model the client shows, the item's own ids 0): UG data with the UGID, +blueprint id, name and description (mostly empty), owner character, account and name, and always 5 behavior ids; for +a brick built model also the blueprint data: blueprint id, creation time, `userModelMod` 1, the model's box relative +to its origin, `userModelOpt` 1, `ugcIconReady` 1, the brick list (every brick's LOT followed by `:`), 1, and the +number of bricks. Premade models got UG data only (behaviors, no owner); an unknown UGID got neither. + +DLU (`BrickByBrick::FillModelMetadata`) finds the UGID among the player's items (their subkey), else among the placed +models (`properties_contents`), and fills the rest from the `ugc` row (owner) and its LXFML (bricks and box). Two +differences: there is no creation time stored (0 is sent), and the box holds the bricks' origins (brick shapes are +not known to the world server), so it is smaller than the model. + ## 2. Entering and leaving property editing 1. `C->S StartBuildingWithItem` (subject: property plaque/build area; source = the thinking hat, `sourceType` 1). @@ -213,6 +242,12 @@ equipped), `S->C FinishArrangingWithItem` / `ModularBuildEnd`, `C->S DoneArrangi | `0x00b73c70` | `BBBManager::RegisterUIListeners` | | `0x00cfabd0` | `LWOBBBComponent_Client::msgRebuildBBBAutosaveMsg` | | `0x00d8ecb0` | `GameMessage::ActivateBrickMode::Deserialize` | +| `0x00b5e680` | `UGModelMetadataCache::GetModelMetadata` (sends `FetchModelMetadataRequest`) | +| `0x00c64300` | `LWOInventoryComponent_Client::msgFetchModelMetadataResponse` | +| `0x00e3ccb0` | `GameMessage::FetchModelMetadataResponse::Serialize` | +| `0x00f5f590` / `0x00f5f760` | `UGObjectMetadata::Serialize` / `BlueprintMetadata::Serialize` | +| `0x00cddb40` | `LWOInventoryComponent_Client::FillinDetailsBlueprint` (model item tooltip) | +| `0x00c64980` | `LWOInventoryComponent_Client::LoadBlueprintIcon` | | `0x00f2af60` | `GameMessage::SetBBBAutosave::Deserialize` | | `0x00dc0170` | `GameMessage::PlaceModelResponse::Deserialize` | | `0x00ce1310` | `LWOInventoryComponent_Common::msgMoveInventoryBatch` | diff --git a/tests/dCommonTests/LxfmlTests.cpp b/tests/dCommonTests/LxfmlTests.cpp index 67db11498..0fefaebbf 100644 --- a/tests/dCommonTests/LxfmlTests.cpp +++ b/tests/dCommonTests/LxfmlTests.cpp @@ -411,3 +411,22 @@ TEST(LxfmlTests, DeepCloneDepthProtection) { } } } + +TEST(LxfmlTests, ReadContentsListsBricksAndTheirBox) { + const std::string lxfml = R"( + + + + + +)"; + const auto contents = Lxfml::ReadContents(lxfml); + EXPECT_EQ(contents.designIds, (std::vector{ 3710, 3001 })); + EXPECT_EQ(contents.boxMin, NiPoint3(-1.2f, 0.0f, -0.4f)); + EXPECT_EQ(contents.boxMax, NiPoint3(0.8f, 0.96f, 0.4f)); + + const auto nothing = Lxfml::ReadContents(""); + EXPECT_TRUE(nothing.designIds.empty()); + EXPECT_EQ(nothing.boxMin, NiPoint3Constant::ZERO); + EXPECT_TRUE(Lxfml::ReadContents("").designIds.empty()); +} diff --git a/tests/dGameTests/BrickByBrickTests.cpp b/tests/dGameTests/BrickByBrickTests.cpp index ce5f7e078..829d170c9 100644 --- a/tests/dGameTests/BrickByBrickTests.cpp +++ b/tests/dGameTests/BrickByBrickTests.cpp @@ -119,3 +119,14 @@ TEST_F(BrickByBrickTests, AReturnedModelIsNeverUsedUpByTheAutosave) { ASSERT_TRUE(saved.has_value()); EXPECT_EQ(saved->sourceItems, (std::vector{ 8 })); } + +TEST_F(BrickByBrickTests, ModelMetadataHasFiveBehaviorsAndAColonBrickList) { + EXPECT_EQ(BrickByBrick::BehaviorIds(""), std::vector(5, 0)); + EXPECT_EQ(BrickByBrick::BehaviorIds("0,0,0,0,0"), std::vector(5, 0)); + EXPECT_EQ(BrickByBrick::BehaviorIds("10449,7"), (std::vector{ 10449, 7, 0, 0, 0 })); + EXPECT_EQ(BrickByBrick::BehaviorIds("1,2,3,4,5,6"), (std::vector{ 1, 2, 3, 4, 5 })); + + EXPECT_EQ(BrickByBrick::BrickList({}), u""); + EXPECT_EQ(BrickByBrick::BrickList({ 66 }), u"66:"); + EXPECT_EQ(BrickByBrick::BrickList({ 529, 529, 41 }), u"529:529:41:"); +} diff --git a/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp index d9f249b8e..bf32b4f85 100644 --- a/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/BuildingMessagesTests.cpp @@ -435,3 +435,58 @@ TEST_F(BuildingMessagesTests, BrickModeMessagesRoundTrip) { EXPECT_EQ(RoundTrip(rebuilt).count, 2); ExpectTruncatedFails(rebuilt); } + +// FetchModelMetadataRequest / Response as the client writes and reads them (GameMessage::FetchModelMetadataResponse:: +// Serialize, 0x00e3ccb0; UGObjectMetadata::Serialize, 0x00f5f590; BlueprintMetadata::Serialize, 0x00f5f760) +TEST_F(BuildingMessagesTests, FetchModelMetadataWire) { + GameMessages::FetchModelMetadataRequest request; + request.context = 1; + request.objectID = 2; + request.requestorID = 3; + request.ugID = 4; + EXPECT_PACKET_EQ(FromHex("01 00 00 00 02 00 00 00 00 00 00 00 03 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00"), Payload(request)); + const auto requestCopy = RoundTrip(request); + EXPECT_EQ(requestCopy.context, 1); + EXPECT_EQ(requestCopy.objectID, 2); + EXPECT_EQ(requestCopy.requestorID, 3); + EXPECT_EQ(requestCopy.ugID, 4); + ExpectTruncatedFails(request); + + // Nothing known: ugID, objectID, requestorID, context, then the two flags + GameMessages::FetchModelMetadataResponse none; + none.ugID = 4; + EXPECT_EQ(Bits(none), 8 * 28 + 2); + + GameMessages::FetchModelMetadataResponse full; + full.ugID = 4; + full.bHasUGData = true; + full.bHasBPData = true; + full.ugData.userModelID = 4; + full.ugData.blueprintID = 5; + full.ugData.userModelName = u"Car"; + full.ugData.owningPlayerID = 6; + full.ugData.accountID = 7; + full.ugData.owningPlayerName = u"Builder"; + full.ugData.userModelBehaviors = { 8, 0, 0, 0, 0 }; + full.bpData.blueprintID = 5; + full.bpData.modelBoxMins = NiPoint3(-1.6f, 0.0f, -0.8f); + full.bpData.modelBoxMaxs = NiPoint3(1.6f, 0.5f, 0.8f); + full.bpData.brickListColonDelim = u"66:"; + full.bpData.numberOfBricks = 1; + // UG: 2 ids, name (4 + 6), desc (4), 2 ids, owner name (4 + 14), u8 count, 5 ids + const uint32_t ugBits = 8 * (16 + 10 + 4 + 16 + 18 + 1 + 40); + // BP: id, timestamp, mod, 2 points, 2 flags, bricks (4 + 6), flag, count + const uint32_t bpBits = 8 * (8 + 8 + 4 + 24 + 10 + 4) + 3; + EXPECT_EQ(Bits(full), 8 * 28 + 2 + ugBits + bpBits); + const auto copy = RoundTrip(full); + EXPECT_EQ(copy.ugData.userModelName, u"Car"); + EXPECT_EQ(copy.ugData.owningPlayerName, u"Builder"); + EXPECT_EQ(copy.ugData.userModelBehaviors, full.ugData.userModelBehaviors); + EXPECT_EQ(copy.bpData.brickListColonDelim, u"66:"); + EXPECT_EQ(copy.bpData.modelBoxMaxs, full.bpData.modelBoxMaxs); + EXPECT_EQ(copy.bpData.numberOfBricks, 1); + EXPECT_TRUE(copy.bpData.userModelOpt); + EXPECT_TRUE(copy.bpData.ugcIconReady); + EXPECT_TRUE(copy.bpData.neverFalseIfPresentInCaps); + ExpectTruncatedFails(full); +}