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 962797d6a2
commit 8ab7e496b0
11 changed files with 479 additions and 0 deletions

View File

@@ -4,6 +4,7 @@
#include "StringifiedEnum.h"
#include "TinyXmlUtils.h"
#include <algorithm>
#include <ranges>
#include <unordered_map>
#include <unordered_set>
@@ -30,6 +31,48 @@ namespace {
</LXFML>)";
}
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<uint32_t>(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<float>(split[9]);
const auto y = GeneralUtils::TryParse<float>(split[10]);
const auto z = GeneralUtils::TryParse<float>(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;

View File

@@ -21,6 +21,16 @@ namespace Lxfml {
[[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO);
[[nodiscard]] std::vector<Result> 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<uint32_t> 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);

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

View File

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

View File

@@ -411,3 +411,22 @@ TEST(LxfmlTests, DeepCloneDepthProtection) {
}
}
}
TEST(LxfmlTests, ReadContentsListsBricksAndTheirBox) {
const std::string lxfml = R"(<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<LXFML versionMajor="5" versionMinor="0">
<Bricks>
<Brick refID="0" designID="3710"><Part refID="0" designID="3710" materials="21"><Bone refID="0" transformation="1,0,0,0,1,0,0,0,1,-1.2,0,0.4"/></Part></Brick>
<Brick refID="1" designID="3001;A"><Part refID="1" designID="3001" materials="1"><Bone refID="1" transformation="1,0,0,0,1,0,0,0,1,0.8,0.96,-0.4"/></Part></Brick>
</Bricks>
</LXFML>)";
const auto contents = Lxfml::ReadContents(lxfml);
EXPECT_EQ(contents.designIds, (std::vector<uint32_t>{ 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("<LXFML><Bricks/></LXFML>").designIds.empty());
}

View File

@@ -119,3 +119,14 @@ TEST_F(BrickByBrickTests, AReturnedModelIsNeverUsedUpByTheAutosave) {
ASSERT_TRUE(saved.has_value());
EXPECT_EQ(saved->sourceItems, (std::vector<LWOOBJID>{ 8 }));
}
TEST_F(BrickByBrickTests, ModelMetadataHasFiveBehaviorsAndAColonBrickList) {
EXPECT_EQ(BrickByBrick::BehaviorIds(""), std::vector<LWOOBJID>(5, 0));
EXPECT_EQ(BrickByBrick::BehaviorIds("0,0,0,0,0"), std::vector<LWOOBJID>(5, 0));
EXPECT_EQ(BrickByBrick::BehaviorIds("10449,7"), (std::vector<LWOOBJID>{ 10449, 7, 0, 0, 0 }));
EXPECT_EQ(BrickByBrick::BehaviorIds("1,2,3,4,5,6"), (std::vector<LWOOBJID>{ 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:");
}

View File

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