fix: brick by brick and model placement work the way the client expects

Mapped from the 1.10.64 client and live captures (docs/BuildWorkflow.md).

Model placement (PropertyManagementComponent):
- A brick built model placed from the inventory spawned at the world origin
  with no rotation and without PlaceModelResponse/PreCreate, and was saved
  to properties_contents with ugc_id 0; it is now placed where the client
  put it, keeps its UGID and blueprint and is saved with them.
- Picking up, putting away and taking apart a brick built model gave it to
  MODELS_IN_BBB and then deleted it; every way off the property now puts it
  in MODELS (carried when picked up), as live, with its blueprint config.
  Taking a premade model apart no longer deletes it either.
- Placing and removing a model saves the property at once, so a crash or a
  disconnect before PropertyEditorEnd does not lose it.
- DoneArrangingWithItem only answers when something new is picked (not when
  leaving), with the subject as build area; SetBuildModeConfirmed's
  warnVisitors matches live.

Brick by brick (BrickByBrick):
- BBBLoadItemRequest moves the model to MODELS_IN_BBB keeping its id and
  fails cleanly when the player has no such model.
- MoveInventoryBatch moves bricks between BRICKS and BRICKS_IN_BBB (it was
  not handled, so the client and server disagreed until a relog).
- BBBSaveRequest uses up the opened models, places the new ones through the
  property, returns the bricks (or uses them with bbb_consume_bricks=1),
  clears the autosave and sends RequeryPropertyModels. Every save makes new
  ugc rows (is_optimized 0, so the UGC server processes them).
- Quick save: SetBBBAutosave is stored per character (bbb_autosave).
- UnUseBBBModel puts a model back on the property where it was when it came
  from there, otherwise back in MODELS.
- Leaving brick mode without a save, a disconnect or a crash: the autosave
  is rebuilt into models (RebuildBBBAutosaveMsg) or the opened models go
  back to MODELS. MODELS_IN_BBB is saved with the character now and loads
  into MODELS, BRICKS_IN_BBB into BRICKS.

Fixes #1632
Fixes #159
Fixes #1565

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 04:21:48 -05:00
parent e25a6df45b
commit 2ea84c8c99
19 changed files with 976 additions and 451 deletions

View File

@@ -0,0 +1,121 @@
#include "BrickByBrick.h"
#include "BbbAutosaveItems.h"
#include "Database.h"
#include "Entity.h"
#include "GameDependencies.h"
#include "eInventoryType.h"
#include <gtest/gtest.h>
// The brick by brick workflow's rules (docs/BuildWorkflow.md): what happens to models and bricks at each step.
class BrickByBrickTests : public GameDependenciesTest {
protected:
void SetUp() override { SetUpDependencies(); }
void TearDown() override { TearDownDependencies(); }
};
namespace {
const std::string HEADER_ONLY("sd0\x01\xff", 5);
const std::string ONE_CHUNK("sd0\x01\xff\x02\x00\x00\x00\x78\x9c", 11);
}
TEST_F(BrickByBrickTests, EmptyModelIsNothingOrTheBareHeader) {
EXPECT_TRUE(BrickByBrick::IsEmptyModel(""));
EXPECT_TRUE(BrickByBrick::IsEmptyModel(HEADER_ONLY));
EXPECT_FALSE(BrickByBrick::IsEmptyModel(ONE_CHUNK));
}
TEST_F(BrickByBrickTests, TakingAModelOffThePropertyPutsItInModels) {
using enum BrickByBrick::eDeleteReason;
const auto pickUp = BrickByBrick::PlanModelRemoval(static_cast<int32_t>(PICKING_MODEL_UP));
EXPECT_TRUE(pickUp.equip);
EXPECT_TRUE(pickUp.notifyPostDelete);
const auto putAway = BrickByBrick::PlanModelRemoval(static_cast<int32_t>(RETURNING_MODEL_TO_INVENTORY));
EXPECT_FALSE(putAway.equip);
EXPECT_FALSE(putAway.notifyPostDelete);
// Taking a model apart: into MODELS, not carried; the client then opens it with BBBLoadItemRequest
const auto apart = BrickByBrick::PlanModelRemoval(static_cast<int32_t>(BREAKING_MODEL_APART));
EXPECT_FALSE(apart.equip);
EXPECT_TRUE(apart.notifyPostDelete);
const auto unknown = BrickByBrick::PlanModelRemoval(7);
EXPECT_FALSE(unknown.equip);
}
TEST_F(BrickByBrickTests, LeavingWithoutAnAutosaveGivesEveryModelBack) {
const auto recovery = BrickByBrick::PlanRecovery({ 1, 2 }, std::nullopt);
EXPECT_FALSE(recovery.rebuild);
EXPECT_TRUE(recovery.consume.empty());
EXPECT_EQ(recovery.giveBack, (std::vector<LWOOBJID>{ 1, 2 }));
}
TEST_F(BrickByBrickTests, AnAutosaveReplacesTheModelsItWasMadeFrom) {
IBbbAutosave::Info autosave{ ONE_CHUNK, { 2, 3 }, 0 };
const auto recovery = BrickByBrick::PlanRecovery({ 1, 2 }, autosave);
EXPECT_TRUE(recovery.rebuild);
// 3 is no longer in the BBB inventory (e.g. back in MODELS after a reload) but still made the autosave
EXPECT_EQ(recovery.consume, (std::vector<LWOOBJID>{ 2, 3 }));
// 1 was opened after the autosave: it goes back
EXPECT_EQ(recovery.giveBack, (std::vector<LWOOBJID>{ 1 }));
}
TEST_F(BrickByBrickTests, AnEmptyAutosaveRebuildsNothing) {
IBbbAutosave::Info autosave{ HEADER_ONLY, { 1 }, 0 };
const auto recovery = BrickByBrick::PlanRecovery({ 1 }, autosave);
EXPECT_FALSE(recovery.rebuild);
EXPECT_TRUE(recovery.consume.empty());
EXPECT_EQ(recovery.giveBack, (std::vector<LWOOBJID>{ 1 }));
}
TEST_F(BrickByBrickTests, BuildInventoriesLoadIntoTheNormalOnes) {
EXPECT_EQ(BrickByBrick::InventoryToLoadInto(eInventoryType::MODELS_IN_BBB), eInventoryType::MODELS);
EXPECT_EQ(BrickByBrick::InventoryToLoadInto(eInventoryType::BRICKS_IN_BBB), eInventoryType::BRICKS);
for (const auto type : { eInventoryType::ITEMS, eInventoryType::BRICKS, eInventoryType::MODELS, eInventoryType::VAULT_MODELS, eInventoryType::TEMP_MODELS }) {
EXPECT_EQ(BrickByBrick::InventoryToLoadInto(type), type);
}
}
TEST_F(BrickByBrickTests, ModelItemConfigKeepsTheModelsIds) {
const auto config = BrickByBrick::ModelItemConfig(1152921508340227662, 1152921510759098851, "10447,0,0,0,0");
EXPECT_EQ(BrickByBrick::ConfigObjectId(config, u"blueprintid"), 1152921508340227662);
EXPECT_EQ(BrickByBrick::ConfigObjectId(config, u"userModelID"), 1152921510759098851);
EXPECT_EQ(config.find(u"userModelBehaviors")->second->GetValueAsString(), "10447,0,0,0,0");
EXPECT_EQ(config.find(u"userModelHasBhvr")->second->GetValueAsString(), "1");
EXPECT_EQ(config.find(u"userModelPhysicsType")->second->GetValueAsString(), "2");
const auto plain = BrickByBrick::ModelItemConfig(5, 6);
EXPECT_EQ(plain.find(u"userModelHasBhvr")->second->GetValueAsString(), "0");
EXPECT_EQ(plain.find(u"userModelBehaviors")->second->GetValueAsString(), "0,0,0,0,0");
EXPECT_EQ(BrickByBrick::ConfigObjectId(plain, u"missing"), LWOOBJID_EMPTY);
}
TEST_F(BrickByBrickTests, AutosaveItemsListRoundTrips) {
const std::vector<LWOOBJID> items = { 1152921510759098815, 2, 3 };
EXPECT_EQ(BbbAutosaveItems::Join(items), "1152921510759098815,2,3");
EXPECT_EQ(BbbAutosaveItems::Parse(BbbAutosaveItems::Join(items)), items);
EXPECT_TRUE(BbbAutosaveItems::Parse("").empty());
EXPECT_EQ(BbbAutosaveItems::Parse("4,,x,5"), (std::vector<LWOOBJID>{ 4, 5 }));
}
TEST_F(BrickByBrickTests, QuickSaveIsKeptUntilClearedWithTheBareHeader) {
Entity player(1152921510000000001, info);
BrickByBrick::Autosave(player, ONE_CHUNK);
const auto saved = Database::Get()->GetBbbAutosave(player.GetObjectID());
ASSERT_TRUE(saved.has_value());
EXPECT_EQ(saved->lxfml, ONE_CHUNK);
EXPECT_GT(saved->updatedAt, 0);
BrickByBrick::Autosave(player, HEADER_ONLY);
EXPECT_FALSE(Database::Get()->GetBbbAutosave(player.GetObjectID()).has_value());
}
TEST_F(BrickByBrickTests, AReturnedModelIsNeverUsedUpByTheAutosave) {
Entity player(1152921510000000002, info);
Database::Get()->SetBbbAutosave(player.GetObjectID(), { ONE_CHUNK, { 7, 8 }, 1 });
BrickByBrick::ReturnModel(player, 7, false, NiPoint3Constant::ZERO, QuatUtils::IDENTITY);
const auto saved = Database::Get()->GetBbbAutosave(player.GetObjectID());
ASSERT_TRUE(saved.has_value());
EXPECT_EQ(saved->sourceItems, (std::vector<LWOOBJID>{ 8 }));
}

View File

@@ -11,6 +11,7 @@ set(DGAMETEST_SOURCES
"CollisionFilterTests.cpp"
"ItemSetPassiveAbilityTests.cpp"
"PowerUpStatisticTests.cpp"
"BrickByBrickTests.cpp"
)
add_subdirectory(dComponentsTests)

View File

@@ -386,3 +386,52 @@ TEST_F(BuildingMessagesTests, GoldenBytes) {
load.WritePacket(bitStream);
EXPECT_PACKET_EQ(FromHex("53 05 00 17 00 00 00 00 01 03 02 00 00 00 00 00 00 de 03 00 00 00 00 00 00"), FromBitStream(bitStream));
}
// Messages added for the brick by brick workflow (docs/BuildWorkflow.md), laid out as the 1.10.64 client reads them.
TEST_F(BuildingMessagesTests, BrickModeMessagesRoundTrip) {
GameMessages::ActivateBrickMode enter;
enter.buildObjectID = 0x1122334455667788;
enter.enterBuildFromWorld = false;
enter.enterFlag = true;
// flag 1 + the build area, flag 0 (build type 2, on a property), 0, 1 (0x00d8ecb0)
EXPECT_PACKET_EQ(FromHex("c4 3b b3 2a a2 19 91 08 90", 68), Payload(enter));
const auto enterCopy = RoundTrip(enter);
EXPECT_EQ(enterCopy.buildObjectID, enter.buildObjectID);
EXPECT_EQ(enterCopy.buildType, 2);
EXPECT_FALSE(enterCopy.enterBuildFromWorld);
EXPECT_TRUE(enterCopy.enterFlag);
ExpectTruncatedFails(enter);
GameMessages::ActivateBrickMode leave;
leave.buildType = 1;
leave.enterBuildFromWorld = true;
leave.enterFlag = false;
const auto leaveCopy = RoundTrip(leave);
EXPECT_EQ(leaveCopy.buildObjectID, LWOOBJID_EMPTY);
EXPECT_EQ(leaveCopy.buildType, 1);
EXPECT_FALSE(leaveCopy.enterFlag);
// The client clears its autosave with the bare sd0 header: u32 size, then the bytes (0x00f2af60)
GameMessages::SetBBBAutosave clear;
clear.lxfmlDataCompressed = std::string("sd0\x01\xff", 5);
EXPECT_PACKET_EQ(FromHex("05 00 00 00 73 64 30 01 ff"), Payload(clear));
EXPECT_EQ(RoundTrip(clear).lxfmlDataCompressed, clear.lxfmlDataCompressed);
ExpectTruncatedFails(clear);
GameMessages::SetBBBAutosave empty;
EXPECT_PACKET_EQ(FromHex("00 00 00 00"), Payload(empty));
EXPECT_TRUE(RoundTrip(empty).lxfmlDataCompressed.empty());
// A size larger than what is left is dropped rather than read past the end
RakNet::BitStream tooLong;
tooLong.Write<uint32_t>(100);
tooLong.Write<uint8_t>(1);
GameMessages::SetBBBAutosave read;
EXPECT_FALSE(read.Deserialize(tooLong));
GameMessages::RebuildBBBAutosaveMsg rebuilt;
rebuilt.count = 2;
EXPECT_PACKET_EQ(FromHex("02 00 00 00"), Payload(rebuilt));
EXPECT_EQ(RoundTrip(rebuilt).count, 2);
ExpectTruncatedFails(rebuilt);
}

View File

@@ -619,3 +619,9 @@ TEST_F(PropertyMessagesTests, GoldenBytes) {
enter.index = -1;
EXPECT_PACKET_EQ(FromHex("ff ff ff ff 80", 33), Payload(enter));
}
// A live server's RequeryPropertyModels (after a brick by brick save) had no payload
TEST_F(PropertyMessagesTests, RequeryPropertyModelsHasNoPayload) {
GameMessages::RequeryPropertyModels requery;
EXPECT_EQ(Payload(requery).bits, 0u);
}