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; }