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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-27 10:35:27 -05:00
committed by GitHub
parent 066571ea28
commit e3a0bb7764
3 changed files with 82 additions and 2 deletions

View File

@@ -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]<typename T>(const std::u16string& key) {
return static_cast<const LDFData<T>*>(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()<float>(u"respawn");
if (respawn > 100.0f) settings.Insert<float>(u"respawn", respawn * 0.001f);
} else if (typeOf(u"respawn") == LDF_TYPE_U32) {
settings.Insert<float>(u"respawn", static_cast<float>(valueOf.operator()<uint32_t>(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()<int32_t>(u"spawntemplate") == 14) {
if (typeOf(u"blueprintid") == LDF_TYPE_UNKNOWN) {
const LWOOBJID subkey = typeOf(u"subkey") == LDF_TYPE_OBJID ? valueOf.operator()<LWOOBJID>(u"subkey") : LWOOBJID_EMPTY;
settings.Insert<LWOOBJID>(u"blueprintid", subkey);
}
if (typeOf(u"DisableModelBehaviors") == LDF_TYPE_UNKNOWN) settings.Insert<bool>(u"DisableModelBehaviors", true);
settings.Insert<bool>(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<uint32_t>(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<std::u16string>(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));

View File

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

View File

@@ -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<const LDFData<float>*>(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<const LDFData<float>*>(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<const LDFData<float>*>(respawn.get())->GetValue(), 20.0f);
ASSERT_EQ(level.GetSounds().size(), 2);
EXPECT_FLOAT_EQ(level.GetSounds()[1].position.z, 13.0f);