mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-06 20:07:13 +00:00
Files from before chunks now read their sounds and particles after the objects, and chunked files their particle chunk: - sounds (SceneLoader::LoadSounds, version 5 on, only before chunks): position and config (before 35 a 260 character name, kept as soundName); - particles (SceneLoader::LoadParticles, version 5 on): priority (43 on), position, rotation, effect names and config (46 on; before, an unused u16); the client stops at one with no effect, and before 44 a file may end before them. Every scene file of every client on disk reads each part to the next part's start (up to the client's 16 byte padding): 5408 referenced by zone files and 5125 on disk, versions 31-43 before chunks and 44-49 chunked. One client file, a LUP world, ends before its sounds; its objects are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
17 KiB
C++
475 lines
17 KiB
C++
#include "Game.h"
|
|
#include "Level.h"
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include "BinaryIO.h"
|
|
#include "Logger.h"
|
|
#include "Spawner.h"
|
|
#include "dZoneManager.h"
|
|
#include "GeneralUtils.h"
|
|
#include "Entity.h"
|
|
#include "EntityManager.h"
|
|
#include "CDFeatureGatingTable.h"
|
|
#include "CDClientManager.h"
|
|
#include "AssetManager.h"
|
|
#include "ClientVersion.h"
|
|
#include "dConfig.h"
|
|
#include <ranges>
|
|
#include <algorithm>
|
|
#include <stdexcept>
|
|
|
|
Level::Level(Zone* parentZone, const std::string& filepath) {
|
|
m_ParentZone = parentZone;
|
|
|
|
auto stream = Game::assetManager->GetFile(filepath.c_str());
|
|
|
|
if (!stream) {
|
|
LOG("Failed to load %s", filepath.c_str());
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ReadChunks(stream);
|
|
} catch (const std::runtime_error&) {
|
|
// The client reads past the end of such a file; what was read before it is kept
|
|
LOG("%s ends early; loading the %zu objects read before its end", filepath.c_str(), m_Objects.size());
|
|
}
|
|
LoadSceneObjects();
|
|
}
|
|
|
|
Level::Level(std::istream& file) {
|
|
ReadChunks(file);
|
|
}
|
|
|
|
void Level::MakeSpawner(const SceneObject& obj) {
|
|
SpawnerInfo spawnInfo = SpawnerInfo();
|
|
SpawnerNode* node = new SpawnerNode();
|
|
spawnInfo.templateID = obj.lot;
|
|
spawnInfo.spawnerID = obj.id;
|
|
spawnInfo.templateScale = obj.scale;
|
|
node->position = obj.position;
|
|
node->rotation = obj.rotation;
|
|
node->config = obj.settings;
|
|
spawnInfo.nodes.push_back(node);
|
|
for (const auto& data : obj.settings.values | std::views::values) {
|
|
if (!data) continue;
|
|
if (data->GetKey() == u"spawntemplate") {
|
|
spawnInfo.templateID = GeneralUtils::TryParse(data->GetValueAsString(), 0);
|
|
}
|
|
|
|
if (data->GetKey() == u"spawner_node_id") {
|
|
node->nodeID = GeneralUtils::TryParse(data->GetValueAsString(), 0u);
|
|
}
|
|
|
|
if (data->GetKey() == u"spawner_name") {
|
|
spawnInfo.name = data->GetValueAsString();
|
|
}
|
|
|
|
if (data->GetKey() == u"max_to_spawn") {
|
|
spawnInfo.maxToSpawn = GeneralUtils::TryParse(data->GetValueAsString(), 0);
|
|
}
|
|
|
|
if (data->GetKey() == u"spawner_active_on_load") {
|
|
spawnInfo.activeOnLoad = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
|
|
if (data->GetKey() == u"active_on_load") {
|
|
spawnInfo.activeOnLoad = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
|
|
if (data->GetKey() == u"respawn") {
|
|
if (data->GetValueType() == eLDFType::LDF_TYPE_FLOAT) // Floats are in seconds
|
|
{
|
|
spawnInfo.respawnTime = GeneralUtils::TryParse(data->GetValueAsString(), 0.0f);
|
|
} else if (data->GetValueType() == eLDFType::LDF_TYPE_U32) // Ints are in ms
|
|
{
|
|
spawnInfo.respawnTime = GeneralUtils::TryParse(data->GetValueAsString(), 0) / 1000;
|
|
}
|
|
}
|
|
if (data->GetKey() == u"spawnsGroupOnSmash") {
|
|
spawnInfo.spawnsOnSmash = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
if (data->GetKey() == u"spawnNetNameForSpawnGroupOnSmash") {
|
|
spawnInfo.spawnOnSmashGroupName = data->GetValueAsString();
|
|
}
|
|
if (data->GetKey() == u"groupID") { // Load object groups
|
|
spawnInfo.groups = GeneralUtils::SplitString(data->GetValueAsString(), ';');
|
|
if (spawnInfo.groups.back().empty()) spawnInfo.groups.erase(spawnInfo.groups.end() - 1);
|
|
}
|
|
if (data->GetKey() == u"no_auto_spawn") {
|
|
spawnInfo.noAutoSpawn = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
if (data->GetKey() == u"no_timed_spawn") {
|
|
spawnInfo.noTimedSpawn = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
if (data->GetKey() == u"spawnActivator") {
|
|
spawnInfo.spawnActivator = GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
}
|
|
}
|
|
|
|
Game::zoneManager->MakeSpawner(spawnInfo);
|
|
}
|
|
|
|
void Level::LoadSceneObjects() {
|
|
CDFeatureGatingTable* featureGatingTable = CDClientManager::GetTable<CDFeatureGatingTable>();
|
|
|
|
CDFeatureGating gating;
|
|
gating.major =
|
|
GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_major")).value_or(ClientVersion::major);
|
|
gating.current =
|
|
GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_current")).value_or(ClientVersion::current);
|
|
gating.minor =
|
|
GeneralUtils::TryParse<int32_t>(Game::config->GetValue("version_minor")).value_or(ClientVersion::minor);
|
|
|
|
const auto zoneControlObject = Game::zoneManager->GetZoneControlObject();
|
|
DluAssert(zoneControlObject != nullptr);
|
|
for (const auto& obj : m_Objects) {
|
|
//This is a little bit of a bodge, but because the alpha client (HF) doesn't store the
|
|
//spawn position / rotation like the later versions do, we need to check the LOT for the spawn pos & set it.
|
|
if (obj.lot == LOT_MARKER_PLAYER_START) {
|
|
Game::zoneManager->GetZoneMut()->SetSpawnPos(obj.position);
|
|
Game::zoneManager->GetZoneMut()->SetSpawnRot(obj.rotation);
|
|
}
|
|
|
|
// We should never have more than 1 zone control object
|
|
bool skipLoadingObject = obj.lot == zoneControlObject->GetLOT();
|
|
for (const auto& data : obj.settings | std::views::values) {
|
|
if (!data) continue;
|
|
if (data->GetKey() == u"gatingOnFeature") {
|
|
gating.featureName = data->GetValueAsString();
|
|
if (gating.featureName == Game::config->GetValue("event_1")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_2")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_3")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_4")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_5")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_6")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_7")) continue;
|
|
else if (gating.featureName == Game::config->GetValue("event_8")) continue;
|
|
else if (!featureGatingTable->FeatureUnlocked(gating)) {
|
|
// The feature is not unlocked, so we can skip loading this object
|
|
skipLoadingObject = true;
|
|
break;
|
|
}
|
|
}
|
|
// If this is a client only object, we can skip loading it
|
|
if (data->GetKey() == u"loadOnClientOnly") {
|
|
skipLoadingObject |= GeneralUtils::TryParse(data->GetValueAsString(), false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (skipLoadingObject) {
|
|
continue;
|
|
}
|
|
|
|
if (obj.lot == 176) { //Spawner
|
|
MakeSpawner(obj);
|
|
} else { //Regular object
|
|
EntityInfo info;
|
|
info.spawnerID = 0;
|
|
info.id = obj.id;
|
|
info.lot = obj.lot;
|
|
info.pos = obj.position;
|
|
info.rot = obj.rotation;
|
|
info.settings = obj.settings;
|
|
info.scale = obj.scale;
|
|
Game::entityManager->CreateEntity(info);
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
constexpr uint32_t CHNK_HEADER = ('C' + ('H' << 8) + ('N' << 16) + ('K' << 24));
|
|
|
|
// A fixed size, zero padded name
|
|
std::string ReadName64(std::istream& file) {
|
|
std::string name(64, '\0');
|
|
file.read(name.data(), static_cast<std::streamsize>(name.size()));
|
|
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
|
name.resize(std::min(name.find('\0'), name.size()));
|
|
return name;
|
|
}
|
|
|
|
// A u32 count of wide characters
|
|
std::string ReadWideString32(std::istream& file) {
|
|
std::u16string value;
|
|
BinaryIO::ReadString<uint32_t>(file, value);
|
|
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
|
return GeneralUtils::UTF16ToWTF8(value);
|
|
}
|
|
}
|
|
|
|
void Level::ReadChunks(std::istream& file) {
|
|
uint32_t magic = 0;
|
|
BinaryIO::BinaryRead(file, magic);
|
|
if (magic == CHNK_HEADER) {
|
|
// SceneLoader::LoadLvlFile / LvlChunkFile::Read: the file info chunk starts the file and gives where the
|
|
// environment, object and particle chunks start (0 for none)
|
|
Header fileInfo = ReadChunkHeader(file, 0);
|
|
file.seekg(fileInfo.startPosition);
|
|
ReadFileInfoChunk(file, fileInfo);
|
|
m_ChunkHeaders.insert({ fileInfo.id, fileInfo });
|
|
m_Version = fileInfo.fileInfo.version;
|
|
m_Revision = fileInfo.fileInfo.revision;
|
|
|
|
if (fileInfo.fileInfo.enviromentChunkStart != 0) {
|
|
const auto header = ReadChunkHeader(file, fileInfo.fileInfo.enviromentChunkStart);
|
|
m_ChunkHeaders.insert({ header.id, header });
|
|
// EnvironmentChunk::ReadLvlChunk2000: where the lighting, skydome and editor settings start
|
|
file.seekg(header.startPosition);
|
|
BinaryIO::BinaryRead(file, m_Enviroment.lightingStart);
|
|
BinaryIO::BinaryRead(file, m_Enviroment.skydomeStart);
|
|
BinaryIO::BinaryRead(file, m_Enviroment.editorSettingsStart);
|
|
// The client reads all three only when there is lighting
|
|
if (m_Enviroment.lightingStart != 0) {
|
|
file.seekg(m_Enviroment.lightingStart);
|
|
ReadLightingInfo(file);
|
|
file.seekg(m_Enviroment.skydomeStart);
|
|
ReadSkydomeInfo(file);
|
|
file.seekg(m_Enviroment.editorSettingsStart);
|
|
ReadEditorSettings(file);
|
|
}
|
|
}
|
|
|
|
if (fileInfo.fileInfo.objectChunkStart != 0) {
|
|
const auto header = ReadChunkHeader(file, fileInfo.fileInfo.objectChunkStart);
|
|
m_ChunkHeaders.insert({ header.id, header });
|
|
file.seekg(header.startPosition);
|
|
ReadSceneObjectDataChunk(file);
|
|
}
|
|
|
|
if (fileInfo.fileInfo.particleChunkStart != 0) {
|
|
const auto header = ReadChunkHeader(file, fileInfo.fileInfo.particleChunkStart);
|
|
m_ChunkHeaders.insert({ header.id, header });
|
|
file.seekg(header.startPosition);
|
|
ReadParticles(file);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Scenes from before chunks: a header (SceneLoader::ReadLvlHeader), then each part in turn
|
|
file.seekg(0);
|
|
Header header;
|
|
header.id = ChunkTypeID::FileInfo;
|
|
BinaryIO::BinaryRead(file, header.chunkVersion);
|
|
BinaryIO::BinaryRead(file, header.chunkType);
|
|
m_Version = header.chunkVersion;
|
|
if (m_Version >= 32) BinaryIO::BinaryRead(file, m_NoEditorSettings);
|
|
if (m_Version > 36) BinaryIO::BinaryRead(file, m_Revision);
|
|
header.fileInfo.version = m_Version;
|
|
header.fileInfo.revision = m_Revision;
|
|
m_ChunkHeaders.insert({ header.id, header });
|
|
|
|
// A newer file is refused ("newer than the client understands")
|
|
if (m_Version > 48) {
|
|
LOG("Scene file version %u is newer than the client reads; nothing read from it", m_Version);
|
|
return;
|
|
}
|
|
|
|
ReadLightingInfo(file);
|
|
ReadSkydomeInfo(file);
|
|
ReadEditorSettings(file);
|
|
ReadSceneObjectDataChunk(file);
|
|
ReadSounds(file);
|
|
ReadParticles(file);
|
|
}
|
|
|
|
Level::Header Level::ReadChunkHeader(std::istream& file, uint32_t start) {
|
|
// No check of the magic, as in the client (0x01063a00)
|
|
file.seekg(start);
|
|
uint32_t magic = 0;
|
|
Header header;
|
|
BinaryIO::BinaryRead(file, magic);
|
|
BinaryIO::BinaryRead(file, header.id);
|
|
BinaryIO::BinaryRead(file, header.chunkVersion);
|
|
BinaryIO::BinaryRead(file, header.chunkType);
|
|
BinaryIO::BinaryRead(file, header.size);
|
|
BinaryIO::BinaryRead(file, header.startPosition);
|
|
return header;
|
|
}
|
|
|
|
void Level::ReadFileInfoChunk(std::istream& file, Header& header) {
|
|
BinaryIO::BinaryRead(file, header.fileInfo.version);
|
|
BinaryIO::BinaryRead(file, header.fileInfo.revision);
|
|
BinaryIO::BinaryRead(file, header.fileInfo.enviromentChunkStart);
|
|
BinaryIO::BinaryRead(file, header.fileInfo.objectChunkStart);
|
|
BinaryIO::BinaryRead(file, header.fileInfo.particleChunkStart);
|
|
}
|
|
|
|
void Level::ReadLightingInfo(std::istream& file) {
|
|
// SceneLoader::ReadLightingInfo (0x0102f8f0)
|
|
m_HasLighting = true;
|
|
if (m_Version >= 45) BinaryIO::BinaryRead(file, m_Lighting.blendTime);
|
|
BinaryIO::BinaryRead(file, m_Lighting.ambient);
|
|
BinaryIO::BinaryRead(file, m_Lighting.specular);
|
|
BinaryIO::BinaryRead(file, m_Lighting.upperHemi);
|
|
BinaryIO::BinaryRead(file, m_Lighting.position);
|
|
|
|
if (m_Version >= 31) {
|
|
if (m_Version < 39) {
|
|
BinaryIO::BinaryRead(file, m_Lighting.maxDrawDistances.fogNear);
|
|
BinaryIO::BinaryRead(file, m_Lighting.maxDrawDistances.fogFar);
|
|
m_Lighting.minDrawDistances.fogNear = m_Lighting.maxDrawDistances.fogNear;
|
|
m_Lighting.minDrawDistances.fogFar = m_Lighting.maxDrawDistances.fogFar;
|
|
} else {
|
|
for (auto* distances : { &m_Lighting.minDrawDistances, &m_Lighting.maxDrawDistances }) {
|
|
BinaryIO::BinaryRead(file, distances->fogNear);
|
|
BinaryIO::BinaryRead(file, distances->fogFar);
|
|
BinaryIO::BinaryRead(file, distances->postFogSolid);
|
|
BinaryIO::BinaryRead(file, distances->postFogFade);
|
|
BinaryIO::BinaryRead(file, distances->staticObjectDistance);
|
|
BinaryIO::BinaryRead(file, distances->dynamicObjectDistance);
|
|
}
|
|
|
|
if (m_Version >= 40) {
|
|
uint32_t count = 0;
|
|
BinaryIO::BinaryRead(file, count);
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
SceneCullSetting setting;
|
|
BinaryIO::BinaryRead(file, setting.group);
|
|
BinaryIO::BinaryRead(file, setting.minDistance);
|
|
BinaryIO::BinaryRead(file, setting.maxDistance);
|
|
m_Lighting.cullSettings.push_back(setting);
|
|
}
|
|
}
|
|
}
|
|
BinaryIO::BinaryRead(file, m_Lighting.fogColor);
|
|
}
|
|
|
|
if (m_Version >= 36) BinaryIO::BinaryRead(file, m_Lighting.directional);
|
|
|
|
if (m_Version < 42) {
|
|
m_Lighting.hasSpawnPoint = true;
|
|
BinaryIO::BinaryRead(file, m_Lighting.filePosition);
|
|
if (m_Version < 33) {
|
|
m_Lighting.spawnPosition = NiPoint3(347.0f, 284.0f, -325.0f);
|
|
m_Lighting.spawnRotation = NiQuaternion(0.97082251f, 0.0f, 0.23979919f, 0.0f);
|
|
} else {
|
|
m_Lighting.spawnPosition = m_Lighting.filePosition;
|
|
BinaryIO::BinaryRead(file, m_Lighting.spawnRotation);
|
|
}
|
|
}
|
|
}
|
|
|
|
void Level::ReadSkydomeInfo(std::istream& file) {
|
|
// SceneLoader::ReadSkydomeInfo (0x0102f550)
|
|
BinaryIO::ReadString<uint32_t>(file, m_Skydome.filename, BinaryIO::ReadType::String);
|
|
if (m_Version >= 34) {
|
|
for (auto& filename : m_Skydome.extraFilenames) BinaryIO::ReadString<uint32_t>(file, filename, BinaryIO::ReadType::String);
|
|
}
|
|
}
|
|
|
|
void Level::ReadEditorSettings(std::istream& file) {
|
|
// SceneLoader::ReadEditorSettings (0x0101b090): their size, then a count of colours; the client keeps the first 32
|
|
if (m_Version < 37 || m_NoEditorSettings != 0) return;
|
|
uint32_t size = 0;
|
|
BinaryIO::BinaryRead(file, size);
|
|
uint32_t count = 0;
|
|
BinaryIO::BinaryRead(file, count);
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
NiColor color;
|
|
BinaryIO::BinaryRead(file, color);
|
|
if (i < 32) m_EditorColors.push_back(color);
|
|
}
|
|
}
|
|
|
|
void Level::ReadSceneObjectDataChunk(std::istream& file) {
|
|
// SceneLoader::ReadLvlGameObjects: the client has no objects for a file older than version 3 ("Level file is
|
|
// unsupported"), and before chunks reads nothing after them either
|
|
if (m_Version < 3) return;
|
|
|
|
uint32_t objectsCount = 0;
|
|
BinaryIO::BinaryRead(file, objectsCount);
|
|
|
|
for (uint32_t i = 0; i < objectsCount; ++i) {
|
|
// SceneLoader::ReadLvlObjectData (0x0103ba20)
|
|
SceneObject obj;
|
|
BinaryIO::BinaryRead(file, obj.id);
|
|
BinaryIO::BinaryRead(file, obj.lot);
|
|
|
|
if (m_Version >= 38) {
|
|
int32_t nodeType = 1;
|
|
BinaryIO::BinaryRead(file, nodeType);
|
|
if (nodeType > -1 && nodeType < 11) obj.nodeType = nodeType;
|
|
}
|
|
|
|
if (m_Version >= 32) BinaryIO::BinaryRead(file, obj.glomId);
|
|
|
|
BinaryIO::BinaryRead(file, obj.position);
|
|
BinaryIO::BinaryRead(file, obj.rotation);
|
|
BinaryIO::BinaryRead(file, obj.scale);
|
|
const auto config = ReadWideString32(file);
|
|
|
|
if (m_Version >= 7) {
|
|
uint32_t techniqueCount = 0;
|
|
BinaryIO::BinaryRead(file, techniqueCount);
|
|
if (techniqueCount != 0) {
|
|
obj.renderTechniquesName = ReadName64(file);
|
|
obj.renderTechniques.resize(techniqueCount);
|
|
for (auto& technique : obj.renderTechniques) {
|
|
technique.name = ReadName64(file);
|
|
BinaryIO::BinaryRead(file, technique.valueCount);
|
|
BinaryIO::BinaryRead(file, technique.isColor);
|
|
for (auto& value : technique.values) BinaryIO::BinaryRead(file, value);
|
|
}
|
|
}
|
|
}
|
|
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
|
|
|
for (const auto& token : GeneralUtils::SplitString(config, '\n')) obj.settings.ParseInsert(token);
|
|
|
|
m_Objects.push_back(std::move(obj));
|
|
}
|
|
}
|
|
|
|
void Level::ReadSounds(std::istream& file) {
|
|
// SceneLoader::LoadSounds (0x0103c110), only in files from before chunks
|
|
if (m_Version < 5) return;
|
|
uint32_t count = 0;
|
|
BinaryIO::BinaryRead(file, count);
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
LevelSound sound;
|
|
BinaryIO::BinaryRead(file, sound.position);
|
|
if (m_Version < 35) {
|
|
// A 260 character name, up to its first null
|
|
std::u16string name(260, u'\0');
|
|
file.read(reinterpret_cast<char*>(name.data()), static_cast<std::streamsize>(name.size() * sizeof(char16_t)));
|
|
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);
|
|
}
|
|
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
|
m_Sounds.push_back(std::move(sound));
|
|
}
|
|
}
|
|
|
|
void Level::ReadParticles(std::istream& file) {
|
|
// SceneLoader::LoadParticles (0x01048320); before version 44 a file may end before them
|
|
if (m_Version < 5) return;
|
|
if (m_Version < 44 && file.peek() == std::char_traits<char>::eof()) {
|
|
file.clear();
|
|
return;
|
|
}
|
|
|
|
uint32_t count = 0;
|
|
BinaryIO::BinaryRead(file, count);
|
|
std::string config;
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
LevelParticle particle;
|
|
if (m_Version >= 43) BinaryIO::BinaryRead(file, particle.priority);
|
|
BinaryIO::BinaryRead(file, particle.position);
|
|
BinaryIO::BinaryRead(file, particle.rotation);
|
|
particle.effectNames = ReadWideString32(file);
|
|
if (m_Version < 46) BinaryIO::BinaryRead(file, particle.unknown);
|
|
else config = ReadWideString32(file);
|
|
particle.config = config;
|
|
if (file.fail()) throw std::runtime_error("Failed to read from istream.");
|
|
// The client stops at a particle with no effect
|
|
if (particle.effectNames.empty()) break;
|
|
m_Particles.push_back(std::move(particle));
|
|
}
|
|
}
|