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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 14:47:29 -05:00
parent 0b89aa6669
commit abfcea36d7
11 changed files with 479 additions and 0 deletions

View File

@@ -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<uint8_t>(std::min<size_t>(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);

View File

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

View File

@@ -141,6 +141,7 @@ namespace {
{ SET_BUILD_MODE, []() { return std::make_unique<SetBuildMode>(); } },
{ BUILD_MODE_SET, []() { return std::make_unique<BuildModeSet>(); } },
{ UN_USE_BBB_MODEL, []() { return std::make_unique<UnUseBBBModel>(); } },
{ FETCH_MODEL_METADATA_REQUEST, []() { return std::make_unique<FetchModelMetadataRequest>(); } },
{ BBB_LOAD_ITEM_REQUEST, []() { return std::make_unique<BBBLoadItemRequest>(); } },
{ BBB_SAVE_REQUEST, []() { return std::make_unique<BBBSaveRequest>(); } },
{ SET_BBB_AUTOSAVE, []() { return std::make_unique<SetBBBAutosave>(); } },

View File

@@ -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 <fstream>
#include <ranges>
#include <sstream>
#include <unordered_map>
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<uint32_t, LOT> lots;
if (lots.empty()) {
for (const auto& entry : CDClientManager::GetTable<CDBrickIDTableTable>()->Query([](const CDBrickIDTable&) { return true; })) {
lots.try_emplace(entry.LEGOBrickID, static_cast<LOT>(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<LOT> 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<int32_t>(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<LWOOBJID> BrickByBrick::BehaviorIds(const std::string_view behaviors) {
std::vector<LWOOBJID> 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<LWOOBJID>(part).value_or(LWOOBJID_EMPTY);
}
return ids;
}
std::u16string BrickByBrick::BrickList(const std::vector<LOT>& 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<InventoryComponent>();
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);
}

View File

@@ -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<LWOOBJID> 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<LOT>& 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);