From e3a0bb776486bc279567b5d1b1372e095312d9ca Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 27 Oct 2026 10:35:27 -0500 Subject: [PATCH] fix: split and adjust scene object config as the client does LwoNameValue::FromString (0x010fa220) splits a config at every comma and line break and leaves out entries without a '='; the world split only at line breaks. Objects and sounds are now split the client's way. ReadLvlObjectData (0x0103ba20) also adjusts an object's config as it loads it, and now so does the world: - before version 47 a respawn time was in milliseconds: a float over 100, or any u32, becomes seconds (a float); - a model spawner (LOT 176 spawning template 14) gets its blueprint from its subkey unless it has one, behaviors off unless it says otherwise, and no render wrapping; - before version 44 an object that overrides its scene goes to layer 0 of the scene it names. Check in game: every world spawns its objects as before; spawner respawn times are unchanged. Co-Authored-By: Claude Opus 5.5 --- dZoneManager/Level.cpp | 53 ++++++++++++++++++- dZoneManager/Level.h | 1 + .../dZoneManagerTests/LevelTests.cpp | 30 +++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index adf985f0f..d0443ae40 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -199,6 +199,18 @@ namespace { if (file.fail()) throw std::runtime_error("Failed to read from istream."); return GeneralUtils::UTF16ToWTF8(value); } + + // LwoNameValue::FromString (0x010fa220): entries are split at every comma and line break, empty ones and ones + // without a '=' are left out + void ParseConfig(LwoNameValue& config, const std::string& text) { + size_t start = 0; + while (start <= text.size()) { + const auto end = std::min(text.find_first_of(",\n", start), text.size()); + const auto entry = text.substr(start, end - start); + if (entry.find('=') != std::string::npos) config.ParseInsert(entry); + start = end + 1; + } + } } void Level::ReadChunks(std::istream& file) { @@ -418,12 +430,49 @@ void Level::ReadSceneObjectDataChunk(std::istream& file) { } if (file.fail()) throw std::runtime_error("Failed to read from istream."); - for (const auto& token : GeneralUtils::SplitString(config, '\n')) obj.settings.ParseInsert(token); + ParseConfig(obj.settings, config); + ApplyClientConfigFixups(obj); m_Objects.push_back(std::move(obj)); } } +void Level::ApplyClientConfigFixups(SceneObject& obj) const { + // What ReadLvlObjectData changes in an object's config as it loads it + auto& settings = obj.settings; + const auto typeOf = [&settings](const std::u16string& key) { + const auto it = settings.find(key); + return it == settings.end() || !it->second ? LDF_TYPE_UNKNOWN : it->second->GetValueType(); + }; + const auto valueOf = [&settings](const std::u16string& key) { + return static_cast*>(settings.find(key)->second.get())->GetValue(); + }; + + // Before version 47 a respawn time was in milliseconds: a float over 100, or any u32, becomes seconds (a float) + if (m_Version < 47) { + if (typeOf(u"respawn") == LDF_TYPE_FLOAT) { + const auto respawn = valueOf.operator()(u"respawn"); + if (respawn > 100.0f) settings.Insert(u"respawn", respawn * 0.001f); + } else if (typeOf(u"respawn") == LDF_TYPE_U32) { + settings.Insert(u"respawn", static_cast(valueOf.operator()(u"respawn") * 0.001)); + } + } + + // A model spawner (LOT 176 spawning template 14): its blueprint is its subkey unless it has one, its behaviors are + // off unless it says otherwise, and it never wraps its render + if (obj.lot == 176 && typeOf(u"spawntemplate") == LDF_TYPE_S32 && valueOf.operator()(u"spawntemplate") == 14) { + if (typeOf(u"blueprintid") == LDF_TYPE_UNKNOWN) { + const LWOOBJID subkey = typeOf(u"subkey") == LDF_TYPE_OBJID ? valueOf.operator()(u"subkey") : LWOOBJID_EMPTY; + settings.Insert(u"blueprintid", subkey); + } + if (typeOf(u"DisableModelBehaviors") == LDF_TYPE_UNKNOWN) settings.Insert(u"DisableModelBehaviors", true); + settings.Insert(u"preventRenderWrapping", true); + } + + // Before version 44 an object that overrides its scene goes to layer 0 of the scene it names + if (m_Version < 44 && typeOf(u"sceneIDOverrideEnabled") != LDF_TYPE_UNKNOWN) settings.Insert(u"sceneLayerIDOverride", 0); +} + void Level::ReadSounds(std::istream& file) { // SceneLoader::LoadSounds (0x0103c110), only in files from before chunks if (m_Version < 5) return; @@ -439,7 +488,7 @@ void Level::ReadSounds(std::istream& file) { name.resize(std::min(name.find(u'\0'), name.size())); sound.config.Insert(u"soundName", name); } else { - for (const auto& token : GeneralUtils::SplitString(ReadWideString32(file), '\n')) sound.config.ParseInsert(token); + ParseConfig(sound.config, ReadWideString32(file)); } if (file.fail()) throw std::runtime_error("Failed to read from istream."); m_Sounds.push_back(std::move(sound)); diff --git a/dZoneManager/Level.h b/dZoneManager/Level.h index 5aef7d8d8..f91ac6266 100644 --- a/dZoneManager/Level.h +++ b/dZoneManager/Level.h @@ -155,6 +155,7 @@ private: void ReadSkydomeInfo(std::istream& file); void ReadEditorSettings(std::istream& file); void ReadSceneObjectDataChunk(std::istream& file); + void ApplyClientConfigFixups(SceneObject& obj) const; void ReadSounds(std::istream& file); void ReadParticles(std::istream& file); void LoadSceneObjects(); diff --git a/tests/dGameTests/dZoneManagerTests/LevelTests.cpp b/tests/dGameTests/dZoneManagerTests/LevelTests.cpp index 04d83ac26..aff6604c9 100644 --- a/tests/dGameTests/dZoneManagerTests/LevelTests.cpp +++ b/tests/dGameTests/dZoneManagerTests/LevelTests.cpp @@ -271,6 +271,32 @@ TEST_F(LevelTests, ReadsEveryPartOfAChunkedFile) { EXPECT_TRUE(level.GetSounds().empty()); } +// LwoNameValue::FromString splits at commas and line breaks and leaves out entries without a '='; ReadLvlObjectData +// adjusts some values as it loads the object +TEST_F(LevelTests, ObjectConfigIsReadAndAdjustedAsTheClientDoes) { + const auto level = Read(WriteChunked(43)); + ASSERT_EQ(level.GetObjects().size(), 3); + + const auto& spawner = level.GetObjects()[0].settings; + ASSERT_NE(spawner.find(u"blueprintid"), spawner.end()); + EXPECT_EQ(spawner.find(u"blueprintid")->second->GetValueAsString(), "55"); + EXPECT_EQ(spawner.find(u"DisableModelBehaviors")->second->GetValueAsString(), "1"); + EXPECT_EQ(spawner.find(u"preventRenderWrapping")->second->GetValueAsString(), "1"); + // A float respawn over 100 was milliseconds + EXPECT_EQ(spawner.find(u"respawn")->second->GetValueType(), LDF_TYPE_FLOAT); + EXPECT_FLOAT_EQ(static_cast*>(spawner.find(u"respawn")->second.get())->GetValue(), 250.0f * 0.001f); + + const auto& overridden = level.GetObjects()[2].settings; + EXPECT_EQ(overridden.values.size(), 2); // the entry without a '=' is left out + ASSERT_NE(overridden.find(u"sceneLayerIDOverride"), overridden.end()); + EXPECT_EQ(overridden.find(u"sceneLayerIDOverride")->second->GetValueAsString(), "0"); + + // From version 47 a respawn is in seconds already, and from 44 the scene override stays + const auto newer = Read(WriteChunked(47)); + EXPECT_FLOAT_EQ(static_cast*>(newer.GetObjects()[0].settings.find(u"respawn")->second.get())->GetValue(), 250.0f); + EXPECT_EQ(newer.GetObjects()[2].settings.find(u"sceneLayerIDOverride"), newer.GetObjects()[2].settings.end()); +} + TEST_F(LevelTests, ReadsAFileFromBeforeChunks) { const auto level = Read(WriteFlat(40)); EXPECT_EQ(level.GetVersion(), 40); @@ -285,6 +311,10 @@ TEST_F(LevelTests, ReadsAFileFromBeforeChunks) { EXPECT_EQ(level.GetEditorColors().size(), 2); ASSERT_EQ(level.GetObjects().size(), 1); + // Before version 47 a u32 respawn is milliseconds; it becomes seconds + const auto& respawn = level.GetObjects()[0].settings.find(u"respawn")->second; + EXPECT_EQ(respawn->GetValueType(), LDF_TYPE_FLOAT); + EXPECT_FLOAT_EQ(static_cast*>(respawn.get())->GetValue(), 20.0f); ASSERT_EQ(level.GetSounds().size(), 2); EXPECT_FLOAT_EQ(level.GetSounds()[1].position.z, 13.0f);