From ce28834dce34ad9e692780de9a93cacadc212f2c Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 10 Oct 2025 23:07:16 -0500 Subject: [PATCH] feat: lxfml splitting for bbb (#1877) * LXFML SPLITTING Included test file * move base to global namespace * wip need to test * update last fixes * update world sending bbb to be more efficient * Address feedback form Emo in doscord * Make LXFML class for robust and add more tests to edge cases and malformed data * get rid of the string copy and make the deep clone have a recursive limit * cleanup tests * fix test file locations * fix file path * KISS * add cmakelists * fix typos * NL @ EOF * tabs and split out to func * naming standard --- dCommon/Lxfml.cpp | 354 +++++++++++++++++- dCommon/Lxfml.h | 2 + dGame/dGameMessages/GameMessages.cpp | 179 ++++----- dWorldServer/WorldServer.cpp | 34 +- tests/dCommonTests/CMakeLists.txt | 3 + .../LxfmlTestFiles/CMakeLists.txt | 18 + .../LxfmlTestFiles/deeply_nested.lxfml | 50 +++ .../LxfmlTestFiles/empty_transform.lxfml | 11 + .../LxfmlTestFiles/invalid_transform.lxfml | 20 + .../mixed_invalid_transform.lxfml | 11 + .../LxfmlTestFiles/mixed_valid_invalid.lxfml | 44 +++ .../LxfmlTestFiles/no_bricks.lxfml | 4 + .../non_numeric_transform.lxfml | 11 + tests/dCommonTests/LxfmlTestFiles/test.lxfml | 336 +++++++++++++++++ .../LxfmlTestFiles/too_few_values.lxfml | 11 + tests/dCommonTests/LxfmlTests.cpp | 297 +++++++++++++++ 16 files changed, 1267 insertions(+), 118 deletions(-) create mode 100644 tests/dCommonTests/LxfmlTestFiles/CMakeLists.txt create mode 100644 tests/dCommonTests/LxfmlTestFiles/deeply_nested.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/empty_transform.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/invalid_transform.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/mixed_invalid_transform.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/mixed_valid_invalid.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/no_bricks.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/non_numeric_transform.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/test.lxfml create mode 100644 tests/dCommonTests/LxfmlTestFiles/too_few_values.lxfml create mode 100644 tests/dCommonTests/LxfmlTests.cpp diff --git a/dCommon/Lxfml.cpp b/dCommon/Lxfml.cpp index e71b2d8e..fd71be4c 100644 --- a/dCommon/Lxfml.cpp +++ b/dCommon/Lxfml.cpp @@ -5,13 +5,43 @@ #include "TinyXmlUtils.h" #include +#include +#include +#include +#include + +namespace { + // The base LXFML xml file to use when creating new models. + std::string g_base = R"( + + + + + + + + + + + + + + +)"; +} Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) { Result toReturn; + + // Handle empty or invalid input + if (data.empty()) { + return toReturn; + } + tinyxml2::XMLDocument doc; - const auto err = doc.Parse(data.data()); + // Use length-based parsing to avoid expensive string copy + const auto err = doc.Parse(data.data(), data.size()); if (err != tinyxml2::XML_SUCCESS) { - LOG("Failed to parse xml %s.", StringifiedEnum::ToString(err).data()); return toReturn; } @@ -20,7 +50,6 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin auto lxfml = reader["LXFML"]; if (!lxfml) { - LOG("Failed to find LXFML element."); return toReturn; } @@ -49,16 +78,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin // Calculate the lowest and highest points on the entire model for (const auto& transformation : transformations | std::views::values) { auto split = GeneralUtils::SplitString(transformation, ','); - if (split.size() < 12) { - LOG("Not enough in the split?"); - continue; - } - - auto x = GeneralUtils::TryParse(split[9]).value(); - auto y = GeneralUtils::TryParse(split[10]).value(); - auto z = GeneralUtils::TryParse(split[11]).value(); - if (x < lowest.x) lowest.x = x; - if (y < lowest.y) lowest.y = y; + if (split.size() < 12) continue; + + auto xOpt = GeneralUtils::TryParse(split[9]); + auto yOpt = GeneralUtils::TryParse(split[10]); + auto zOpt = GeneralUtils::TryParse(split[11]); + + if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) continue; + + auto x = xOpt.value(); + auto y = yOpt.value(); + auto z = zOpt.value(); + if (x < lowest.x) lowest.x = x; + if (y < lowest.y) lowest.y = y; if (z < lowest.z) lowest.z = z; if (highest.x < x) highest.x = x; @@ -87,13 +119,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin for (auto& transformation : transformations | std::views::values) { auto split = GeneralUtils::SplitString(transformation, ','); if (split.size() < 12) { - LOG("Not enough in the split?"); continue; } - auto x = GeneralUtils::TryParse(split[9]).value() - newRootPos.x + curPosition.x; - auto y = GeneralUtils::TryParse(split[10]).value() - newRootPos.y + curPosition.y; - auto z = GeneralUtils::TryParse(split[11]).value() - newRootPos.z + curPosition.z; + auto xOpt = GeneralUtils::TryParse(split[9]); + auto yOpt = GeneralUtils::TryParse(split[10]); + auto zOpt = GeneralUtils::TryParse(split[11]); + + if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) { + continue; + } + auto x = xOpt.value() - newRootPos.x + curPosition.x; + auto y = yOpt.value() - newRootPos.y + curPosition.y; + auto z = zOpt.value() - newRootPos.z + curPosition.z; std::stringstream stream; for (int i = 0; i < 9; i++) { stream << split[i]; @@ -128,3 +166,285 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin toReturn.center = newRootPos; return toReturn; } + +// Deep-clone an XMLElement (attributes, text, and child elements) into a target document +// with maximum depth protection to prevent infinite loops +static tinyxml2::XMLElement* CloneElementDeep(const tinyxml2::XMLElement* src, tinyxml2::XMLDocument& dstDoc, int maxDepth = 100) { + if (!src || maxDepth <= 0) return nullptr; + auto* dst = dstDoc.NewElement(src->Name()); + + // copy attributes + for (const tinyxml2::XMLAttribute* attr = src->FirstAttribute(); attr; attr = attr->Next()) { + dst->SetAttribute(attr->Name(), attr->Value()); + } + + // copy children (elements and text) + for (const tinyxml2::XMLNode* child = src->FirstChild(); child; child = child->NextSibling()) { + if (const tinyxml2::XMLElement* childElem = child->ToElement()) { + // Recursively clone child elements with decremented depth + auto* clonedChild = CloneElementDeep(childElem, dstDoc, maxDepth - 1); + if (clonedChild) dst->InsertEndChild(clonedChild); + } else if (const tinyxml2::XMLText* txt = child->ToText()) { + auto* n = dstDoc.NewText(txt->Value()); + dst->InsertEndChild(n); + } else if (const tinyxml2::XMLComment* c = child->ToComment()) { + auto* n = dstDoc.NewComment(c->Value()); + dst->InsertEndChild(n); + } + } + + return dst; +} + +std::vector Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) { + std::vector results; + + // Handle empty or invalid input + if (data.empty()) { + return results; + } + + // Prevent processing extremely large inputs that could cause hangs + if (data.size() > 10000000) { // 10MB limit + return results; + } + + tinyxml2::XMLDocument doc; + // Use length-based parsing to avoid expensive string copy + const auto err = doc.Parse(data.data(), data.size()); + if (err != tinyxml2::XML_SUCCESS) { + return results; + } + + auto* lxfml = doc.FirstChildElement("LXFML"); + if (!lxfml) { + return results; + } + + // Build maps: partRef -> Part element, partRef -> Brick element, boneRef -> partRef, brickRef -> Brick element + std::unordered_map partRefToPart; + std::unordered_map partRefToBrick; + std::unordered_map boneRefToPartRef; + std::unordered_map brickByRef; + + auto* bricksParent = lxfml->FirstChildElement("Bricks"); + if (bricksParent) { + for (auto* brick = bricksParent->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) { + const char* brickRef = brick->Attribute("refID"); + if (brickRef) brickByRef.emplace(std::string(brickRef), brick); + for (auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) { + const char* partRef = part->Attribute("refID"); + if (partRef) { + partRefToPart.emplace(std::string(partRef), part); + partRefToBrick.emplace(std::string(partRef), brick); + } + auto* bone = part->FirstChildElement("Bone"); + if (bone) { + const char* boneRef = bone->Attribute("refID"); + if (boneRef) boneRefToPartRef.emplace(std::string(boneRef), partRef ? std::string(partRef) : std::string()); + } + } + } + } + + // Collect RigidSystem elements + std::vector rigidSystems; + auto* rigidSystemsParent = lxfml->FirstChildElement("RigidSystems"); + if (rigidSystemsParent) { + for (auto* rs = rigidSystemsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + rigidSystems.push_back(rs); + } + } + + // Collect top-level groups (immediate children of GroupSystem) + std::vector groupRoots; + auto* groupSystemsParent = lxfml->FirstChildElement("GroupSystems"); + if (groupSystemsParent) { + for (auto* gs = groupSystemsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* group = gs->FirstChildElement("Group"); group; group = group->NextSiblingElement("Group")) { + groupRoots.push_back(group); + } + } + } + + // Track used bricks and rigidsystems + std::unordered_set usedBrickRefs; + std::unordered_set usedRigidSystems; + + // Helper to create output document from sets of brick refs and rigidsystem pointers + auto makeOutput = [&](const std::unordered_set& bricksToInclude, const std::vector& rigidSystemsToInclude, const std::vector& groupsToInclude = {}) { + tinyxml2::XMLDocument outDoc; + outDoc.Parse(g_base.c_str()); + auto* outRoot = outDoc.FirstChildElement("LXFML"); + auto* outBricks = outRoot->FirstChildElement("Bricks"); + auto* outRigidSystems = outRoot->FirstChildElement("RigidSystems"); + auto* outGroupSystems = outRoot->FirstChildElement("GroupSystems"); + + // clone and insert bricks + for (const auto& bref : bricksToInclude) { + auto it = brickByRef.find(bref); + if (it == brickByRef.end()) continue; + tinyxml2::XMLElement* cloned = CloneElementDeep(it->second, outDoc); + if (cloned) outBricks->InsertEndChild(cloned); + } + + // clone and insert rigidsystems + for (auto* rsPtr : rigidSystemsToInclude) { + tinyxml2::XMLElement* cloned = CloneElementDeep(rsPtr, outDoc); + if (cloned) outRigidSystems->InsertEndChild(cloned); + } + + // clone and insert group(s) if requested + if (outGroupSystems && !groupsToInclude.empty()) { + // clear default children + while (outGroupSystems->FirstChild()) outGroupSystems->DeleteChild(outGroupSystems->FirstChild()); + // create a GroupSystem element and append requested groups + auto* newGS = outDoc.NewElement("GroupSystem"); + for (auto* gptr : groupsToInclude) { + tinyxml2::XMLElement* clonedG = CloneElementDeep(gptr, outDoc); + if (clonedG) newGS->InsertEndChild(clonedG); + } + outGroupSystems->InsertEndChild(newGS); + } + + // Print to string + tinyxml2::XMLPrinter printer; + outDoc.Print(&printer); + // Normalize position and compute center using existing helper + std::string xmlString = printer.CStr(); + if (xmlString.size() > 5000000) { // 5MB limit for normalization + Result emptyResult; + emptyResult.lxfml = xmlString; + return emptyResult; + } + auto normalized = NormalizePosition(xmlString, curPosition); + return normalized; + }; + + // 1) Process groups (each top-level Group becomes one output; nested groups are included) + for (auto* groupRoot : groupRoots) { + // collect all partRefs in this group's subtree + std::unordered_set partRefs; + std::function collectParts = [&](const tinyxml2::XMLElement* g) { + if (!g) return; + const char* partAttr = g->Attribute("partRefs"); + if (partAttr) { + for (auto& tok : GeneralUtils::SplitString(partAttr, ',')) partRefs.insert(tok); + } + for (auto* child = g->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectParts(child); + }; + collectParts(groupRoot); + + // Build initial sets of bricks and boneRefs + std::unordered_set bricksIncluded; + std::unordered_set boneRefsIncluded; + for (const auto& pref : partRefs) { + auto pit = partRefToBrick.find(pref); + if (pit != partRefToBrick.end()) { + const char* bref = pit->second->Attribute("refID"); + if (bref) bricksIncluded.insert(std::string(bref)); + } + auto partIt = partRefToPart.find(pref); + if (partIt != partRefToPart.end()) { + auto* bone = partIt->second->FirstChildElement("Bone"); + if (bone) { + const char* bref = bone->Attribute("refID"); + if (bref) boneRefsIncluded.insert(std::string(bref)); + } + } + } + + // Iteratively include any RigidSystems that reference any boneRefsIncluded + bool changed = true; + std::vector rigidSystemsToInclude; + int maxIterations = 1000; // Safety limit to prevent infinite loops + int iteration = 0; + while (changed && iteration < maxIterations) { + changed = false; + iteration++; + for (auto* rs : rigidSystems) { + if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue; + // parse boneRefs of this rigid system (from its children) + bool intersects = false; + std::vector rsBoneRefs; + for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) { + const char* battr = rigid->Attribute("boneRefs"); + if (!battr) continue; + for (auto& tok : GeneralUtils::SplitString(battr, ',')) { + rsBoneRefs.push_back(tok); + if (boneRefsIncluded.find(tok) != boneRefsIncluded.end()) intersects = true; + } + } + if (!intersects) continue; + // include this rigid system and all boneRefs it references + usedRigidSystems.insert(rs); + rigidSystemsToInclude.push_back(rs); + for (const auto& br : rsBoneRefs) { + boneRefsIncluded.insert(br); + auto bpIt = boneRefToPartRef.find(br); + if (bpIt != boneRefToPartRef.end()) { + auto partRef = bpIt->second; + auto pbIt = partRefToBrick.find(partRef); + if (pbIt != partRefToBrick.end()) { + const char* bref = pbIt->second->Attribute("refID"); + if (bref && bricksIncluded.insert(std::string(bref)).second) changed = true; + } + } + } + } + } + + if (iteration >= maxIterations) { + // Iteration limit reached, stop processing to prevent infinite loops + // The file is likely malformed, so just skip further processing + return results; + } + // include bricks from bricksIncluded into used set + for (const auto& b : bricksIncluded) usedBrickRefs.insert(b); + + // make output doc and push result (include this group's XML) + std::vector groupsVec{ groupRoot }; + auto normalized = makeOutput(bricksIncluded, rigidSystemsToInclude, groupsVec); + results.push_back(normalized); + } + + // 2) Process remaining RigidSystems (each becomes its own file) + for (auto* rs : rigidSystems) { + if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue; + std::unordered_set bricksIncluded; + // collect boneRefs referenced by this rigid system + for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) { + const char* battr = rigid->Attribute("boneRefs"); + if (!battr) continue; + for (auto& tok : GeneralUtils::SplitString(battr, ',')) { + auto bpIt = boneRefToPartRef.find(tok); + if (bpIt != boneRefToPartRef.end()) { + auto partRef = bpIt->second; + auto pbIt = partRefToBrick.find(partRef); + if (pbIt != partRefToBrick.end()) { + const char* bref = pbIt->second->Attribute("refID"); + if (bref) bricksIncluded.insert(std::string(bref)); + } + } + } + } + // mark used + for (const auto& b : bricksIncluded) usedBrickRefs.insert(b); + usedRigidSystems.insert(rs); + + std::vector rsVec{ rs }; + auto normalized = makeOutput(bricksIncluded, rsVec); + results.push_back(normalized); + } + + // 3) Any remaining bricks not included become their own files + for (const auto& [bref, brickPtr] : brickByRef) { + if (usedBrickRefs.find(bref) != usedBrickRefs.end()) continue; + std::unordered_set bricksIncluded{ bref }; + auto normalized = makeOutput(bricksIncluded, {}); + results.push_back(normalized); + usedBrickRefs.insert(bref); + } + + return results; +} diff --git a/dCommon/Lxfml.h b/dCommon/Lxfml.h index 80710713..1baaeeed 100644 --- a/dCommon/Lxfml.h +++ b/dCommon/Lxfml.h @@ -6,6 +6,7 @@ #include #include +#include #include "NiPoint3.h" @@ -18,6 +19,7 @@ namespace Lxfml { // Normalizes a LXFML model to be positioned relative to its local 0, 0, 0 rather than a game worlds 0, 0, 0. // Returns a struct of its new center and the updated LXFML containing these edits. [[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); // these are only for the migrations due to a bug in one of the implementations. [[nodiscard]] Result NormalizePositionOnlyFirstPart(const std::string_view data); diff --git a/dGame/dGameMessages/GameMessages.cpp b/dGame/dGameMessages/GameMessages.cpp index fffa52b9..c23b4988 100644 --- a/dGame/dGameMessages/GameMessages.cpp +++ b/dGame/dGameMessages/GameMessages.cpp @@ -2566,9 +2566,6 @@ void GameMessages::HandleBBBSaveRequest(RakNet::BitStream& inStream, Entity* ent inStream.Read(timeTaken); /* - Disabled this, as it's kinda silly to do this roundabout way of storing plaintext lxfml, then recompressing - it to send it back to the client. - On DLU we had agreed that bricks wouldn't be taken anyway, but if your server decides otherwise, feel free to comment this back out and add the needed code to get the bricks used from lxfml and take them from the inventory. @@ -2582,23 +2579,6 @@ void GameMessages::HandleBBBSaveRequest(RakNet::BitStream& inStream, Entity* ent //We need to get a new ID for our model first: if (!entity || !entity->GetCharacter() || !entity->GetCharacter()->GetParentUser()) return; - const uint32_t maxRetries = 100; - uint32_t retries = 0; - bool blueprintIDExists = true; - bool modelExists = true; - - // Legacy logic to check for old random IDs (regenerating these is not really feasible) - // Probably good to have this anyway in case someone messes with the last_object_id or it gets reset somehow - LWOOBJID newIDL = LWOOBJID_EMPTY; - LWOOBJID blueprintID = LWOOBJID_EMPTY; - do { - if (newIDL != LWOOBJID_EMPTY) LOG("Generating blueprintID for UGC model, collision with existing model ID: %llu", blueprintID); - newIDL = ObjectIDManager::GetPersistentID(); - blueprintID = ObjectIDManager::GetPersistentID(); - ++retries; - blueprintIDExists = Database::Get()->GetUgcModel(blueprintID).has_value(); - modelExists = Database::Get()->GetModel(newIDL).has_value(); - } while ((blueprintIDExists || modelExists) && retries < maxRetries); //We need to get the propertyID: (stolen from Wincent's propertyManagementComp) const auto& worldId = Game::zoneManager->GetZone()->GetZoneID(); @@ -2615,85 +2595,112 @@ void GameMessages::HandleBBBSaveRequest(RakNet::BitStream& inStream, Entity* ent std::istringstream sd0DataStream(str); Sd0 sd0(sd0DataStream); - // Uncompress the data and normalize the position + // Uncompress the data, split, and nornmalize the model const auto asStr = sd0.GetAsStringUncompressed(); - const auto [newLxfml, newCenter] = Lxfml::NormalizePosition(asStr); + auto splitLxfmls = Lxfml::Split(asStr); + LOG_DEBUG("Split into %zu models", splitLxfmls.size()); - // Recompress the data and save to the database - sd0.FromData(reinterpret_cast(newLxfml.data()), newLxfml.size()); - auto sd0AsStream = sd0.GetAsStream(); - Database::Get()->InsertNewUgcModel(sd0AsStream, blueprintID, entity->GetCharacter()->GetParentUser()->GetAccountID(), entity->GetCharacter()->GetID()); - - //Insert into the db as a BBB model: - IPropertyContents::Model model; - model.id = newIDL; - model.ugcId = blueprintID; - model.position = newCenter; - model.rotation = NiQuaternion(0.0f, 0.0f, 0.0f, 0.0f); - model.lot = 14; - Database::Get()->InsertNewPropertyModel(propertyId, model, "Objects_14_name"); - - /* - Commented out until UGC server would be updated to use a sd0 file instead of lxfml stream. - (or you uncomment the lxfml decomp stuff above) - */ - - // //Send off to UGC for processing, if enabled: - // if (Game::config->GetValue("ugc_remote") == "1") { - // std::string ugcIP = Game::config->GetValue("ugc_ip"); - // int ugcPort = std::stoi(Game::config->GetValue("ugc_port")); - - // httplib::Client cli(ugcIP, ugcPort); //connect to UGC HTTP server using our config above ^ - - // //Send out a request: - // std::string request = "/3dservices/UGCC150/150" + std::to_string(blueprintID) + ".lxfml"; - // cli.Put(request.c_str(), lxfml.c_str(), "text/lxfml"); - - // //When the "put" above returns, it means that the UGC HTTP server is done processing our model & - // //the nif, hkx and checksum files are ready to be downloaded from cache. - // } - - //Tell the client their model is saved: (this causes us to actually pop out of our current state): - const auto& newSd0 = sd0.GetAsVector(); - uint32_t newSd0Size{}; - for (const auto& chunk : newSd0) newSd0Size += chunk.size(); CBITSTREAM; BitStreamUtils::WriteHeader(bitStream, ServiceType::CLIENT, MessageType::Client::BLUEPRINT_SAVE_RESPONSE); bitStream.Write(localId); bitStream.Write(eBlueprintSaveResponseType::EverythingWorked); - bitStream.Write(1); - bitStream.Write(blueprintID); + bitStream.Write(splitLxfmls.size()); - bitStream.Write(newSd0Size); + std::vector blueprintIDs; + std::vector modelIDs; - for (const auto& chunk : newSd0) bitStream.WriteAlignedBytes(reinterpret_cast(chunk.data()), chunk.size()); + for (size_t i = 0; i < splitLxfmls.size(); ++i) { + // Legacy logic to check for old random IDs (regenerating these is not really feasible) + // Probably good to have this anyway in case someone messes with the last_object_id or it gets reset somehow + const uint32_t maxRetries = 100; + uint32_t retries = 0; + bool blueprintIDExists = true; + bool modelExists = true; + + LWOOBJID newID = LWOOBJID_EMPTY; + LWOOBJID blueprintID = LWOOBJID_EMPTY; + do { + if (newID != LWOOBJID_EMPTY) LOG("Generating blueprintID for UGC model, collision with existing model ID: %llu", blueprintID); + newID = ObjectIDManager::GetPersistentID(); + blueprintID = ObjectIDManager::GetPersistentID(); + ++retries; + blueprintIDExists = Database::Get()->GetUgcModel(blueprintID).has_value(); + modelExists = Database::Get()->GetModel(newID).has_value(); + } while ((blueprintIDExists || modelExists) && retries < maxRetries); + + blueprintIDs.push_back(blueprintID); + modelIDs.push_back(newID); + + // Save each model to the database + sd0.FromData(reinterpret_cast(splitLxfmls[i].lxfml.data()), splitLxfmls[i].lxfml.size()); + auto sd0AsStream = sd0.GetAsStream(); + Database::Get()->InsertNewUgcModel(sd0AsStream, blueprintID, entity->GetCharacter()->GetParentUser()->GetAccountID(), entity->GetCharacter()->GetID()); + + // Insert the new property model + IPropertyContents::Model model; + model.id = newID; + model.ugcId = blueprintID; + model.position = splitLxfmls[i].center; + model.rotation = QuatUtils::IDENTITY; + model.lot = 14; + Database::Get()->InsertNewPropertyModel(propertyId, model, "Objects_14_name"); + + /* + Commented out until UGC server would be updated to use a sd0 file instead of lxfml stream. + (or you uncomment the lxfml decomp stuff above) + */ + + // Send off to UGC for processing, if enabled: + // if (Game::config->GetValue("ugc_remote") == "1") { + // std::string ugcIP = Game::config->GetValue("ugc_ip"); + // int ugcPort = std::stoi(Game::config->GetValue("ugc_port")); + + // httplib::Client cli(ugcIP, ugcPort); //connect to UGC HTTP server using our config above ^ + + // //Send out a request: + // std::string request = "/3dservices/UGCC150/150" + std::to_string(blueprintID) + ".lxfml"; + // cli.Put(request.c_str(), lxfml.c_str(), "text/lxfml"); + + // //When the "put" above returns, it means that the UGC HTTP server is done processing our model & + // //the nif, hkx and checksum files are ready to be downloaded from cache. + // } + + // Write the ID and data to the response packet + bitStream.Write(blueprintID); + + const auto& newSd0 = sd0.GetAsVector(); + uint32_t newSd0Size{}; + for (const auto& chunk : newSd0) newSd0Size += chunk.size(); + bitStream.Write(newSd0Size); + for (const auto& chunk : newSd0) bitStream.WriteAlignedBytes(reinterpret_cast(chunk.data()), chunk.size()); + } SEND_PACKET; - //Now we have to construct this object: + // Create entities for each model + for (size_t i = 0; i < splitLxfmls.size(); ++i) { + EntityInfo info; + info.lot = 14; + info.pos = splitLxfmls[i].center; + info.rot = QuatUtils::IDENTITY; + info.spawner = nullptr; + info.spawnerID = entity->GetObjectID(); + info.spawnerNodeID = 0; - EntityInfo info; - info.lot = 14; - info.pos = newCenter; - info.rot = {}; - info.spawner = nullptr; - info.spawnerID = entity->GetObjectID(); - info.spawnerNodeID = 0; + info.settings.push_back(new LDFData(u"blueprintid", blueprintIDs[i])); + info.settings.push_back(new LDFData(u"componentWhitelist", 1)); + info.settings.push_back(new LDFData(u"modelType", 2)); + info.settings.push_back(new LDFData(u"propertyObjectID", true)); + info.settings.push_back(new LDFData(u"userModelID", modelIDs[i])); + Entity* newEntity = Game::entityManager->CreateEntity(info, nullptr); + if (newEntity) { + Game::entityManager->ConstructEntity(newEntity); - info.settings.push_back(new LDFData(u"blueprintid", blueprintID)); - info.settings.push_back(new LDFData(u"componentWhitelist", 1)); - info.settings.push_back(new LDFData(u"modelType", 2)); - info.settings.push_back(new LDFData(u"propertyObjectID", true)); - info.settings.push_back(new LDFData(u"userModelID", newIDL)); - - Entity* newEntity = Game::entityManager->CreateEntity(info, nullptr); - if (newEntity) { - Game::entityManager->ConstructEntity(newEntity); - - //Make sure the propMgmt doesn't delete our model after the server dies - //Trying to do this after the entity is constructed. Shouldn't really change anything but - //there was an issue with builds not appearing since it was placed above ConstructEntity. - PropertyManagementComponent::Instance()->AddModel(newEntity->GetObjectID(), newIDL); + //Make sure the propMgmt doesn't delete our model after the server dies + //Trying to do this after the entity is constructed. Shouldn't really change anything but + //there was an issue with builds not appearing since it was placed above ConstructEntity. + PropertyManagementComponent::Instance()->AddModel(newEntity->GetObjectID(), modelIDs[i]); + } } } diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 110b7573..ad570534 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -1166,32 +1166,36 @@ void HandlePacket(Packet* packet) { LOG("Couldn't find property ID for zone %i, clone %i", zoneId, cloneId); goto noBBB; } - for (auto& bbbModel : Database::Get()->GetUgcModels(propertyId)) { + + // Workaround for not having a UGC server to get model LXFML onto the client so it + // can generate the physics and nif for the object. + + auto bbbModels = Database::Get()->GetUgcModels(propertyId); + if (bbbModels.empty()) { + LOG("No BBB models found for property %llu", propertyId); + goto noBBB; + } + + CBITSTREAM; + BitStreamUtils::WriteHeader(bitStream, ServiceType::CLIENT, MessageType::Client::BLUEPRINT_SAVE_RESPONSE); + bitStream.Write(LWOOBJID_EMPTY); //always zero so that a check on the client passes + bitStream.Write(eBlueprintSaveResponseType::EverythingWorked); + bitStream.Write(bbbModels.size()); + for (auto& bbbModel : bbbModels) { LOG("Getting lxfml ugcID: %llu", bbbModel.id); bbbModel.lxfmlData.seekg(0, std::ios::end); size_t lxfmlSize = bbbModel.lxfmlData.tellg(); bbbModel.lxfmlData.seekg(0); - //Send message: + // write data LWOOBJID blueprintID = bbbModel.id; - - // Workaround for not having a UGC server to get model LXFML onto the client so it - // can generate the physics and nif for the object. - CBITSTREAM; - BitStreamUtils::WriteHeader(bitStream, ServiceType::CLIENT, MessageType::Client::BLUEPRINT_SAVE_RESPONSE); - bitStream.Write(LWOOBJID_EMPTY); //always zero so that a check on the client passes - bitStream.Write(eBlueprintSaveResponseType::EverythingWorked); - bitStream.Write(1); bitStream.Write(blueprintID); - bitStream.Write(lxfmlSize); - bitStream.WriteAlignedBytes(reinterpret_cast(bbbModel.lxfmlData.str().c_str()), lxfmlSize); - - SystemAddress sysAddr = packet->systemAddress; - SEND_PACKET; } + SystemAddress sysAddr = packet->systemAddress; + SEND_PACKET; } noBBB: diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index 17b31ced..74039be1 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -10,6 +10,7 @@ set(DCOMMONTEST_SOURCES "TestLUString.cpp" "TestLUWString.cpp" "dCommonDependencies.cpp" + "LxfmlTests.cpp" ) add_subdirectory(dEnumsTests) @@ -32,6 +33,8 @@ target_link_libraries(dCommonTests ${COMMON_LIBRARIES} GTest::gtest_main) # Copy test files to testing directory add_subdirectory(TestBitStreams) file(COPY ${TESTBITSTREAMS} DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) +add_subdirectory(LxfmlTestFiles) +file(COPY ${LXFMLTESTFILES} DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) # Discover the tests gtest_discover_tests(dCommonTests) diff --git a/tests/dCommonTests/LxfmlTestFiles/CMakeLists.txt b/tests/dCommonTests/LxfmlTestFiles/CMakeLists.txt new file mode 100644 index 00000000..e07cbd62 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/CMakeLists.txt @@ -0,0 +1,18 @@ +set(LXFMLTESTFILES + "deeply_nested.lxfml" + "empty_transform.lxfml" + "invalid_transform.lxfml" + "mixed_invalid_transform.lxfml" + "mixed_valid_invalid.lxfml" + "non_numeric_transform.lxfml" + "no_bricks.lxfml" + "test.lxfml" + "too_few_values.lxfml" +) + +# Get the folder name and prepend it to the files above +get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME) +list(TRANSFORM LXFMLTESTFILES PREPEND "${thisFolderName}/") + +# Export our list of files +set(LXFMLTESTFILES ${LXFMLTESTFILES} PARENT_SCOPE) diff --git a/tests/dCommonTests/LxfmlTestFiles/deeply_nested.lxfml b/tests/dCommonTests/LxfmlTestFiles/deeply_nested.lxfml new file mode 100644 index 00000000..ea473ab1 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/deeply_nested.lxfml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/empty_transform.lxfml b/tests/dCommonTests/LxfmlTestFiles/empty_transform.lxfml new file mode 100644 index 00000000..5d5cc90d --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/empty_transform.lxfml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/invalid_transform.lxfml b/tests/dCommonTests/LxfmlTestFiles/invalid_transform.lxfml new file mode 100644 index 00000000..19899850 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/invalid_transform.lxfml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/mixed_invalid_transform.lxfml b/tests/dCommonTests/LxfmlTestFiles/mixed_invalid_transform.lxfml new file mode 100644 index 00000000..6b0730d1 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/mixed_invalid_transform.lxfml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/mixed_valid_invalid.lxfml b/tests/dCommonTests/LxfmlTestFiles/mixed_valid_invalid.lxfml new file mode 100644 index 00000000..30a0b278 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/mixed_valid_invalid.lxfml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/no_bricks.lxfml b/tests/dCommonTests/LxfmlTestFiles/no_bricks.lxfml new file mode 100644 index 00000000..da7d56ae --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/no_bricks.lxfml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/non_numeric_transform.lxfml b/tests/dCommonTests/LxfmlTestFiles/non_numeric_transform.lxfml new file mode 100644 index 00000000..3245cb10 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/non_numeric_transform.lxfml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/test.lxfml b/tests/dCommonTests/LxfmlTestFiles/test.lxfml new file mode 100644 index 00000000..f9ce0d35 --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/test.lxfml @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTestFiles/too_few_values.lxfml b/tests/dCommonTests/LxfmlTestFiles/too_few_values.lxfml new file mode 100644 index 00000000..ae37857b --- /dev/null +++ b/tests/dCommonTests/LxfmlTestFiles/too_few_values.lxfml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/dCommonTests/LxfmlTests.cpp b/tests/dCommonTests/LxfmlTests.cpp new file mode 100644 index 00000000..7e1ca4b7 --- /dev/null +++ b/tests/dCommonTests/LxfmlTests.cpp @@ -0,0 +1,297 @@ +#include "gtest/gtest.h" + +#include "Lxfml.h" +#include "TinyXmlUtils.h" +#include "dCommonDependencies.h" + +#include +#include +#include +#include + +using namespace TinyXmlUtils; + +static std::string ReadFile(const std::string& filename) { + std::ifstream in(filename, std::ios::in | std::ios::binary); + if (!in.is_open()) { + return ""; + } + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +std::string SerializeElement(tinyxml2::XMLElement* elem) { + tinyxml2::XMLPrinter p; + elem->Accept(&p); + return std::string(p.CStr()); +}; + +TEST(LxfmlTests, SplitUsesAllBricksAndNoDuplicates) { + // Read the test.lxfml file copied to build directory by CMake + std::string data = ReadFile("test.lxfml"); + ASSERT_FALSE(data.empty()) << "Failed to read test.lxfml from build directory"; + + auto results = Lxfml::Split(data); + ASSERT_GT(results.size(), 0); + + // parse original to count bricks + tinyxml2::XMLDocument doc; + ASSERT_EQ(doc.Parse(data.c_str()), tinyxml2::XML_SUCCESS); + DocumentReader reader(doc); + auto lxfml = reader["LXFML"]; + ASSERT_TRUE(lxfml); + + std::unordered_set originalRigidSet; + if (auto* rsParent = doc.FirstChildElement("LXFML")->FirstChildElement("RigidSystems")) { + for (auto* rs = rsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + originalRigidSet.insert(SerializeElement(rs)); + } + } + + std::unordered_set originalGroupSet; + if (auto* gsParent = doc.FirstChildElement("LXFML")->FirstChildElement("GroupSystems")) { + for (auto* gs = gsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* g = gs->FirstChildElement("Group"); g; g = g->NextSiblingElement("Group")) { + // collect this group and nested groups + std::function collectGroups = [&](tinyxml2::XMLElement* grp) { + originalGroupSet.insert(SerializeElement(grp)); + for (auto* child = grp->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectGroups(child); + }; + collectGroups(g); + } + } + } + + std::unordered_set originalBricks; + for (const auto& brick : lxfml["Bricks"]) { + const auto* ref = brick.Attribute("refID"); + if (ref) originalBricks.insert(ref); + } + ASSERT_GT(originalBricks.size(), 0); + + // Collect bricks across all results and ensure no duplicates and all used + std::unordered_set usedBricks; + // Track used rigid systems and groups (serialized strings) + std::unordered_set usedRigidSet; + std::unordered_set usedGroupSet; + for (const auto& res : results) { + tinyxml2::XMLDocument outDoc; + ASSERT_EQ(outDoc.Parse(res.lxfml.c_str()), tinyxml2::XML_SUCCESS); + DocumentReader outReader(outDoc); + auto outLxfml = outReader["LXFML"]; + ASSERT_TRUE(outLxfml); + // collect rigid systems in this output + if (auto* rsParent = outDoc.FirstChildElement("LXFML")->FirstChildElement("RigidSystems")) { + for (auto* rs = rsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + auto s = SerializeElement(rs); + // no duplicate allowed across outputs + ASSERT_EQ(usedRigidSet.find(s), usedRigidSet.end()) << "Duplicate RigidSystem across splits"; + usedRigidSet.insert(s); + } + } + // collect groups in this output + if (auto* gsParent = outDoc.FirstChildElement("LXFML")->FirstChildElement("GroupSystems")) { + for (auto* gs = gsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* g = gs->FirstChildElement("Group"); g; g = g->NextSiblingElement("Group")) { + std::function collectGroupsOut = [&](tinyxml2::XMLElement* grp) { + auto s = SerializeElement(grp); + ASSERT_EQ(usedGroupSet.find(s), usedGroupSet.end()) << "Duplicate Group across splits"; + usedGroupSet.insert(s); + for (auto* child = grp->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectGroupsOut(child); + }; + collectGroupsOut(g); + } + } + } + for (const auto& brick : outLxfml["Bricks"]) { + const auto* ref = brick.Attribute("refID"); + if (ref) { + // no duplicate allowed + ASSERT_EQ(usedBricks.find(ref), usedBricks.end()) << "Duplicate brick ref across splits: " << ref; + usedBricks.insert(ref); + } + } + } + + // Every original brick must be used in one of the outputs + for (const auto& bref : originalBricks) { + ASSERT_NE(usedBricks.find(bref), usedBricks.end()) << "Brick not used in splits: " << bref; + } + + // And usedBricks should not contain anything outside original + for (const auto& ub : usedBricks) { + ASSERT_NE(originalBricks.find(ub), originalBricks.end()) << "Split produced unknown brick: " << ub; + } + + // Ensure all original rigid systems and groups were used exactly once + ASSERT_EQ(originalRigidSet.size(), usedRigidSet.size()) << "RigidSystem count mismatch"; + for (const auto& s : originalRigidSet) ASSERT_NE(usedRigidSet.find(s), usedRigidSet.end()) << "RigidSystem missing in splits"; + + ASSERT_EQ(originalGroupSet.size(), usedGroupSet.size()) << "Group count mismatch"; + for (const auto& s : originalGroupSet) ASSERT_NE(usedGroupSet.find(s), usedGroupSet.end()) << "Group missing in splits"; +} + +// Tests for invalid input handling - now working with the improved Split function + +TEST(LxfmlTests, InvalidLxfmlHandling) { + // Test LXFML with invalid transformation matrices + std::string invalidTransformData = ReadFile("invalid_transform.lxfml"); + ASSERT_FALSE(invalidTransformData.empty()) << "Failed to read invalid_transform.lxfml from build directory"; + + // The Split function should handle invalid transformation matrices gracefully + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(invalidTransformData); + }) << "Split should not crash on invalid transformation matrices"; + + // Function should handle invalid transforms gracefully, possibly returning empty or partial results + // The exact behavior depends on how the function handles invalid numeric parsing +} + +TEST(LxfmlTests, EmptyLxfmlHandling) { + // Test with completely empty input + std::string emptyData = ""; + std::vector results; + + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(emptyData); + }) << "Split should not crash on empty input"; + + EXPECT_EQ(results.size(), 0) << "Empty input should return empty results"; +} + +TEST(LxfmlTests, EmptyTransformHandling) { + // Test LXFML with empty transformation matrix + std::string testData = ReadFile("empty_transform.lxfml"); + ASSERT_FALSE(testData.empty()) << "Failed to read empty_transform.lxfml from build directory"; + + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(testData); + }) << "Split should not crash on empty transformation matrix"; + + // The function should handle empty transforms gracefully + // May return empty results or skip invalid bricks +} + +TEST(LxfmlTests, TooFewValuesTransformHandling) { + // Test LXFML with too few transformation values (needs 12, has fewer) + std::string testData = ReadFile("too_few_values.lxfml"); + ASSERT_FALSE(testData.empty()) << "Failed to read too_few_values.lxfml from build directory"; + + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(testData); + }) << "Split should not crash on transformation matrix with too few values"; + + // The function should handle incomplete transforms gracefully + // May return empty results or skip invalid bricks +} + +TEST(LxfmlTests, NonNumericTransformHandling) { + // Test LXFML with non-numeric transformation values + std::string testData = ReadFile("non_numeric_transform.lxfml"); + ASSERT_FALSE(testData.empty()) << "Failed to read non_numeric_transform.lxfml from build directory"; + + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(testData); + }) << "Split should not crash on non-numeric transformation values"; + + // The function should handle non-numeric transforms gracefully + // May return empty results or skip invalid bricks +} + +TEST(LxfmlTests, MixedInvalidTransformHandling) { + // Test LXFML with mixed valid/invalid transformation values within a matrix + std::string testData = ReadFile("mixed_invalid_transform.lxfml"); + ASSERT_FALSE(testData.empty()) << "Failed to read mixed_invalid_transform.lxfml from build directory"; + + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(testData); + }) << "Split should not crash on mixed valid/invalid transformation values"; + + // The function should handle mixed valid/invalid transforms gracefully + // May return empty results or skip invalid bricks +} + +TEST(LxfmlTests, NoBricksHandling) { + // Test LXFML with no Bricks section (should return empty gracefully) + std::string testData = ReadFile("no_bricks.lxfml"); + ASSERT_FALSE(testData.empty()) << "Failed to read no_bricks.lxfml from build directory"; + + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(testData); + }) << "Split should not crash on LXFML with no Bricks section"; + + // Should return empty results gracefully when no bricks are present + EXPECT_EQ(results.size(), 0) << "LXFML with no bricks should return empty results"; +} + +TEST(LxfmlTests, MixedValidInvalidTransformsHandling) { + // Test LXFML with mix of valid and invalid transformation data + std::string mixedValidData = ReadFile("mixed_valid_invalid.lxfml"); + ASSERT_FALSE(mixedValidData.empty()) << "Failed to read mixed_valid_invalid.lxfml from build directory"; + + // The Split function should handle mixed valid/invalid transforms gracefully + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(mixedValidData); + }) << "Split should not crash on mixed valid/invalid transforms"; + + // Should process valid bricks and handle invalid ones gracefully + if (results.size() > 0) { + EXPECT_NO_FATAL_FAILURE({ + for (size_t i = 0; i < results.size(); ++i) { + // Each result should have valid LXFML structure + tinyxml2::XMLDocument doc; + auto parseResult = doc.Parse(results[i].lxfml.c_str()); + EXPECT_EQ(parseResult, tinyxml2::XML_SUCCESS) + << "Result " << i << " should produce valid XML"; + + if (parseResult == tinyxml2::XML_SUCCESS) { + auto* lxfml = doc.FirstChildElement("LXFML"); + EXPECT_NE(lxfml, nullptr) << "Result " << i << " should have LXFML root element"; + } + } + }) << "Mixed valid/invalid transform processing should not cause fatal errors"; + } +} + +TEST(LxfmlTests, DeepCloneDepthProtection) { + // Test that deep cloning has protection against excessive nesting + std::string deeplyNestedLxfml = ReadFile("deeply_nested.lxfml"); + ASSERT_FALSE(deeplyNestedLxfml.empty()) << "Failed to read deeply_nested.lxfml from build directory"; + + // The Split function should handle deeply nested structures without hanging + std::vector results; + EXPECT_NO_FATAL_FAILURE({ + results = Lxfml::Split(deeplyNestedLxfml); + }) << "Split should not hang or crash on deeply nested XML structures"; + + // Should still produce valid output despite depth limitations + EXPECT_GT(results.size(), 0) << "Should produce at least one result even with deep nesting"; + + if (results.size() > 0) { + // Verify the result is still valid XML + tinyxml2::XMLDocument doc; + auto parseResult = doc.Parse(results[0].lxfml.c_str()); + EXPECT_EQ(parseResult, tinyxml2::XML_SUCCESS) << "Result should still be valid XML"; + + if (parseResult == tinyxml2::XML_SUCCESS) { + auto* lxfml = doc.FirstChildElement("LXFML"); + EXPECT_NE(lxfml, nullptr) << "Result should have LXFML root element"; + + // Verify that bricks are still included despite group nesting issues + auto* bricks = lxfml->FirstChildElement("Bricks"); + EXPECT_NE(bricks, nullptr) << "Bricks element should be present"; + if (bricks) { + auto* brick = bricks->FirstChildElement("Brick"); + EXPECT_NE(brick, nullptr) << "At least one brick should be present"; + } + } + } +}