Index: ps/trunk/binaries/data/mods/public/gui/common/functions_utility_loadsave.js
===================================================================
--- ps/trunk/binaries/data/mods/public/gui/common/functions_utility_loadsave.js (revision 20728)
+++ ps/trunk/binaries/data/mods/public/gui/common/functions_utility_loadsave.js (revision 20729)
@@ -1,115 +1,106 @@
function sortDecreasingDate(a, b)
{
return b.metadata.time - a.metadata.time;
}
function isCompatibleSavegame(metadata, engineInfo)
{
- return engineInfo && hasSameSavegameVersion(metadata, engineInfo) &&
- hasSameEngineVersion(metadata, engineInfo) & hasSameMods(metadata, engineInfo);
+ return engineInfo && hasSameEngineVersion(metadata, engineInfo) & hasSameMods(metadata, engineInfo);
}
function generateSavegameDateString(metadata, engineInfo)
{
return compatibilityColor(
Engine.FormatMillisecondsIntoDateStringLocal(metadata.time * 1000, translate("yyyy-MM-dd HH:mm:ss")),
isCompatibleSavegame(metadata, engineInfo));
}
function generateSavegameLabel(metadata, engineInfo)
{
return sprintf(
metadata.description ?
translate("%(dateString)s %(map)s - %(description)s") :
translate("%(dateString)s %(map)s"),
{
"dateString": generateSavegameDateString(metadata, engineInfo),
"map": metadata.initAttributes.map,
"description": metadata.description || ""
}
);
}
/**
* Check the version compatibility between the saved game to be loaded and the engine
*/
-function hasSameSavegameVersion(metadata, engineInfo)
-{
- return metadata.version_major == engineInfo.version_major;
-}
-
-/**
- * Check the version compatibility between the saved game to be loaded and the engine
- */
function hasSameEngineVersion(metadata, engineInfo)
{
return metadata.engine_version && metadata.engine_version == engineInfo.engine_version;
}
/**
* Check the mod compatibility between the saved game to be loaded and the engine
*
* @param metadata {string[]}
* @param engineInfo {string[]}
* @returns {boolean}
*/
function hasSameMods(metadata, engineInfo)
{
if (!metadata.mods || !engineInfo.mods)
return false;
// Ignore the "user" mod which is loaded for releases but not working-copies
let modsA = metadata.mods.filter(mod => mod != "user");
let modsB = engineInfo.mods.filter(mod => mod != "user");
if (modsA.length != modsB.length)
return false;
// Mods must be loaded in the same order
return modsA.every((mod, index) => mod == modsB[index]);
}
function deleteGame()
{
let gameSelection = Engine.GetGUIObjectByName("gameSelection");
let gameID = gameSelection.list_data[gameSelection.selected];
if (!gameID)
return;
if (Engine.HotkeyIsPressed("session.savedgames.noconfirmation"))
reallyDeleteGame(gameID);
else
messageBox(
500, 200,
sprintf(translate("\"%(label)s\""), {
"label": gameSelection.list[gameSelection.selected]
}) + "\n" + translate("Saved game will be permanently deleted, are you sure?"),
translate("DELETE"),
[translate("No"), translate("Yes")],
[null, function(){ reallyDeleteGame(gameID); }]
);
}
function reallyDeleteGame(gameID)
{
if (!Engine.DeleteSavedGame(gameID))
error("Could not delete saved game: " + gameID);
// Run init again to refresh saved game list
init();
}
function deleteTooltip()
{
let tooltip = colorizeHotkey(
translate("Delete the selected entry using %(hotkey)s."),
"session.savedgames.delete");
if (tooltip)
tooltip += colorizeHotkey(
"\n" + translate("Hold %(hotkey)s to delete without confirmation."),
"session.savedgames.noconfirmation");
return tooltip;
}
Index: ps/trunk/binaries/data/mods/public/gui/loadgame/load.js
===================================================================
--- ps/trunk/binaries/data/mods/public/gui/loadgame/load.js (revision 20728)
+++ ps/trunk/binaries/data/mods/public/gui/loadgame/load.js (revision 20729)
@@ -1,214 +1,207 @@
var g_SavedGamesMetadata = [];
/**
* Needed for formatPlayerInfo to show the player civs in the details.
*/
const g_CivData = loadCivData(false, false);
function init()
{
let savedGames = Engine.GetSavedGames();
// Get current game version and loaded mods
let engineInfo = Engine.GetEngineInfo();
if (Engine.GetGUIObjectByName("compatibilityFilter").checked)
savedGames = savedGames.filter(game => isCompatibleSavegame(game.metadata, engineInfo));
let gameSelection = Engine.GetGUIObjectByName("gameSelection");
gameSelection.enabled = !!savedGames.length;
Engine.GetGUIObjectByName("gameSelectionFeedback").hidden = !!savedGames.length;
let selectedGameId = gameSelection.list_data[gameSelection.selected];
// Save metadata for the detailed view
g_SavedGamesMetadata = savedGames.map(game => {
game.metadata.id = game.id;
return game.metadata;
});
let sortKey = gameSelection.selected_column;
let sortOrder = gameSelection.selected_column_order;
g_SavedGamesMetadata = g_SavedGamesMetadata.sort((a, b) => {
let cmpA, cmpB;
switch (sortKey)
{
case 'date':
cmpA = +a.time;
cmpB = +b.time;
break;
case 'mapName':
cmpA = translate(a.initAttributes.settings.Name);
cmpB = translate(b.initAttributes.settings.Name);
break;
case 'mapType':
cmpA = translateMapType(a.initAttributes.mapType);
cmpB = translateMapType(b.initAttributes.mapType);
break;
case 'description':
cmpA = a.description;
cmpB = b.description;
break;
}
if (cmpA < cmpB)
return -sortOrder;
else if (cmpA > cmpB)
return +sortOrder;
return 0;
});
let list = g_SavedGamesMetadata.map(metadata => {
let isCompatible = isCompatibleSavegame(metadata, engineInfo);
return {
"date": generateSavegameDateString(metadata, engineInfo),
"mapName": compatibilityColor(translate(metadata.initAttributes.settings.Name), isCompatible),
"mapType": compatibilityColor(translateMapType(metadata.initAttributes.mapType), isCompatible),
"description": compatibilityColor(metadata.description, isCompatible)
};
});
if (list.length)
list = prepareForDropdown(list);
gameSelection.list_date = list.date || [];
gameSelection.list_mapName = list.mapName || [];
gameSelection.list_mapType = list.mapType || [];
gameSelection.list_description = list.description || [];
// Change these last, otherwise crash
// list strings used in the delete dialog
gameSelection.list = g_SavedGamesMetadata.map(metadata => generateSavegameLabel(metadata, engineInfo));
gameSelection.list_data = g_SavedGamesMetadata.map(metadata => metadata.id);
let selectedGameIndex = g_SavedGamesMetadata.findIndex(metadata => metadata.id == selectedGameId);
if (selectedGameIndex != -1)
gameSelection.selected = selectedGameIndex;
else if (gameSelection.selected >= g_SavedGamesMetadata.length) // happens when deleting the last saved game
gameSelection.selected = g_SavedGamesMetadata.length - 1;
else if (gameSelection.selected == -1 && g_SavedGamesMetadata.length)
gameSelection.selected = 0;
Engine.GetGUIObjectByName("deleteGameButton").tooltip = deleteTooltip();
}
function selectionChanged()
{
let metadata = g_SavedGamesMetadata[Engine.GetGUIObjectByName("gameSelection").selected];
Engine.GetGUIObjectByName("invalidGame").hidden = !!metadata;
Engine.GetGUIObjectByName("validGame").hidden = !metadata;
Engine.GetGUIObjectByName("loadGameButton").enabled = !!metadata;
Engine.GetGUIObjectByName("deleteGameButton").enabled = !!metadata;
if (!metadata)
return;
Engine.GetGUIObjectByName("savedMapName").caption = translate(metadata.initAttributes.settings.Name);
let mapData = getMapDescriptionAndPreview(metadata.initAttributes.mapType, metadata.initAttributes.map);
setMapPreviewImage("savedInfoPreview", mapData.preview);
Engine.GetGUIObjectByName("savedPlayers").caption = metadata.initAttributes.settings.PlayerData.length - 1;
Engine.GetGUIObjectByName("savedPlayedTime").caption = timeToString(metadata.gui.timeElapsed ? metadata.gui.timeElapsed : 0);
Engine.GetGUIObjectByName("savedMapType").caption = translateMapType(metadata.initAttributes.mapType);
Engine.GetGUIObjectByName("savedMapSize").caption = translateMapSize(metadata.initAttributes.settings.Size);
Engine.GetGUIObjectByName("savedVictory").caption = translateVictoryCondition(metadata.initAttributes.settings.GameType);
let caption = sprintf(translate("Mods: %(mods)s"), { "mods": metadata.mods.join(translate(", ")) });
if (!hasSameMods(metadata, Engine.GetEngineInfo()))
caption = coloredText(caption, "orange");
Engine.GetGUIObjectByName("savedMods").caption = caption;
Engine.GetGUIObjectByName("savedPlayersNames").caption = formatPlayerInfo(
metadata.initAttributes.settings.PlayerData,
metadata.gui.states
);
}
function loadGame()
{
let gameSelection = Engine.GetGUIObjectByName("gameSelection");
let gameId = gameSelection.list_data[gameSelection.selected];
let metadata = g_SavedGamesMetadata[gameSelection.selected];
// Check compatibility before really loading it
let engineInfo = Engine.GetEngineInfo();
let sameMods = hasSameMods(metadata, engineInfo);
let sameEngineVersion = hasSameEngineVersion(metadata, engineInfo);
- let sameSavegameVersion = hasSameSavegameVersion(metadata, engineInfo);
- if (sameEngineVersion && sameSavegameVersion && sameMods)
+ if (sameEngineVersion && sameMods)
{
reallyLoadGame(gameId);
return;
}
// Version not compatible ... ask for confirmation
let message = translate("This saved game may not be compatible:");
if (!sameEngineVersion)
if (metadata.engine_version)
message += "\n" + sprintf(translate("It needs 0 A.D. version %(requiredVersion)s, while you are running version %(currentVersion)s."), {
"requiredVersion": metadata.engine_version,
"currentVersion": engineInfo.engine_version
});
else
message += "\n" + translate("It needs an older version of 0 A.D.");
- if (!sameSavegameVersion)
- message += "\n" + sprintf(translate("It needs 0 A.D. savegame version %(requiredVersion)s, while you have savegame version %(currentVersion)s."), {
- "requiredVersion": metadata.version_major,
- "currentVersion": engineInfo.version_major
- });
-
if (!sameMods)
{
if (!metadata.mods)
metadata.mods = [];
message += translate("The savegame needs a different set of mods:") + "\n" +
sprintf(translate("Required: %(mods)s"), {
"mods": metadata.mods.join(translate(", "))
}) + "\n" +
sprintf(translate("Active: %(mods)s"), {
"mods": engineInfo.mods.join(translate(", "))
});
}
message += "\n" + translate("Do you still want to proceed?");
messageBox(
500, 250,
message,
translate("Warning"),
[translate("No"), translate("Yes")],
[init, function(){ reallyLoadGame(gameId); }]
);
}
function reallyLoadGame(gameId)
{
let metadata = Engine.StartSavedGame(gameId);
if (!metadata)
{
// Probably the file wasn't found
// Show error and refresh saved game list
error("Could not load saved game: " + gameId);
init();
return;
}
let pData = metadata.initAttributes.settings.PlayerData[metadata.playerID];
Engine.SwitchGuiPage("page_loading.xml", {
"attribs": metadata.initAttributes,
"isNetworked": false,
"playerAssignments": {
"local": {
"name": pData ? pData.Name : singleplayerName(),
"player": metadata.playerID
}
},
"savedGUIData": metadata.gui
});
}
Index: ps/trunk/source/ps/SavedGame.cpp
===================================================================
--- ps/trunk/source/ps/SavedGame.cpp (revision 20728)
+++ ps/trunk/source/ps/SavedGame.cpp (revision 20729)
@@ -1,312 +1,304 @@
/* Copyright (C) 2017 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* 0 A.D. is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with 0 A.D. If not, see .
*/
#include "precompiled.h"
#include "SavedGame.h"
#include "graphics/GameView.h"
#include "gui/GUIManager.h"
#include "lib/allocators/shared_ptr.h"
#include "lib/file/archive/archive_zip.h"
#include "i18n/L10n.h"
#include "lib/utf8.h"
#include "ps/CLogger.h"
#include "ps/Filesystem.h"
#include "ps/Game.h"
#include "ps/Mod.h"
#include "ps/Pyrogenesis.h"
#include "scriptinterface/ScriptInterface.h"
#include "simulation2/Simulation2.h"
-static const int SAVED_GAME_VERSION_MAJOR = 1; // increment on incompatible changes to the format
-static const int SAVED_GAME_VERSION_MINOR = 0; // increment on compatible changes to the format
-
// TODO: we ought to check version numbers when loading files
-
Status SavedGames::SavePrefix(const CStrW& prefix, const CStrW& description, CSimulation2& simulation, const shared_ptr& guiMetadataClone)
{
// Determine the filename to save under
const VfsPath basenameFormat(L"saves/" + prefix + L"-%04d");
const VfsPath filenameFormat = basenameFormat.ChangeExtension(L".0adsave");
VfsPath filename;
// Don't make this a static global like NextNumberedFilename expects, because
// that wouldn't work when 'prefix' changes, and because it's not thread-safe
size_t nextSaveNumber = 0;
vfs::NextNumberedFilename(g_VFS, filenameFormat, nextSaveNumber, filename);
return Save(filename.Filename().string(), description, simulation, guiMetadataClone);
}
Status SavedGames::Save(const CStrW& name, const CStrW& description, CSimulation2& simulation, const shared_ptr& guiMetadataClone)
{
JSContext* cx = simulation.GetScriptInterface().GetContext();
JSAutoRequest rq(cx);
// Determine the filename to save under
const VfsPath basenameFormat(L"saves/" + name);
const VfsPath filename = basenameFormat.ChangeExtension(L".0adsave");
// ArchiveWriter_Zip can only write to OsPaths, not VfsPaths,
// but we'd like to handle saved games via VFS.
// To avoid potential confusion from writing with non-VFS then
// reading the same file with VFS, we'll just write to a temporary
// non-VFS path and then load and save again via VFS,
// which is kind of a hack.
OsPath tempSaveFileRealPath;
WARN_RETURN_STATUS_IF_ERR(g_VFS->GetDirectoryRealPath("cache/", tempSaveFileRealPath));
tempSaveFileRealPath = tempSaveFileRealPath / "temp.0adsave";
time_t now = time(NULL);
// Construct the serialized state to be saved
std::stringstream simStateStream;
if (!simulation.SerializeState(simStateStream))
WARN_RETURN(ERR::FAIL);
JS::RootedValue metadata(cx);
JS::RootedValue initAttributes(cx, simulation.GetInitAttributes());
simulation.GetScriptInterface().Eval("({})", &metadata);
- simulation.GetScriptInterface().SetProperty(metadata, "version_major", SAVED_GAME_VERSION_MAJOR);
- simulation.GetScriptInterface().SetProperty(metadata, "version_minor", SAVED_GAME_VERSION_MINOR);
simulation.GetScriptInterface().SetProperty(metadata, "engine_version", std::string(engine_version));
simulation.GetScriptInterface().SetProperty(metadata, "mods", g_modsLoaded);
simulation.GetScriptInterface().SetProperty(metadata, "time", (double)now);
simulation.GetScriptInterface().SetProperty(metadata, "playerID", g_Game->GetPlayerID());
simulation.GetScriptInterface().SetProperty(metadata, "initAttributes", initAttributes);
JS::RootedValue guiMetadata(cx);
simulation.GetScriptInterface().ReadStructuredClone(guiMetadataClone, &guiMetadata);
// get some camera data
JS::RootedValue cameraMetadata(cx);
simulation.GetScriptInterface().Eval("({})", &cameraMetadata);
simulation.GetScriptInterface().SetProperty(cameraMetadata, "PosX", g_Game->GetView()->GetCameraPosX());
simulation.GetScriptInterface().SetProperty(cameraMetadata, "PosY", g_Game->GetView()->GetCameraPosY());
simulation.GetScriptInterface().SetProperty(cameraMetadata, "PosZ", g_Game->GetView()->GetCameraPosZ());
simulation.GetScriptInterface().SetProperty(cameraMetadata, "RotX", g_Game->GetView()->GetCameraRotX());
simulation.GetScriptInterface().SetProperty(cameraMetadata, "RotY", g_Game->GetView()->GetCameraRotY());
simulation.GetScriptInterface().SetProperty(cameraMetadata, "Zoom", g_Game->GetView()->GetCameraZoom());
simulation.GetScriptInterface().SetProperty(guiMetadata, "camera", cameraMetadata);
simulation.GetScriptInterface().SetProperty(metadata, "gui", guiMetadata);
simulation.GetScriptInterface().SetProperty(metadata, "description", description);
std::string metadataString = simulation.GetScriptInterface().StringifyJSON(&metadata, true);
// Write the saved game as zip file containing the various components
PIArchiveWriter archiveWriter = CreateArchiveWriter_Zip(tempSaveFileRealPath, false);
if (!archiveWriter)
WARN_RETURN(ERR::FAIL);
WARN_RETURN_STATUS_IF_ERR(archiveWriter->AddMemory((const u8*)metadataString.c_str(), metadataString.length(), now, "metadata.json"));
WARN_RETURN_STATUS_IF_ERR(archiveWriter->AddMemory((const u8*)simStateStream.str().c_str(), simStateStream.str().length(), now, "simulation.dat"));
archiveWriter.reset(); // close the file
WriteBuffer buffer;
CFileInfo tempSaveFile;
WARN_RETURN_STATUS_IF_ERR(GetFileInfo(tempSaveFileRealPath, &tempSaveFile));
buffer.Reserve(tempSaveFile.Size());
WARN_RETURN_STATUS_IF_ERR(io::Load(tempSaveFileRealPath, buffer.Data().get(), buffer.Size()));
WARN_RETURN_STATUS_IF_ERR(g_VFS->CreateFile(filename, buffer.Data(), buffer.Size()));
OsPath realPath;
WARN_RETURN_STATUS_IF_ERR(g_VFS->GetRealPath(filename, realPath));
LOGMESSAGERENDER(g_L10n.Translate("Saved game to '%s'"), realPath.string8());
debug_printf("Saved game to '%s'\n", realPath.string8().c_str());
return INFO::OK;
}
/**
* Helper class for retrieving data from saved game archives
*/
class CGameLoader
{
NONCOPYABLE(CGameLoader);
public:
/**
* @param scriptInterface the ScriptInterface used for loading metadata.
* @param[out] savedState serialized simulation state stored as string of bytes,
* loaded from simulation.dat inside the archive.
*
* Note: We use a different approach for returning the string and the metadata JS::Value.
* We use a pointer for the string to avoid copies (efficiency). We don't use this approach
* for the metadata because it would be error prone with rooting and the stack-based rooting
* types and confusing (a chain of pointers pointing to other pointers).
*/
CGameLoader(const ScriptInterface& scriptInterface, std::string* savedState) :
m_ScriptInterface(scriptInterface),
m_Metadata(scriptInterface.GetJSRuntime()),
m_SavedState(savedState)
{
}
static void ReadEntryCallback(const VfsPath& pathname, const CFileInfo& fileInfo, PIArchiveFile archiveFile, uintptr_t cbData)
{
((CGameLoader*)cbData)->ReadEntry(pathname, fileInfo, archiveFile);
}
void ReadEntry(const VfsPath& pathname, const CFileInfo& fileInfo, PIArchiveFile archiveFile)
{
JSContext* cx = m_ScriptInterface.GetContext();
JSAutoRequest rq(cx);
if (pathname == L"metadata.json")
{
std::string buffer;
buffer.resize(fileInfo.Size());
WARN_IF_ERR(archiveFile->Load("", DummySharedPtr((u8*)buffer.data()), buffer.size()));
m_ScriptInterface.ParseJSON(buffer, &m_Metadata);
}
else if (pathname == L"simulation.dat" && m_SavedState)
{
m_SavedState->resize(fileInfo.Size());
WARN_IF_ERR(archiveFile->Load("", DummySharedPtr((u8*)m_SavedState->data()), m_SavedState->size()));
}
}
JS::Value GetMetadata()
{
return m_Metadata.get();
}
private:
const ScriptInterface& m_ScriptInterface;
JS::PersistentRooted m_Metadata;
std::string* m_SavedState;
};
Status SavedGames::Load(const std::wstring& name, const ScriptInterface& scriptInterface, JS::MutableHandleValue metadata, std::string& savedState)
{
// Determine the filename to load
const VfsPath basename(L"saves/" + name);
const VfsPath filename = basename.ChangeExtension(L".0adsave");
// Don't crash just because file isn't found, this can happen if the file is deleted from the OS
if (!VfsFileExists(filename))
return ERR::FILE_NOT_FOUND;
OsPath realPath;
WARN_RETURN_STATUS_IF_ERR(g_VFS->GetRealPath(filename, realPath));
PIArchiveReader archiveReader = CreateArchiveReader_Zip(realPath);
if (!archiveReader)
WARN_RETURN(ERR::FAIL);
CGameLoader loader(scriptInterface, &savedState);
WARN_RETURN_STATUS_IF_ERR(archiveReader->ReadEntries(CGameLoader::ReadEntryCallback, (uintptr_t)&loader));
metadata.set(loader.GetMetadata());
return INFO::OK;
}
JS::Value SavedGames::GetSavedGames(const ScriptInterface& scriptInterface)
{
TIMER(L"GetSavedGames");
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
JS::RootedObject games(cx, JS_NewArrayObject(cx, 0));
Status err;
VfsPaths pathnames;
err = vfs::GetPathnames(g_VFS, "saves/", L"*.0adsave", pathnames);
WARN_IF_ERR(err);
for (size_t i = 0; i < pathnames.size(); ++i)
{
OsPath realPath;
err = g_VFS->GetRealPath(pathnames[i], realPath);
if (err < 0)
{
DEBUG_WARN_ERR(err);
continue; // skip this file
}
PIArchiveReader archiveReader = CreateArchiveReader_Zip(realPath);
if (!archiveReader)
{
// Triggered by e.g. the file being open in another program
LOGWARNING("Failed to read saved game '%s'", realPath.string8());
continue; // skip this file
}
CGameLoader loader(scriptInterface, NULL);
err = archiveReader->ReadEntries(CGameLoader::ReadEntryCallback, (uintptr_t)&loader);
if (err < 0)
{
DEBUG_WARN_ERR(err);
continue; // skip this file
}
JS::RootedValue metadata(cx, loader.GetMetadata());
JS::RootedValue game(cx);
scriptInterface.Eval("({})", &game);
scriptInterface.SetProperty(game, "id", pathnames[i].Basename());
scriptInterface.SetProperty(game, "metadata", metadata);
JS_SetElement(cx, games, i, game);
}
return JS::ObjectValue(*games);
}
bool SavedGames::DeleteSavedGame(const std::wstring& name)
{
const VfsPath basename(L"saves/" + name);
const VfsPath filename = basename.ChangeExtension(L".0adsave");
OsPath realpath;
// Make sure it exists in VFS and find its real path
if (!VfsFileExists(filename) || g_VFS->GetRealPath(filename, realpath) != INFO::OK)
return false; // Error
// Remove from VFS
if (g_VFS->RemoveFile(filename) != INFO::OK)
return false; // Error
// Delete actual file
if (wunlink(realpath) != 0)
return false; // Error
// Successfully deleted file
return true;
}
JS::Value SavedGames::GetEngineInfo(const ScriptInterface& scriptInterface)
{
JSContext* cx = scriptInterface.GetContext();
JSAutoRequest rq(cx);
JS::RootedValue metainfo(cx);
scriptInterface.Eval("({})", &metainfo);
- scriptInterface.SetProperty(metainfo, "version_major", SAVED_GAME_VERSION_MAJOR);
- scriptInterface.SetProperty(metainfo, "version_minor", SAVED_GAME_VERSION_MINOR);
scriptInterface.SetProperty(metainfo, "engine_version", std::string(engine_version));
scriptInterface.SetProperty(metainfo, "mods", g_modsLoaded);
scriptInterface.FreezeObject(metainfo, true);
return metainfo;
}