Index: ps/trunk/binaries/data/mods/mod/gui/common/mod.js =================================================================== --- ps/trunk/binaries/data/mods/mod/gui/common/mod.js (nonexistent) +++ ps/trunk/binaries/data/mods/mod/gui/common/mod.js (revision 21239) @@ -0,0 +1,32 @@ +/** + * Check the mod compatibility between the saved game to be loaded and the engine + */ +function hasSameMods(modsA, modsB) +{ + if (!modsA || !modsB || modsA.length != modsB.length) + return false; + // Mods must be loaded in the same order. 0: modname, 1: modversion + return modsA.every((mod, index) => [0, 1].every(i => mod[i] == modsB[index][i])); +} + +/** + * Converts a list of mods and their version into a human-readable string. + */ +function modsToString(mods) +{ + return mods.map(mod => sprintf(translateWithContext("Mod comparison", "%(mod)s (%(version)s)"), { + "mod": mod[0], + "version": mod[1] + })).join(translate(", ")); +} + +/** + * Convert the required and active mods and their version into a humanreadable translated string. + */ +function comparedModsString(required, active) +{ + return sprintf(translateWithContext("Mod comparison", "Required: %(mods)s"), + { "mods": modsToString(required) }) + "\n" + + sprintf(translateWithContext("Mod comparison", "Active: %(mods)s"), + { "mods": modsToString(active) }); +} Property changes on: ps/trunk/binaries/data/mods/mod/gui/common/mod.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property 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 21238) +++ ps/trunk/binaries/data/mods/public/gui/common/functions_utility_loadsave.js (revision 21239) @@ -1,106 +1,83 @@ function sortDecreasingDate(a, b) { return b.metadata.time - a.metadata.time; } function isCompatibleSavegame(metadata, engineInfo) { - return engineInfo && hasSameEngineVersion(metadata, engineInfo) & hasSameMods(metadata, engineInfo); + return engineInfo && hasSameEngineVersion(metadata, engineInfo) & hasSameMods(metadata.mods, engineInfo.mods); } 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 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 21238) +++ ps/trunk/binaries/data/mods/public/gui/loadgame/load.js (revision 21239) @@ -1,207 +1,202 @@ 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())) + let caption = sprintf(translate("Mods: %(mods)s"), { "mods": modsToString(metadata.mods) }); + if (!hasSameMods(metadata.mods, Engine.GetEngineInfo().mods)) 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 sameMods = hasSameMods(metadata.mods, engineInfo.mods); let sameEngineVersion = hasSameEngineVersion(metadata, engineInfo); if (sameEngineVersion && sameMods) { reallyLoadGame(gameId); return; } // Version not compatible ... ask for confirmation - let message = translate("This saved game may not be compatible:"); + let message = ""; 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."), { + message += sprintf(translate("This savegame needs 0 A.D. version %(requiredVersion)s, while you are running version %(currentVersion)s."), { "requiredVersion": metadata.engine_version, "currentVersion": engineInfo.engine_version - }); + }) + "\n"; else - message += "\n" + translate("It needs an older version of 0 A.D."); + message += translate("This savegame needs an older version of 0 A.D.") + "\n"; 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 += translate("This savegame needs a different sequence of mods:") + "\n" + + comparedModsString(metadata.mods, engineInfo.mods) + "\n"; } - message += "\n" + translate("Do you still want to proceed?"); + message += 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/binaries/data/mods/public/gui/replaymenu/replay_actions.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/replaymenu/replay_actions.js (revision 21238) +++ ps/trunk/binaries/data/mods/public/gui/replaymenu/replay_actions.js (revision 21239) @@ -1,204 +1,203 @@ /** * Creates the data for restoring selection, order and filters when returning to the replay menu. */ function createReplaySelectionData(selectedDirectory) { let replaySelection = Engine.GetGUIObjectByName("replaySelection"); let dateTimeFilter = Engine.GetGUIObjectByName("dateTimeFilter"); let playersFilter = Engine.GetGUIObjectByName("playersFilter"); let mapNameFilter = Engine.GetGUIObjectByName("mapNameFilter"); let mapSizeFilter = Engine.GetGUIObjectByName("mapSizeFilter"); let populationFilter = Engine.GetGUIObjectByName("populationFilter"); let durationFilter = Engine.GetGUIObjectByName("durationFilter"); let compatibilityFilter = Engine.GetGUIObjectByName("compatibilityFilter"); let singleplayerFilter = Engine.GetGUIObjectByName("singleplayerFilter"); let victoryConFilter = Engine.GetGUIObjectByName("victoryConditionFilter"); let ratedGamesFilter = Engine.GetGUIObjectByName("ratedGamesFilter"); return { "directory": selectedDirectory, "column": replaySelection.selected_column, "columnOrder": replaySelection.selected_column_order, "filters": { "date": dateTimeFilter.list_data[dateTimeFilter.selected], "playernames": playersFilter.caption, "mapName": mapNameFilter.list_data[mapNameFilter.selected], "mapSize": mapSizeFilter.list_data[mapSizeFilter.selected], "popCap": populationFilter.list_data[populationFilter.selected], "duration": durationFilter.list_data[durationFilter.selected], "compatibility": compatibilityFilter.checked, "singleplayer": singleplayerFilter.list_data[singleplayerFilter.selected], "victoryCondition": victoryConFilter.list_data[victoryConFilter.selected], "ratedGames": ratedGamesFilter.selected } }; } /** * Starts the selected visual replay, or shows an error message in case of incompatibility. */ function startReplay() { var selected = Engine.GetGUIObjectByName("replaySelection").selected; if (selected == -1) return; var replay = g_ReplaysFiltered[selected]; if (isReplayCompatible(replay)) reallyStartVisualReplay(replay.directory); else displayReplayCompatibilityError(replay); } /** * Attempts the visual replay, regardless of the compatibility. * * @param replayDirectory {string} */ function reallyStartVisualReplay(replayDirectory) { if (!Engine.StartVisualReplay(replayDirectory)) { warn('Replay "' + escapeText(Engine.GetReplayDirectoryName(replayDirectory)) + '" not found! Please click on reload cache.'); return; } Engine.SwitchGuiPage("page_loading.xml", { "attribs": Engine.GetReplayAttributes(replayDirectory), "isNetworked": false, "playerAssignments": { "local": { "name": singleplayerName(), "player": -1 } }, "savedGUIData": "", "isReplay": true, "replaySelectionData": createReplaySelectionData(replayDirectory) }); } /** * Shows an error message stating why the replay is not compatible. * * @param replay {Object} */ function displayReplayCompatibilityError(replay) { var errMsg; if (replayHasSameEngineVersion(replay)) { let gameMods = replay.attribs.mods || []; - errMsg = translate("You don't have the same mods active as the replay does.") + "\n"; - errMsg += sprintf(translate("Required: %(mods)s"), { "mods": gameMods.join(translate(", ")) }) + "\n"; - errMsg += sprintf(translate("Active: %(mods)s"), { "mods": g_EngineInfo.mods.join(translate(", ")) }); + errMsg = translate("This replay needs a different sequence of mods:") + "\n" + + comparedModsString(gameMods, g_EngineInfo.mods); } else { errMsg = translate("This replay is not compatible with your version of the game!") + "\n"; errMsg += sprintf(translate("Your version: %(version)s"), { "version": g_EngineInfo.engine_version }) + "\n"; errMsg += sprintf(translate("Required version: %(version)s"), { "version": replay.attribs.engine_version }); } messageBox(500, 200, errMsg, translate("Incompatible replay")); } /** * Opens the summary screen of the given replay, if its data was found in that directory. */ function showReplaySummary() { var selected = Engine.GetGUIObjectByName("replaySelection").selected; if (selected == -1) return; // Load summary screen data from the selected replay directory let simData = Engine.GetReplayMetadata(g_ReplaysFiltered[selected].directory); if (!simData) { messageBox(500, 200, translate("No summary data available."), translate("Error")); return; } Engine.SwitchGuiPage("page_summary.xml", { "sim": simData, "gui": { "dialog": false, "isReplay": true, "replayDirectory": g_ReplaysFiltered[selected].directory, "replaySelectionData": createReplaySelectionData(g_ReplaysFiltered[selected].directory) }, "selectedData": g_SummarySelectedData }); } function reloadCache() { let selected = Engine.GetGUIObjectByName("replaySelection").selected; loadReplays(selected > -1 ? createReplaySelectionData(g_ReplaysFiltered[selected].directory) : "", true); } /** * Callback. */ function deleteReplayButtonPressed() { if (!Engine.GetGUIObjectByName("deleteReplayButton").enabled) return; if (Engine.HotkeyIsPressed("session.savedgames.noconfirmation")) deleteReplayWithoutConfirmation(); else deleteReplay(); } /** * Shows a confirmation dialog and deletes the selected replay from the disk in case. */ function deleteReplay() { // Get selected replay var selected = Engine.GetGUIObjectByName("replaySelection").selected; if (selected == -1) return; var replay = g_ReplaysFiltered[selected]; messageBox( 500, 200, translate("Are you sure you want to delete this replay permanently?") + "\n" + escapeText(Engine.GetReplayDirectoryName(replay.directory)), translate("Delete replay"), [translate("No"), translate("Yes")], [null, function() { reallyDeleteReplay(replay.directory); }] ); } /** * Attempts to delete the selected replay from the disk. */ function deleteReplayWithoutConfirmation() { var selected = Engine.GetGUIObjectByName("replaySelection").selected; if (selected > -1) reallyDeleteReplay(g_ReplaysFiltered[selected].directory); } /** * Attempts to delete the given replay directory from the disk. * * @param replayDirectory {string} */ function reallyDeleteReplay(replayDirectory) { var replaySelection = Engine.GetGUIObjectByName("replaySelection"); var selectedIndex = replaySelection.selected; if (!Engine.DeleteReplay(replayDirectory)) error("Could not delete replay!"); // Refresh replay list init(); replaySelection.selected = Math.min(selectedIndex, g_ReplaysFiltered.length - 1); } Index: ps/trunk/binaries/data/mods/public/gui/replaymenu/replay_menu.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/replaymenu/replay_menu.js (revision 21238) +++ ps/trunk/binaries/data/mods/public/gui/replaymenu/replay_menu.js (revision 21239) @@ -1,364 +1,364 @@ /** * Used for checking replay compatibility. */ const g_EngineInfo = Engine.GetEngineInfo(); /** * Needed for formatPlayerInfo to show the player civs in the details. */ const g_CivData = loadCivData(false, false); /** * Used for creating the mapsize filter. */ const g_MapSizes = prepareForDropdown(g_Settings && g_Settings.MapSizes); /** * All replays found in the directory. */ var g_Replays = []; /** * List of replays after applying the display filter. */ var g_ReplaysFiltered = []; /** * Array of unique usernames of all replays. Used for autocompleting usernames. */ var g_Playernames = []; /** * Sorted list of unique maptitles. Used by mapfilter. */ var g_MapNames = []; /** * Sorted list of the victory conditions occuring in the replays */ var g_VictoryConditions = []; /** * Directory name of the currently selected replay. Used to restore the selection after changing filters. */ var g_SelectedReplayDirectory = ""; /** * Skip duplicate expensive GUI updates before init is complete. */ var g_ReplaysLoaded = false; /** * Remember last viewed summary panel and charts. */ var g_SummarySelectedData; /** * Initializes globals, loads replays and displays the list. */ function init(data) { if (!g_Settings) { Engine.SwitchGuiPage("page_pregame.xml"); return; } loadReplays(data && data.replaySelectionData, false); if (!g_Replays) { Engine.SwitchGuiPage("page_pregame.xml"); return; } initHotkeyTooltips(); displayReplayList(); if (data && data.summarySelectedData) g_SummarySelectedData = data.summarySelectedData; } /** * Store the list of replays loaded in C++ in g_Replays. * Check timestamp and compatibility and extract g_Playernames, g_MapNames, g_VictoryConditions. * Restore selected filters and item. * @param replaySelectionData - Currently selected filters and item to be restored after the loading. * @param compareFiles - If true, compares files briefly (which might be slow with optical harddrives), * otherwise blindly trusts the replay cache. */ function loadReplays(replaySelectionData, compareFiles) { g_Replays = Engine.GetReplays(compareFiles); if (!g_Replays) return; g_Playernames = []; for (let replay of g_Replays) { let nonAIPlayers = 0; // Check replay for compatibility replay.isCompatible = isReplayCompatible(replay); sanitizeGameAttributes(replay.attribs); // Extract map names if (g_MapNames.indexOf(replay.attribs.settings.Name) == -1 && replay.attribs.settings.Name != "") g_MapNames.push(replay.attribs.settings.Name); // Extract victory conditions if (replay.attribs.settings.GameType && g_VictoryConditions.indexOf(replay.attribs.settings.GameType) == -1) g_VictoryConditions.push(replay.attribs.settings.GameType); // Extract playernames for (let playerData of replay.attribs.settings.PlayerData) { if (!playerData || playerData.AI) continue; // Remove rating from nick let playername = playerData.Name; let ratingStart = playername.indexOf(" ("); if (ratingStart != -1) playername = playername.substr(0, ratingStart); if (g_Playernames.indexOf(playername) == -1) g_Playernames.push(playername); ++nonAIPlayers; } replay.isMultiplayer = nonAIPlayers > 1; replay.isRated = nonAIPlayers == 2 && replay.attribs.settings.PlayerData.length == 2 && replay.attribs.settings.RatingEnabled; } g_MapNames.sort(); g_VictoryConditions.sort(); // Reload filters (since they depend on g_Replays and its derivatives) initFilters(replaySelectionData && replaySelectionData.filters); // Restore user selection if (replaySelectionData) { if (replaySelectionData.directory) g_SelectedReplayDirectory = replaySelectionData.directory; let replaySelection = Engine.GetGUIObjectByName("replaySelection"); if (replaySelectionData.column) replaySelection.selected_column = replaySelectionData.column; if (replaySelectionData.columnOrder) replaySelection.selected_column_order = replaySelectionData.columnOrder; } g_ReplaysLoaded = true; } /** * We may encounter malformed replays. */ function sanitizeGameAttributes(attribs) { if (!attribs.settings) attribs.settings = {}; if (!attribs.settings.Size) attribs.settings.Size = -1; if (!attribs.settings.Name) attribs.settings.Name = ""; if (!attribs.settings.PlayerData) attribs.settings.PlayerData = []; if (!attribs.settings.PopulationCap) attribs.settings.PopulationCap = 300; if (!attribs.settings.mapType) attribs.settings.mapType = "skirmish"; if (!attribs.settings.GameType) attribs.settings.GameType = "conquest"; // Remove gaia if (attribs.settings.PlayerData.length && attribs.settings.PlayerData[0] == null) attribs.settings.PlayerData.shift(); attribs.settings.PlayerData.forEach((pData, index) => { if (!pData.Name) pData.Name = ""; }); } function initHotkeyTooltips() { Engine.GetGUIObjectByName("playersFilter").tooltip = translate("Filter replays by typing one or more, partial or complete playernames.") + " " + colorizeAutocompleteHotkey(); Engine.GetGUIObjectByName("deleteReplayButton").tooltip = deleteTooltip(); } /** * Filter g_Replays, fill the GUI list with that data and show the description of the current replay. */ function displayReplayList() { if (!g_ReplaysLoaded) return; // Remember previously selected replay var replaySelection = Engine.GetGUIObjectByName("replaySelection"); if (replaySelection.selected != -1) g_SelectedReplayDirectory = g_ReplaysFiltered[replaySelection.selected].directory; filterReplays(); var list = g_ReplaysFiltered.map(replay => { let works = replay.isCompatible; return { "directories": replay.directory, "months": compatibilityColor(getReplayDateTime(replay), works), "popCaps": compatibilityColor(translatePopulationCapacity(replay.attribs.settings.PopulationCap), works), "mapNames": compatibilityColor(getReplayMapName(replay), works), "mapSizes": compatibilityColor(translateMapSize(replay.attribs.settings.Size), works), "durations": compatibilityColor(getReplayDuration(replay), works), "playerNames": compatibilityColor(getReplayPlayernames(replay), works) }; }); if (list.length) list = prepareForDropdown(list); // Push to GUI replaySelection.selected = -1; replaySelection.list_months = list.months || []; replaySelection.list_players = list.playerNames || []; replaySelection.list_mapName = list.mapNames || []; replaySelection.list_mapSize = list.mapSizes || []; replaySelection.list_popCapacity = list.popCaps || []; replaySelection.list_duration = list.durations || []; // Change these last, otherwise crash replaySelection.list = list.directories || []; replaySelection.list_data = list.directories || []; replaySelection.selected = replaySelection.list.findIndex(directory => directory == g_SelectedReplayDirectory); displayReplayDetails(); } /** * Shows preview image, description and player text in the right panel. */ function displayReplayDetails() { let selected = Engine.GetGUIObjectByName("replaySelection").selected; let replaySelected = selected > -1; Engine.GetGUIObjectByName("replayInfo").hidden = !replaySelected; Engine.GetGUIObjectByName("replayInfoEmpty").hidden = replaySelected; Engine.GetGUIObjectByName("startReplayButton").enabled = replaySelected; Engine.GetGUIObjectByName("deleteReplayButton").enabled = replaySelected; Engine.GetGUIObjectByName("replayFilename").hidden = !replaySelected; Engine.GetGUIObjectByName("summaryButton").hidden = true; if (!replaySelected) return; let replay = g_ReplaysFiltered[selected]; Engine.GetGUIObjectByName("sgMapName").caption = translate(replay.attribs.settings.Name); Engine.GetGUIObjectByName("sgMapSize").caption = translateMapSize(replay.attribs.settings.Size); Engine.GetGUIObjectByName("sgMapType").caption = translateMapType(replay.attribs.settings.mapType); Engine.GetGUIObjectByName("sgVictory").caption = translateVictoryCondition(replay.attribs.settings.GameType); Engine.GetGUIObjectByName("sgNbPlayers").caption = sprintf(translate("Players: %(numberOfPlayers)s"), { "numberOfPlayers": replay.attribs.settings.PlayerData.length }); Engine.GetGUIObjectByName("replayFilename").caption = Engine.GetReplayDirectoryName(replay.directory); let metadata = Engine.GetReplayMetadata(replay.directory); Engine.GetGUIObjectByName("sgPlayersNames").caption = formatPlayerInfo( replay.attribs.settings.PlayerData, Engine.GetGUIObjectByName("showSpoiler").checked && metadata && metadata.playerStates && metadata.playerStates.map(pState => pState.state) ); let mapData = getMapDescriptionAndPreview(replay.attribs.settings.mapType, replay.attribs.map); Engine.GetGUIObjectByName("sgMapDescription").caption = mapData.description; Engine.GetGUIObjectByName("summaryButton").hidden = !Engine.HasReplayMetadata(replay.directory); setMapPreviewImage("sgMapPreview", mapData.preview); } /** * Returns a human-readable version of the replay date. */ function getReplayDateTime(replay) { return Engine.FormatMillisecondsIntoDateStringLocal(replay.attribs.timestamp * 1000, translate("yyyy-MM-dd HH:mm")); } /** * Returns a human-readable list of the playernames of that replay. * * @returns {string} */ function getReplayPlayernames(replay) { return replay.attribs.settings.PlayerData.map(pData => pData.Name).join(", "); } /** * Returns the name of the map of the given replay. * * @returns {string} */ function getReplayMapName(replay) { return translate(replay.attribs.settings.Name); } /** * Returns the month of the given replay in the format "yyyy-MM". * * @returns {string} */ function getReplayMonth(replay) { return Engine.FormatMillisecondsIntoDateStringLocal(replay.attribs.timestamp * 1000, translate("yyyy-MM")); } /** * Returns a human-readable version of the time when the replay started. * * @returns {string} */ function getReplayDuration(replay) { return timeToString(replay.duration * 1000); } /** * True if we can start the given replay with the currently loaded mods. */ function isReplayCompatible(replay) { - return replayHasSameEngineVersion(replay) && hasSameMods(replay.attribs, g_EngineInfo); + return replayHasSameEngineVersion(replay) && hasSameMods(replay.attribs.mods, g_EngineInfo.mods); } /** * True if we can start the given replay with the currently loaded mods. */ function replayHasSameEngineVersion(replay) { return replay.attribs.engine_version && replay.attribs.engine_version == g_EngineInfo.engine_version; } Index: ps/trunk/source/ps/Mod.cpp =================================================================== --- ps/trunk/source/ps/Mod.cpp (revision 21238) +++ ps/trunk/source/ps/Mod.cpp (revision 21239) @@ -1,101 +1,126 @@ -/* Copyright (C) 2017 Wildfire Games. +/* Copyright (C) 2018 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 "ps/Mod.h" #include #include "lib/file/file_system.h" #include "lib/file/vfs/vfs.h" #include "lib/utf8.h" #include "ps/Filesystem.h" #include "ps/GameSetup/GameSetup.h" #include "ps/GameSetup/Paths.h" std::vector g_modsLoaded; CmdLineArgs g_args; JS::Value Mod::GetAvailableMods(const ScriptInterface& scriptInterface) { JSContext* cx = scriptInterface.GetContext(); JSAutoRequest rq(cx); JS::RootedObject obj(cx, JS_NewPlainObject(cx)); const Paths paths(g_args); // loop over all possible paths OsPath modPath = paths.RData()/"mods"; OsPath modUserPath = paths.UserData()/"mods"; DirectoryNames modDirs; DirectoryNames modDirsUser; GetDirectoryEntries(modPath, NULL, &modDirs); // Sort modDirs so that we can do a fast lookup below std::sort(modDirs.begin(), modDirs.end()); PIVFS vfs = CreateVfs(); for (DirectoryNames::iterator iter = modDirs.begin(); iter != modDirs.end(); ++iter) { vfs->Clear(); if (vfs->Mount(L"", modPath / *iter, VFS_MOUNT_MUST_EXIST) < 0) continue; CVFSFile modinfo; if (modinfo.Load(vfs, L"mod.json", false) != PSRETURN_OK) continue; JS::RootedValue json(cx); if (!scriptInterface.ParseJSON(modinfo.GetAsString(), &json)) continue; // Valid mod, add it to our structure JS_SetProperty(cx, obj, utf8_from_wstring(iter->string()).c_str(), json); } GetDirectoryEntries(modUserPath, NULL, &modDirsUser); bool dev = InDevelopmentCopy(); for (DirectoryNames::iterator iter = modDirsUser.begin(); iter != modDirsUser.end(); ++iter) { // If we are in a dev copy we do not mount mods in the user mod folder that // are already present in the mod folder, thus we skip those here. if (dev && std::binary_search(modDirs.begin(), modDirs.end(), *iter)) continue; vfs->Clear(); if (vfs->Mount(L"", modUserPath / *iter, VFS_MOUNT_MUST_EXIST) < 0) continue; CVFSFile modinfo; if (modinfo.Load(vfs, L"mod.json", false) != PSRETURN_OK) continue; JS::RootedValue json(cx); if (!scriptInterface.ParseJSON(modinfo.GetAsString(), &json)) continue; // Valid mod, add it to our structure JS_SetProperty(cx, obj, utf8_from_wstring(iter->string()).c_str(), json); } return JS::ObjectValue(*obj); } + +JS::Value Mod::GetLoadedModsWithVersions(const ScriptInterface& scriptInterface) +{ + JSContext* cx = scriptInterface.GetContext(); + JSAutoRequest rq(cx); + + JS::RootedValue availableMods(cx, GetAvailableMods(scriptInterface)); + + JS::RootedValue ret(cx, JS::ObjectValue(*JS_NewArrayObject(cx, 0))); + + // Index of the created array + size_t j = 0; + for (size_t i = 0; i < g_modsLoaded.size(); ++i) + { + // Ignore user and mod mod as they are irrelevant for compatibility checks + if (g_modsLoaded[i] == "mod" || g_modsLoaded[i] == "user") + continue; + CStr version; + JS::RootedValue modData(cx); + if (scriptInterface.GetProperty(availableMods, g_modsLoaded[i].c_str(), &modData)) + scriptInterface.GetProperty(modData, "version", version); + scriptInterface.SetPropertyInt(ret, j++, std::vector{g_modsLoaded[i], version}); + } + return ret; +} Index: ps/trunk/source/ps/Mod.h =================================================================== --- ps/trunk/source/ps/Mod.h (revision 21238) +++ ps/trunk/source/ps/Mod.h (revision 21239) @@ -1,32 +1,33 @@ -/* Copyright (C) 2017 Wildfire Games. +/* Copyright (C) 2018 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 . */ #ifndef INCLUDED_MOD #define INCLUDED_MOD #include "ps/CStr.h" #include "ps/GameSetup/CmdLineArgs.h" #include "scriptinterface/ScriptInterface.h" extern std::vector g_modsLoaded; extern CmdLineArgs g_args; namespace Mod { JS::Value GetAvailableMods(const ScriptInterface& scriptInterface); + JS::Value GetLoadedModsWithVersions(const ScriptInterface& scriptInterface); } #endif // INCLUDED_MOD Index: ps/trunk/source/ps/Replay.cpp =================================================================== --- ps/trunk/source/ps/Replay.cpp (revision 21238) +++ ps/trunk/source/ps/Replay.cpp (revision 21239) @@ -1,264 +1,268 @@ -/* Copyright (C) 2017 Wildfire Games. +/* Copyright (C) 2018 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 "Replay.h" #include "graphics/TerrainTextureManager.h" #include "lib/timer.h" #include "lib/file/file_system.h" #include "lib/res/h_mgr.h" #include "lib/tex/tex.h" #include "ps/Game.h" #include "ps/CLogger.h" #include "ps/Loader.h" #include "ps/Mod.h" #include "ps/Profile.h" #include "ps/ProfileViewer.h" #include "ps/Pyrogenesis.h" +#include "ps/Mod.h" #include "ps/Util.h" #include "ps/VisualReplay.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/ScriptStats.h" #include "simulation2/Simulation2.h" #include "simulation2/helpers/SimulationCommand.h" #include #include CReplayLogger::CReplayLogger(const ScriptInterface& scriptInterface) : m_ScriptInterface(scriptInterface), m_Stream(NULL) { } CReplayLogger::~CReplayLogger() { delete m_Stream; } void CReplayLogger::StartGame(JS::MutableHandleValue attribs) { + JSContext* cx = m_ScriptInterface.GetContext(); + JSAutoRequest rq(cx); + // Add timestamp, since the file-modification-date can change m_ScriptInterface.SetProperty(attribs, "timestamp", (double)std::time(nullptr)); // Add engine version and currently loaded mods for sanity checks when replaying m_ScriptInterface.SetProperty(attribs, "engine_version", CStr(engine_version)); - m_ScriptInterface.SetProperty(attribs, "mods", g_modsLoaded); + m_ScriptInterface.SetProperty(attribs, "mods", JS::RootedValue(cx, Mod::GetLoadedModsWithVersions(m_ScriptInterface))); m_Directory = createDateIndexSubdirectory(VisualReplay::GetDirectoryName()); debug_printf("Writing replay to %s\n", m_Directory.string8().c_str()); m_Stream = new std::ofstream(OsString(m_Directory / L"commands.txt").c_str(), std::ofstream::out | std::ofstream::trunc); *m_Stream << "start " << m_ScriptInterface.StringifyJSON(attribs, false) << "\n"; } void CReplayLogger::Turn(u32 n, u32 turnLength, std::vector& commands) { JSContext* cx = m_ScriptInterface.GetContext(); JSAutoRequest rq(cx); *m_Stream << "turn " << n << " " << turnLength << "\n"; for (SimulationCommand& command : commands) *m_Stream << "cmd " << command.player << " " << m_ScriptInterface.StringifyJSON(&command.data, false) << "\n"; *m_Stream << "end\n"; m_Stream->flush(); } void CReplayLogger::Hash(const std::string& hash, bool quick) { if (quick) *m_Stream << "hash-quick " << Hexify(hash) << "\n"; else *m_Stream << "hash " << Hexify(hash) << "\n"; } OsPath CReplayLogger::GetDirectory() const { return m_Directory; } //////////////////////////////////////////////////////////////// CReplayPlayer::CReplayPlayer() : m_Stream(NULL) { } CReplayPlayer::~CReplayPlayer() { delete m_Stream; } void CReplayPlayer::Load(const OsPath& path) { ENSURE(!m_Stream); m_Stream = new std::ifstream(OsString(path).c_str()); ENSURE(m_Stream->good()); } void CReplayPlayer::Replay(bool serializationtest, int rejointestturn, bool ooslog) { ENSURE(m_Stream); new CProfileViewer; new CProfileManager; g_ScriptStatsTable = new CScriptStatsTable; g_ProfileViewer.AddRootTable(g_ScriptStatsTable); const int runtimeSize = 384 * 1024 * 1024; const int heapGrowthBytesGCTrigger = 20 * 1024 * 1024; g_ScriptRuntime = ScriptInterface::CreateRuntime(shared_ptr(), runtimeSize, heapGrowthBytesGCTrigger); g_Game = new CGame(true, false); if (serializationtest) g_Game->GetSimulation2()->EnableSerializationTest(); if (rejointestturn > 0) g_Game->GetSimulation2()->EnableRejoinTest(rejointestturn); if (ooslog) g_Game->GetSimulation2()->EnableOOSLog(); // Need some stuff for terrain movement costs: // (TODO: this ought to be independent of any graphics code) new CTerrainTextureManager; g_TexMan.LoadTerrainTextures(); // Initialise h_mgr so it doesn't crash when emitting sounds h_mgr_init(); std::vector commands; u32 turn = 0; u32 turnLength = 0; { JSContext* cx = g_Game->GetSimulation2()->GetScriptInterface().GetContext(); JSAutoRequest rq(cx); std::string type; while ((*m_Stream >> type).good()) { if (type == "start") { std::string line; std::getline(*m_Stream, line); JS::RootedValue attribs(cx); ENSURE(g_Game->GetSimulation2()->GetScriptInterface().ParseJSON(line, &attribs)); std::vector replayModList; g_Game->GetSimulation2()->GetScriptInterface().GetProperty(attribs, "mods", replayModList); for (const CStr& mod : replayModList) if (mod != "user" && std::find(g_modsLoaded.begin(), g_modsLoaded.end(), mod) == g_modsLoaded.end()) LOGWARNING("The mod '%s' is required by the replay file, but wasn't passed as an argument!", mod); for (const CStr& mod : g_modsLoaded) if (mod != "user" && std::find(replayModList.begin(), replayModList.end(), mod) == replayModList.end()) LOGWARNING("The mod '%s' wasn't used when creating this replay file, but was passed as an argument!", mod); g_Game->StartGame(&attribs, ""); // TODO: Non progressive load can fail - need a decent way to handle this LDR_NonprogressiveLoad(); PSRETURN ret = g_Game->ReallyStartGame(); ENSURE(ret == PSRETURN_OK); } else if (type == "turn") { *m_Stream >> turn >> turnLength; debug_printf("Turn %u (%u)...\n", turn, turnLength); } else if (type == "cmd") { player_id_t player; *m_Stream >> player; std::string line; std::getline(*m_Stream, line); JS::RootedValue data(cx); g_Game->GetSimulation2()->GetScriptInterface().ParseJSON(line, &data); g_Game->GetSimulation2()->GetScriptInterface().FreezeObject(data, true); commands.emplace_back(SimulationCommand(player, cx, data)); } else if (type == "hash" || type == "hash-quick") { std::string replayHash; *m_Stream >> replayHash; bool quick = (type == "hash-quick"); if (turn % 100 == 0) { std::string hash; bool ok = g_Game->GetSimulation2()->ComputeStateHash(hash, quick); ENSURE(ok); std::string hexHash = Hexify(hash); if (hexHash == replayHash) debug_printf("hash ok (%s)\n", hexHash.c_str()); else debug_printf("HASH MISMATCH (%s != %s)\n", hexHash.c_str(), replayHash.c_str()); } } else if (type == "end") { { g_Profiler2.RecordFrameStart(); PROFILE2("frame"); g_Profiler2.IncrementFrameNumber(); PROFILE2_ATTR("%d", g_Profiler2.GetFrameNumber()); g_Game->GetSimulation2()->Update(turnLength, commands); commands.clear(); } g_Profiler.Frame(); if (turn % 20 == 0) g_ProfileViewer.SaveToFile(); } else { debug_printf("Unrecognised replay token %s\n", type.c_str()); } } } SAFE_DELETE(m_Stream); g_Profiler2.SaveToFile(); std::string hash; bool ok = g_Game->GetSimulation2()->ComputeStateHash(hash, false); ENSURE(ok); debug_printf("# Final state: %s\n", Hexify(hash).c_str()); timer_DisplayClientTotals(); SAFE_DELETE(g_Game); // Must be explicitly destructed here to avoid callbacks from the JSAPI trying to use g_Profiler2 when // it's already destructed. g_ScriptRuntime.reset(); // Clean up delete &g_TexMan; delete &g_Profiler; delete &g_ProfileViewer; SAFE_DELETE(g_ScriptStatsTable); } Index: ps/trunk/source/ps/SavedGame.cpp =================================================================== --- ps/trunk/source/ps/SavedGame.cpp (revision 21238) +++ ps/trunk/source/ps/SavedGame.cpp (revision 21239) @@ -1,304 +1,304 @@ -/* Copyright (C) 2017 Wildfire Games. +/* Copyright (C) 2018 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" // 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, "engine_version", std::string(engine_version)); - simulation.GetScriptInterface().SetProperty(metadata, "mods", g_modsLoaded); + simulation.GetScriptInterface().SetProperty(metadata, "mods", JS::RootedValue(cx, Mod::GetLoadedModsWithVersions(simulation.GetScriptInterface()))); 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, "engine_version", std::string(engine_version)); - scriptInterface.SetProperty(metainfo, "mods", g_modsLoaded); + scriptInterface.SetProperty(metainfo, "mods", JS::RootedValue(cx, Mod::GetLoadedModsWithVersions(scriptInterface))); scriptInterface.FreezeObject(metainfo, true); return metainfo; }