Index: ps/trunk/binaries/data/mods/mod/gui/common/mod.js =================================================================== --- ps/trunk/binaries/data/mods/mod/gui/common/mod.js (revision 25633) +++ ps/trunk/binaries/data/mods/mod/gui/common/mod.js (revision 25634) @@ -1,32 +1,43 @@ /** - * Check the mod compatibility between the saved game to be loaded and the engine + * Check the mod compatibility between the saved game to be loaded and the engine. + * This is a wrapper around an engine function to allow mods to to fancier or specific things. */ function hasSameMods(modsA, modsB) { - if (!modsA || !modsB || modsA.length != modsB.length) + if (!modsA || !modsB) 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])); + return Engine.AreModsPlayCompatible(modsA, modsB); +} + +/** + * Print the shorthand identifier of a mod. + */ +function modToString(mod) +{ + // Skip version for play-compatible mods. + if (mod.ignoreInCompatibilityChecks) + return mod.name; + return sprintf(translateWithContext("Mod comparison", "%(mod)s (%(version)s)"), { + "mod": mod.name, + "version": mod.version + }); } /** * 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(", ")); + return mods.map(mod => modToString(mod)).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) }); + { "mods": modsToString(required) } + ) + "\n" + sprintf(translateWithContext("Mod comparison", "Active: %(mods)s"), + { "mods": modsToString(active) }); } Index: ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js (revision 25633) +++ ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.js (revision 25634) @@ -1,144 +1,164 @@ /** * The purpose of this class is to display information about the selected game. */ class GameDetails { constructor(dialog, gameList, mapCache) { this.mapCache = mapCache; this.playernameArgs = {}; this.playerCountArgs = {}; this.gameStartArgs = {}; this.lastGame = {}; this.gameDetails = Engine.GetGUIObjectByName("gameDetails"); this.sgMapName = Engine.GetGUIObjectByName("sgMapName"); this.sgGame = Engine.GetGUIObjectByName("sgGame"); - this.sgPlayersNames = Engine.GetGUIObjectByName("sgPlayersNames"); + this.sgPlayersAndMods = Engine.GetGUIObjectByName("sgPlayersAndMods"); this.sgMapSize = Engine.GetGUIObjectByName("sgMapSize"); this.sgMapPreview = Engine.GetGUIObjectByName("sgMapPreview"); this.sgMapDescription = Engine.GetGUIObjectByName("sgMapDescription"); gameList.registerSelectionChangeHandler(this.onGameListSelectionChange.bind(this)); this.resize(dialog); } resize(dialog) { let bottom = Engine.GetGUIObjectByName(dialog ? "leaveButton" : "joinButton").size.top - 5; let size = this.gameDetails.size; size.bottom = bottom; this.gameDetails.size = size; } /** * Populate the game info area with information on the current game selection. */ onGameListSelectionChange(game) { this.gameDetails.hidden = !game; if (!game) return; Engine.ProfileStart("GameDetails"); let stanza = game.stanza; let displayData = game.displayData; if (stanza.mapType != this.lastGame.mapType || stanza.mapName != this.lastGame.mapName) { this.sgMapName.caption = displayData.mapName; if (this.mapCache.checkIfExists(stanza.mapType, stanza.mapName)) this.sgMapPreview.sprite = this.mapCache.getMapPreview(stanza.mapType, stanza.mapName); else this.sgMapPreview.sprite = this.mapCache.getMapPreview(stanza.mapType); } { - let txt; - if (game.isCompatible) - txt = - setStringTags(this.VictoryConditionsFormat, this.CaptionTags) + " " + - (stanza.victoryConditions ? - stanza.victoryConditions.split(",").map(translateVictoryCondition).join(this.Comma) : - translateWithContext("victory condition", "Endless Game")); - else - txt = - setStringTags(this.ModsFormat, this.CaptionTags) + " " + - escapeText(modsToString(game.mods, Engine.GetEngineInfo().mods)); + let txt = setStringTags(this.VictoryConditionsFormat, this.CaptionTags) + " " + + (stanza.victoryConditions ? + stanza.victoryConditions.split(",").map(translateVictoryCondition).join(this.Comma) : + translateWithContext("victory condition", "Endless Game")); txt += "\n" + setStringTags(this.MapTypeFormat, this.CaptionTags) + " " + displayData.mapType + "\n" + setStringTags(this.MapSizeFormat, this.CaptionTags) + " " + displayData.mapSize + "\n" + setStringTags(this.MapDescriptionFormat, this.CaptionTags) + " " + displayData.mapDescription; this.sgMapDescription.caption = txt; } { let txt = escapeText(stanza.name); this.playernameArgs.playername = escapeText(stanza.hostUsername); txt += "\n" + sprintf(this.HostFormat, this.playernameArgs); - this.playerCountArgs.current = escapeText(stanza.nbp); - this.playerCountArgs.total = escapeText(stanza.maxnbp); - txt += "\n" + sprintf(this.PlayerCountFormat, this.playerCountArgs); - if (stanza.startTime) { this.gameStartArgs.time = Engine.FormatMillisecondsIntoDateStringLocal(+stanza.startTime * 1000, this.TimeFormat); txt += "\n" + sprintf(this.GameStartFormat, this.gameStartArgs); } this.sgGame.caption = txt; - } - { - let textHeight = this.sgGame.getTextSize().height; + const textHeight = this.sgGame.getTextSize().height; - let sgGameSize = this.sgGame.size; + const sgGameSize = this.sgGame.size; sgGameSize.bottom = textHeight; this.sgGame.size = sgGameSize; - - let sgPlayersNamesSize = this.sgPlayersNames.size; - sgPlayersNamesSize.top = textHeight + 5; - this.sgPlayersNames.size = sgPlayersNamesSize; } - this.sgPlayersNames.caption = formatPlayerInfo(game.players); + { + // Player information + this.playerCountArgs.current = escapeText(stanza.nbp); + this.playerCountArgs.total = escapeText(stanza.maxnbp); + let txt = sprintf(this.PlayerCountFormat, this.playerCountArgs); + txt = setStringTags(txt, this.CaptionTags); + + txt += "\n" + formatPlayerInfo(game.players); + + // Mod information + txt += "\n\n" + setStringTags(this.ModsFormat, this.CaptionTags); + if (!game.isCompatible) + txt = setStringTags(coloredText(txt, "red"), { + "tooltip": sprintf(translate("You have some incompatible mods:\n%(details)s"), { + "details": comparedModsString(game.mods, Engine.GetEngineInfo().mods), + }), + }); + + const sortedMods = game.mods; + sortedMods.sort((a, b) => a.ignoreInCompatibilityChecks - b.ignoreInCompatibilityChecks); + for (const mod of sortedMods) + { + let modStr = escapeText(modToString(mod)); + if (mod.ignoreInCompatibilityChecks) + modStr = setStringTags(coloredText(modStr, "180 180 180"), { + "tooltip": translate("This mod does not affect MP compatibility"), + }); + txt += "\n" + modStr; + } + + this.sgPlayersAndMods.caption = txt; + + // Resize the box + const textHeight = this.sgPlayersAndMods.getTextSize().height; + const size = this.sgPlayersAndMods.size; + size.top = this.sgGame.size.bottom + 5; + this.sgPlayersAndMods.size = size; + } this.lastGame = game; Engine.ProfileStop(); } } GameDetails.prototype.HostFormat = translate("Host: %(playername)s"); GameDetails.prototype.PlayerCountFormat = translate("Players: %(current)s/%(total)s"); GameDetails.prototype.VictoryConditionsFormat = translate("Victory Conditions:"); // Translation: Comma used to concatenate victory conditions GameDetails.prototype.Comma = translate(", "); GameDetails.prototype.ModsFormat = translate("Mods:"); // Translation: %(time)s is the hour and minute here. GameDetails.prototype.GameStartFormat = translate("Game started at %(time)s"); GameDetails.prototype.TimeFormat = translate("HH:mm"); GameDetails.prototype.MapTypeFormat = translate("Map Type:"); GameDetails.prototype.MapSizeFormat = translate("Map Size:"); GameDetails.prototype.MapDescriptionFormat = translate("Map Description:"); GameDetails.prototype.CaptionTags = { "font": "sans-bold-14" }; Index: ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml =================================================================== --- ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml (revision 25633) +++ ps/trunk/binaries/data/mods/public/gui/lobby/LobbyPage/GameDetails.xml (revision 25634) @@ -1,25 +1,25 @@ - - + + Index: ps/trunk/source/ps/Mod.cpp =================================================================== --- ps/trunk/source/ps/Mod.cpp (revision 25633) +++ ps/trunk/source/ps/Mod.cpp (revision 25634) @@ -1,374 +1,379 @@ /* Copyright (C) 2021 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 "i18n/L10n.h" #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" #include "ps/Profiler2.h" -#include "ps/Pyrogenesis.h" #include "scriptinterface/JSON.h" #include "scriptinterface/Object.h" #include "scriptinterface/ScriptExceptions.h" #include "scriptinterface/ScriptInterface.h" #include #include #include #include #include #include namespace { /** * Global instance of Mod, always exists. */ Mod g_ModInstance; bool LoadModJSON(const PIVFS& vfs, OsPath modsPath, OsPath mod, std::string& text) { // Attempt to open mod.json first. std::ifstream modjson; modjson.open((modsPath / mod / L"mod.json").string8()); if (!modjson.is_open()) { modjson.close(); // Fallback: open the archive and read mod.json there. // This can take in the hundreds of milliseconds with large mods. vfs->Clear(); if (vfs->Mount(L"", modsPath / mod / "", VFS_MOUNT_MUST_EXIST, VFS_MIN_PRIORITY) < 0) return false; CVFSFile modinfo; if (modinfo.Load(vfs, L"mod.json", false) != PSRETURN_OK) return false; text = modinfo.GetAsString(); // Attempt to write the mod.json file so we'll take the fast path next time. std::ofstream out_mod_json((modsPath / mod / L"mod.json").string8()); if (out_mod_json.good()) { out_mod_json << text; out_mod_json.close(); } else { // Print a warning - we'll keep trying, which could have adverse effects. if (L10n::IsInitialised()) LOGWARNING(g_L10n.Translate("Could not write external mod.json for zipped mod '%s'. The mod should be reinstalled."), mod.string8()); else LOGWARNING("Could not write external mod.json for zipped mod '%s'. The mod should be reinstalled.", mod.string8()); } return true; } else { std::stringstream buffer; buffer << modjson.rdbuf(); text = buffer.str(); return true; } } bool ParseModJSON(const ScriptRequest& rq, const PIVFS& vfs, OsPath modsPath, OsPath mod, Mod::ModData& data) { std::string text; if (!LoadModJSON(vfs, modsPath, mod, text)) return false; JS::RootedValue json(rq.cx); if (!Script::ParseJSON(rq, text, &json)) return false; + Script::FromJSVal(rq, json, data); + + // Complete - FromJSVal won't convert everything. data.m_Pathname = utf8_from_wstring(mod.string()); data.m_Text = text; - - if (!Script::GetProperty(rq, json, "version", data.m_Version)) - return false; - if (!Script::GetProperty(rq, json, "name", data.m_Name)) - return false; if (!Script::GetProperty(rq, json, "dependencies", data.m_Dependencies)) return false; return true; } } // anonymous namespace Mod& Mod::Instance() { return g_ModInstance; } +const std::vector& Mod::GetEnabledMods() const +{ + return m_EnabledMods; +} + +const std::vector& Mod::GetIncompatibleMods() const +{ + return m_IncompatibleMods; +} + +const std::vector& Mod::GetAvailableMods() const +{ + return m_AvailableMods; +} + +bool Mod::EnableMods(const ScriptInterface& scriptInterface, const std::vector& mods, const bool addPublic) +{ + m_IncompatibleMods.clear(); + m_EnabledMods.clear(); + + std::unordered_map counts; + for (const CStr& mod : mods) + { + // Ignore duplicates. + if (counts.try_emplace(mod, 0).first->second++ > 0) + continue; + m_EnabledMods.emplace_back(mod); + } + + if (addPublic && counts["public"] == 0) + m_EnabledMods.insert(m_EnabledMods.begin(), "public"); + + if (counts["mod"] == 0) + m_EnabledMods.insert(m_EnabledMods.begin(), "mod"); + + UpdateAvailableMods(scriptInterface); + + m_IncompatibleMods = CheckForIncompatibleMods(m_EnabledMods); + + for (const CStr& mod : m_IncompatibleMods) + m_EnabledMods.erase(std::find(m_EnabledMods.begin(), m_EnabledMods.end(), mod)); + + return m_IncompatibleMods.empty(); +} + +const Mod::ModData* Mod::GetModData(const CStr& mod) const +{ + std::vector::const_iterator it = std::find_if(m_AvailableMods.begin(), m_AvailableMods.end(), + [&mod](const ModData& modData) { return modData.m_Pathname == mod; }); + if (it == m_AvailableMods.end()) + return nullptr; + return std::addressof(*it); +} + +const std::vector Mod::GetEnabledModsData() const +{ + std::vector loadedMods; + for (const CStr& mod : m_EnabledMods) + { + if (mod == "mod" || mod == "user") + continue; + + const ModData* data = GetModData(mod); + + // This ought be impossible, but let's handle it anyways since it's not a reason to crash. + if (!data) + { + LOGERROR("Unavailable mod '%s' was enabled.", mod); + continue; + } + + loadedMods.emplace_back(data); + } + return loadedMods; +} + +bool Mod::AreModsPlayCompatible(const std::vector& modsA, const std::vector& modsB) +{ + // Mods must be loaded in the same order. + std::vector::const_iterator a = modsA.begin(); + std::vector::const_iterator b = modsB.begin(); + + while (a != modsA.end() || b != modsB.end()) + { + if (a != modsA.end() && (*a)->m_IgnoreInCompatibilityChecks) + { + ++a; + continue; + } + if (b != modsB.end() && (*b)->m_IgnoreInCompatibilityChecks) + { + ++b; + continue; + } + // If at this point one of the two lists still contains items, the sizes are different -> fail. + if (a == modsA.end() || b == modsB.end()) + return false; + + if ((*a)->m_Pathname != (*b)->m_Pathname) + return false; + if ((*a)->m_Version != (*b)->m_Version) + return false; + ++a; + ++b; + } + return true; +} + void Mod::UpdateAvailableMods(const ScriptInterface& scriptInterface) { PROFILE2("UpdateAvailableMods"); const Paths paths(g_CmdLineArgs); // 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(); ScriptRequest rq(scriptInterface); for (DirectoryNames::iterator iter = modDirs.begin(); iter != modDirs.end(); ++iter) { ModData data; if (!ParseModJSON(rq, vfs, modPath, *iter, data)) continue; // Valid mod data, add it to our structure m_AvailableMods.emplace_back(std::move(data)); } GetDirectoryEntries(modUserPath, NULL, &modDirsUser); for (DirectoryNames::iterator iter = modDirsUser.begin(); iter != modDirsUser.end(); ++iter) { // Ignore mods in the user folder if we have already found them in modDirs. if (std::binary_search(modDirs.begin(), modDirs.end(), *iter)) continue; ModData data; if (!ParseModJSON(rq, vfs, modUserPath, *iter, data)) continue; // Valid mod data, add it to our structure m_AvailableMods.emplace_back(std::move(data)); } } -const std::vector& Mod::GetEnabledMods() const -{ - return m_EnabledMods; -} - -const std::vector& Mod::GetIncompatibleMods() const -{ - return m_IncompatibleMods; -} - -bool Mod::EnableMods(const ScriptInterface& scriptInterface, const std::vector& mods, const bool addPublic) -{ - m_IncompatibleMods.clear(); - m_EnabledMods.clear(); - - std::unordered_map counts; - for (const CStr& mod : mods) - { - // Ignore duplicates. - if (counts.try_emplace(mod, 0).first->second++ > 0) - continue; - m_EnabledMods.emplace_back(mod); - } - - if (addPublic && counts["public"] == 0) - m_EnabledMods.insert(m_EnabledMods.begin(), "public"); - - if (counts["mod"] == 0) - m_EnabledMods.insert(m_EnabledMods.begin(), "mod"); - - UpdateAvailableMods(scriptInterface); - - m_IncompatibleMods = CheckForIncompatibleMods(m_EnabledMods); - - for (const CStr& mod : m_IncompatibleMods) - m_EnabledMods.erase(std::find(m_EnabledMods.begin(), m_EnabledMods.end(), mod)); - - return m_IncompatibleMods.empty(); -} - std::vector Mod::CheckForIncompatibleMods(const std::vector& mods) const { std::vector incompatibleMods; std::unordered_map> modDependencies; std::unordered_map modNameVersions; for (const CStr& mod : mods) { if (mod == "mod" || mod == "user") continue; std::vector::const_iterator it = std::find_if(m_AvailableMods.begin(), m_AvailableMods.end(), [&mod](const ModData& modData) { return modData.m_Pathname == mod; }); if (it == m_AvailableMods.end()) { incompatibleMods.push_back(mod); continue; } modNameVersions.emplace(it->m_Name, it->m_Version); modDependencies.emplace(it->m_Name, it->m_Dependencies); } static const std::vector toCheck = { "<=", ">=", "=", "<", ">" }; for (const CStr& mod : mods) { if (mod == "mod" || mod == "user") continue; const std::unordered_map>::iterator res = modDependencies.find(mod); if (res == modDependencies.end()) continue; const std::vector deps = res->second; if (deps.empty()) continue; for (const CStr& dep : deps) { if (dep.empty()) continue; // 0ad<=0.0.24 for (const CStr& op : toCheck) { const int pos = dep.Find(op.c_str()); if (pos == -1) continue; //0ad const CStr modToCheck = dep.substr(0, pos); //0.0.24 const CStr versionToCheck = dep.substr(pos + op.size()); const std::unordered_map::iterator it = modNameVersions.find(modToCheck); if (it == modNameVersions.end()) { incompatibleMods.push_back(mod); continue; } // 0.0.25(0ad) , <=, 0.0.24(required version) if (!CompareVersionStrings(it->second, op, versionToCheck)) { incompatibleMods.push_back(mod); continue; } break; } } } return incompatibleMods; } bool Mod::CompareVersionStrings(const CStr& version, const CStr& op, const CStr& required) const { std::vector versionSplit; std::vector requiredSplit; static const std::string toIgnore = "-,_"; boost::split(versionSplit, version, boost::is_any_of(toIgnore), boost::token_compress_on); boost::split(requiredSplit, required, boost::is_any_of(toIgnore), boost::token_compress_on); boost::split(versionSplit, versionSplit[0], boost::is_any_of("."), boost::token_compress_on); boost::split(requiredSplit, requiredSplit[0], boost::is_any_of("."), boost::token_compress_on); const bool eq = op.Find("=") != -1; const bool lt = op.Find("<") != -1; const bool gt = op.Find(">") != -1; const size_t min = std::min(versionSplit.size(), requiredSplit.size()); for (size_t i = 0; i < min; ++i) { const int diff = versionSplit[i].ToInt() - requiredSplit[i].ToInt(); if ((gt && diff > 0) || (lt && diff < 0)) return true; if ((gt && diff < 0) || (lt && diff > 0) || (eq && diff)) return false; } const size_t versionSize = versionSplit.size(); const size_t requiredSize = requiredSplit.size(); if (versionSize == requiredSize) return eq; return versionSize < requiredSize ? lt : gt; } - -JS::Value Mod::GetLoadedModsWithVersions(const ScriptInterface& scriptInterface) const -{ - std::vector> loadedMods; - for (const CStr& mod : m_EnabledMods) - { - if (mod == "mod" || mod == "user") - continue; - - std::vector::const_iterator it = std::find_if(m_AvailableMods.begin(), m_AvailableMods.end(), - [&mod](const ModData& modData) { return modData.m_Pathname == mod; }); - - // This ought be impossible, but let's handle it anyways since it's not a reason to crash. - if (it == m_AvailableMods.end()) - { - LOGERROR("Unavailable mod '%s' was enabled.", mod); - continue; - } - - loadedMods.emplace_back(std::vector{ it->m_Pathname, it->m_Version }); - } - ScriptRequest rq(scriptInterface); - JS::RootedValue returnValue(rq.cx); - Script::ToJSVal(rq, &returnValue, loadedMods); - return returnValue; -} - -JS::Value Mod::GetEngineInfo(const ScriptInterface& scriptInterface) const -{ - ScriptRequest rq(scriptInterface); - - JS::RootedValue mods(rq.cx, GetLoadedModsWithVersions(scriptInterface)); - JS::RootedValue metainfo(rq.cx); - - Script::CreateObject( - rq, - &metainfo, - "engine_version", engine_version, - "mods", mods); - - Script::FreezeObject(rq, metainfo, true); - - return metainfo; -} - -JS::Value Mod::GetAvailableMods(const ScriptRequest& rq) const -{ - JS::RootedValue ret(rq.cx, Script::CreateObject(rq)); - for (const ModData& data : m_AvailableMods) - { - JS::RootedValue json(rq.cx); - if (!Script::ParseJSON(rq, data.m_Text, &json)) - { - ScriptException::Raise(rq, "Error parsing mod.json of '%s'", data.m_Pathname.c_str()); - continue; - } - Script::SetProperty(rq, ret, data.m_Pathname.c_str(), json); - } - return ret.get(); -} Index: ps/trunk/source/ps/Mod.h =================================================================== --- ps/trunk/source/ps/Mod.h (revision 25633) +++ ps/trunk/source/ps/Mod.h (revision 25634) @@ -1,110 +1,109 @@ /* Copyright (C) 2021 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 "scriptinterface/ScriptForward.h" #include #define g_Mods (Mod::Instance()) class Mod { friend class TestMod; public: // Singleton-like interface. static Mod& Instance(); + /** + * Parsed mod.json data for C++ usage. + * Note that converting to/from JS is lossy. + */ + struct ModData + { + // 'Folder name' of the mod, e.g. 'public' for the main 0 A.D. mod. + CStr m_Pathname; + // "name" property in the mod.json + CStr m_Name; + + CStr m_Version; + std::vector m_Dependencies; + // If true, the mod is assumed to be 'GUI-only', i.e. ignored for MP or replay compatibility checks. + bool m_IgnoreInCompatibilityChecks; + + // For convenience when exporting to JS, keep a record of the full file. + CStr m_Text; + }; + const std::vector& GetEnabledMods() const; const std::vector& GetIncompatibleMods() const; + const std::vector& GetAvailableMods() const; /** * Enables specified mods (& mods required by the engine). * @param addPublic - if true, enable the public mod. * @return whether the mods were enabled successfully. This can fail if e.g. mods are incompatible. * If true, GetEnabledMods() should be non-empty, GetIncompatibleMods() empty. Otherwise, GetIncompatibleMods() is non-empty. */ bool EnableMods(const ScriptInterface& scriptInterface, const std::vector& mods, const bool addPublic); /** - * Get the loaded mods and their version. - * "user" mod and "mod" mod are ignored as they are irrelevant for compatibility checks. - * - * @param scriptInterface the ScriptInterface in which to create the return data. - * @return list of loaded mods with the format [[modA, versionA], [modB, versionB], ...] + * Get data for the given mod. + * @param the mod path name (e.g. 'public') + * @return the mod data or nullptr if unavailable. + * TODO: switch to std::optional or something related. */ - JS::Value GetLoadedModsWithVersions(const ScriptInterface& scriptInterface) const; + const ModData* GetModData(const CStr& mod) const; /** - * Gets info (version and mods loaded) on the running engine - * - * @param scriptInterface the ScriptInterface in which to create the return data. - * @return list of objects containing data + * Get a list of the enabled mod's data (intended for compatibility checks). + * "user" mod and "mod" mod are ignored as they are irrelevant for compatibility checks. */ - JS::Value GetEngineInfo(const ScriptInterface& scriptInterface) const; + const std::vector GetEnabledModsData() const; /** - * Gets a dictionary of available mods and their complete, parsed mod.json data. + * @return whether the two lists are compatible for replaying / MP play. */ - JS::Value GetAvailableMods(const ScriptRequest& rq) const; - + static bool AreModsPlayCompatible(const std::vector& modsA, const std::vector& modsB); +private: /** * Fetches available mods and stores some metadata about them. * This may open the zipped mod archives, depending on the situation, * and/or try to write files to the user mod folder, * which can be quite slow, so should be run rarely. * TODO: if this did not need the scriptInterface to parse JSON, * we could run it in different contexts and possibly cleaner. */ void UpdateAvailableMods(const ScriptInterface& scriptInterface); /** - * Parsed mod.json data for C++ usage. - */ - struct ModData - { - // 'Folder name' of the mod, e.g. 'public' for the main 0 A.D. mod. - CStr m_Pathname; - // "name" property in the mod.json - CStr m_Name; - - CStr m_Version; - std::vector m_Dependencies; - - // For convenience when exporting to JS, keep a record of the full file. - CStr m_Text; - }; - -private: - - /** * Checks a list of @a mods and returns the incompatible mods, if any. */ std::vector CheckForIncompatibleMods(const std::vector& mods) const; bool CompareVersionStrings(const CStr& required, const CStr& op, const CStr& version) const; std::vector m_EnabledMods; // Of the currently loaded mods, these are the incompatible with the engine and cannot be loaded. std::vector m_IncompatibleMods; std::vector m_AvailableMods; }; #endif // INCLUDED_MOD Index: ps/trunk/source/ps/Replay.cpp =================================================================== --- ps/trunk/source/ps/Replay.cpp (revision 25633) +++ ps/trunk/source/ps/Replay.cpp (revision 25634) @@ -1,367 +1,354 @@ /* Copyright (C) 2021 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/CLogger.h" #include "ps/Game.h" #include "ps/GameSetup/GameSetup.h" #include "ps/GameSetup/CmdLineArgs.h" #include "ps/GameSetup/Paths.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/FunctionWrapper.h" #include "scriptinterface/Object.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/ScriptRequest.h" #include "scriptinterface/ScriptStats.h" #include "scriptinterface/JSON.h" #include "simulation2/components/ICmpGuiInterface.h" #include "simulation2/helpers/Player.h" #include "simulation2/helpers/SimulationCommand.h" #include "simulation2/Simulation2.h" #include "simulation2/system/CmpPtr.h" #include #include /** * Number of turns between two saved profiler snapshots. * Keep in sync with source/tools/replayprofile/graph.js */ static const int PROFILE_TURN_INTERVAL = 20; CReplayLogger::CReplayLogger(const ScriptInterface& scriptInterface) : m_ScriptInterface(scriptInterface), m_Stream(NULL) { } CReplayLogger::~CReplayLogger() { delete m_Stream; } void CReplayLogger::StartGame(JS::MutableHandleValue attribs) { ScriptRequest rq(m_ScriptInterface); // Add timestamp, since the file-modification-date can change Script::SetProperty(rq, attribs, "timestamp", (double)std::time(nullptr)); // Add engine version and currently loaded mods for sanity checks when replaying Script::SetProperty(rq, attribs, "engine_version", engine_version); - JS::RootedValue mods(rq.cx, g_Mods.GetLoadedModsWithVersions(m_ScriptInterface)); + JS::RootedValue mods(rq.cx); + Script::ToJSVal(rq, &mods, g_Mods.GetEnabledModsData()); Script::SetProperty(rq, attribs, "mods", mods); m_Directory = createDateIndexSubdirectory(VisualReplay::GetDirectoryPath()); 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 " << Script::StringifyJSON(rq, attribs, false) << "\n"; } void CReplayLogger::Turn(u32 n, u32 turnLength, std::vector& commands) { ScriptRequest rq(m_ScriptInterface); *m_Stream << "turn " << n << " " << turnLength << "\n"; for (SimulationCommand& command : commands) *m_Stream << "cmd " << command.player << " " << Script::StringifyJSON(rq, &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"; } void CReplayLogger::SaveMetadata(const CSimulation2& simulation) { CmpPtr cmpGuiInterface(simulation, SYSTEM_ENTITY); if (!cmpGuiInterface) { LOGERROR("Could not save replay metadata!"); return; } ScriptInterface& scriptInterface = simulation.GetScriptInterface(); ScriptRequest rq(scriptInterface); JS::RootedValue arg(rq.cx); JS::RootedValue metadata(rq.cx); cmpGuiInterface->ScriptCall(INVALID_PLAYER, L"GetReplayMetadata", arg, &metadata); const OsPath fileName = g_Game->GetReplayLogger().GetDirectory() / L"metadata.json"; CreateDirectories(fileName.Parent(), 0700); std::ofstream stream (OsString(fileName).c_str(), std::ofstream::out | std::ofstream::trunc); stream << Script::StringifyJSON(rq, &metadata, false); stream.close(); debug_printf("Saved replay metadata to %s\n", fileName.string8().c_str()); } 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()); } -CStr CReplayPlayer::ModListToString(const std::vector>& list) const +namespace +{ +CStr ModListToString(const std::vector& list) { CStr text; - for (const std::vector& mod : list) - text += mod[0] + " (" + mod[1] + ")\n"; + for (const Mod::ModData* data : list) + text += data->m_Pathname + " (" + data->m_Version + ")\n"; return text; } -void CReplayPlayer::CheckReplayMods(const ScriptInterface& scriptInterface, JS::HandleValue attribs) const +void CheckReplayMods(const std::vector& replayMods) { - ScriptRequest rq(scriptInterface); - - std::vector> replayMods; - Script::GetProperty(rq, attribs, "mods", replayMods); - - std::vector> enabledMods; - JS::RootedValue enabledModsJS(rq.cx, g_Mods.GetLoadedModsWithVersions(scriptInterface)); - Script::FromJSVal(rq, enabledModsJS, enabledMods); - - CStr warn; - if (replayMods.size() != enabledMods.size()) - warn = "The number of enabled mods does not match the mods of the replay."; - else - for (size_t i = 0; i < replayMods.size(); ++i) - { - if (replayMods[i][0] != enabledMods[i][0]) - { - warn = "The enabled mods don't match the mods of the replay."; - break; - } - else if (replayMods[i][1] != enabledMods[i][1]) - { - warn = "The mod '" + replayMods[i][0] + "' with version '" + replayMods[i][1] + "' is required by the replay file, but version '" + enabledMods[i][1] + "' is present!"; - break; - } - } - - if (!warn.empty()) - LOGWARNING("%s\nThe mods of the replay are:\n%s\nThese mods are enabled:\n%s", warn, ModListToString(replayMods), ModListToString(enabledMods)); + std::vector replayData; + replayData.reserve(replayMods.size()); + for (const Mod::ModData& data : replayMods) + replayData.push_back(&data); + if (!Mod::AreModsPlayCompatible(g_Mods.GetEnabledModsData(), replayData)) + LOGWARNING("Incompatible replay mods detected.\nThe mods of the replay are:\n%s\nThese mods are enabled:\n%s", + ModListToString(replayData), ModListToString(g_Mods.GetEnabledModsData())); } +} // anonymous namespace void CReplayPlayer::Replay(const bool serializationtest, const int rejointestturn, const bool ooslog, const bool testHashFull, const bool testHashQuick) { ENSURE(m_Stream); new CProfileViewer; new CProfileManager; g_ScriptStatsTable = new CScriptStatsTable; g_ProfileViewer.AddRootTable(g_ScriptStatsTable); const int contextSize = 384 * 1024 * 1024; const int heapGrowthBytesGCTrigger = 20 * 1024 * 1024; g_ScriptContext = ScriptContext::CreateContext(contextSize, heapGrowthBytesGCTrigger); std::vector commands; u32 turn = 0; u32 turnLength = 0; { std::string type; while ((*m_Stream >> type).good()) { if (type == "start") { std::string attribsStr; { - // TODO: it'd be nice to not create a scriptInterface to load JSON. ScriptInterface scriptInterface("Engine", "Replay", g_ScriptContext); ScriptRequest rq(scriptInterface); std::getline(*m_Stream, attribsStr); JS::RootedValue attribs(rq.cx); if (!Script::ParseJSON(rq, attribsStr, &attribs)) { LOGERROR("Error parsing JSON attributes: %s", attribsStr); // TODO: do something cleverer than crashing. ENSURE(false); } // Load the mods specified in the replay. - std::vector> replayMods; - Script::GetProperty(rq, attribs, "mods", replayMods); + std::vector replayMods; + if (!Script::GetProperty(rq, attribs, "mods", replayMods)) + { + LOGERROR("Could not get replay mod information."); + // TODO: do something cleverer than crashing. + ENSURE(false); + } + std::vector mods; - for (const std::vector& ModAndVersion : replayMods) - if (!ModAndVersion.empty()) - mods.emplace_back(ModAndVersion[0]); + for (const Mod::ModData& data : replayMods) + mods.emplace_back(data.m_Pathname); // Ignore the return value, we check below. g_Mods.EnableMods(scriptInterface, mods, false); - MountMods(Paths(g_CmdLineArgs), g_Mods.GetEnabledMods()); + CheckReplayMods(replayMods); - CheckReplayMods(scriptInterface, attribs); + MountMods(Paths(g_CmdLineArgs), g_Mods.GetEnabledMods()); } g_Game = new CGame(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(); ScriptRequest rq(g_Game->GetSimulation2()->GetScriptInterface()); JS::RootedValue attribs(rq.cx); ENSURE(Script::ParseJSON(rq, attribsStr, &attribs)); 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); ScriptRequest rq(g_Game->GetSimulation2()->GetScriptInterface()); JS::RootedValue data(rq.cx); Script::ParseJSON(rq, line, &data); Script::FreezeObject(rq, data, true); commands.emplace_back(SimulationCommand(player, rq.cx, data)); } else if (type == "hash" || type == "hash-quick") { std::string replayHash; *m_Stream >> replayHash; TestHash(type, replayHash, testHashFull, testHashQuick); } 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 % PROFILE_TURN_INTERVAL == 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_ScriptContext.reset(); // Clean up delete &g_TexMan; delete &g_Profiler; delete &g_ProfileViewer; SAFE_DELETE(g_ScriptStatsTable); } void CReplayPlayer::TestHash(const std::string& hashType, const std::string& replayHash, const bool testHashFull, const bool testHashQuick) { bool quick = (hashType == "hash-quick"); if ((quick && !testHashQuick) || (!quick && !testHashFull)) return; std::string hash; ENSURE(g_Game->GetSimulation2()->ComputeStateHash(hash, quick)); std::string hexHash = Hexify(hash); if (hexHash == replayHash) debug_printf("%s ok (%s)\n", hashType.c_str(), hexHash.c_str()); else debug_printf("%s MISMATCH (%s != %s)\n", hashType.c_str(), hexHash.c_str(), replayHash.c_str()); } Index: ps/trunk/source/ps/Replay.h =================================================================== --- ps/trunk/source/ps/Replay.h (revision 25633) +++ ps/trunk/source/ps/Replay.h (revision 25634) @@ -1,121 +1,119 @@ /* Copyright (C) 2021 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_REPLAY #define INCLUDED_REPLAY #include "lib/os_path.h" #include "ps/CStr.h" #include "scriptinterface/ScriptTypes.h" #include struct SimulationCommand; class CSimulation2; class ScriptInterface; /** * Replay log recorder interface. * Call its methods at appropriate times during the game. */ class IReplayLogger { public: IReplayLogger() { } virtual ~IReplayLogger() { } /** * Started the game with the given game attributes. */ virtual void StartGame(JS::MutableHandleValue attribs) = 0; /** * Run the given turn with the given collection of player commands. */ virtual void Turn(u32 n, u32 turnLength, std::vector& commands) = 0; /** * Optional hash of simulation state (for sync checking). */ virtual void Hash(const std::string& hash, bool quick) = 0; /** * Saves metadata.json containing part of the simulation state used for the summary screen. */ virtual void SaveMetadata(const CSimulation2& simulation) = 0; /** * Remember the directory containing the commands.txt file, so that we can save additional files to it. */ virtual OsPath GetDirectory() const = 0; }; /** * Implementation of IReplayLogger that simply throws away all data. */ class CDummyReplayLogger : public IReplayLogger { public: virtual void StartGame(JS::MutableHandleValue UNUSED(attribs)) { } virtual void Turn(u32 UNUSED(n), u32 UNUSED(turnLength), std::vector& UNUSED(commands)) { } virtual void Hash(const std::string& UNUSED(hash), bool UNUSED(quick)) { } virtual void SaveMetadata(const CSimulation2& UNUSED(simulation)) { }; virtual OsPath GetDirectory() const { return OsPath(); } }; /** * Implementation of IReplayLogger that saves data to a file in the logs directory. */ class CReplayLogger : public IReplayLogger { NONCOPYABLE(CReplayLogger); public: CReplayLogger(const ScriptInterface& scriptInterface); ~CReplayLogger(); virtual void StartGame(JS::MutableHandleValue attribs); virtual void Turn(u32 n, u32 turnLength, std::vector& commands); virtual void Hash(const std::string& hash, bool quick); virtual void SaveMetadata(const CSimulation2& simulation); virtual OsPath GetDirectory() const; private: const ScriptInterface& m_ScriptInterface; std::ostream* m_Stream; OsPath m_Directory; }; /** * Replay log replayer. Runs the log with no graphics and dumps some info to stdout. */ class CReplayPlayer { public: CReplayPlayer(); ~CReplayPlayer(); void Load(const OsPath& path); void Replay(const bool serializationtest, const int rejointestturn, const bool ooslog, const bool testHashFull, const bool testHashQuick); private: std::istream* m_Stream; - CStr ModListToString(const std::vector>& list) const; - void CheckReplayMods(const ScriptInterface& scriptInterface, JS::HandleValue attribs) const; void TestHash(const std::string& hashType, const std::string& replayHash, const bool testHashFull, const bool testHashQuick); }; #endif // INCLUDED_REPLAY Index: ps/trunk/source/ps/SavedGame.cpp =================================================================== --- ps/trunk/source/ps/SavedGame.cpp (revision 25633) +++ ps/trunk/source/ps/SavedGame.cpp (revision 25634) @@ -1,304 +1,305 @@ /* Copyright (C) 2021 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 "i18n/L10n.h" #include "lib/allocators/shared_ptr.h" #include "lib/file/archive/archive_zip.h" #include "lib/file/io/io.h" #include "lib/utf8.h" #include "maths/Vector3D.h" #include "ps/CLogger.h" #include "ps/Filesystem.h" #include "ps/Game.h" #include "ps/Mod.h" #include "ps/Pyrogenesis.h" #include "scriptinterface/Object.h" #include "scriptinterface/JSON.h" #include "scriptinterface/StructuredClone.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 Script::StructuredClone& 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 Script::StructuredClone& guiMetadataClone) { ScriptRequest rq(simulation.GetScriptInterface()); // 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 initAttributes(rq.cx, simulation.GetInitAttributes()); - JS::RootedValue mods(rq.cx, g_Mods.GetLoadedModsWithVersions(simulation.GetScriptInterface())); + JS::RootedValue mods(rq.cx); + Script::ToJSVal(rq, &mods, g_Mods.GetEnabledModsData()); JS::RootedValue metadata(rq.cx); Script::CreateObject( rq, &metadata, "engine_version", engine_version, "time", static_cast(now), "playerID", g_Game->GetPlayerID(), "mods", mods, "initAttributes", initAttributes); JS::RootedValue guiMetadata(rq.cx); Script::ReadStructuredClone(rq, guiMetadataClone, &guiMetadata); // get some camera data const CVector3D cameraPosition = g_Game->GetView()->GetCameraPosition(); const CVector3D cameraRotation = g_Game->GetView()->GetCameraRotation(); JS::RootedValue cameraMetadata(rq.cx); Script::CreateObject( rq, &cameraMetadata, "PosX", cameraPosition.X, "PosY", cameraPosition.Y, "PosZ", cameraPosition.Z, "RotX", cameraRotation.X, "RotY", cameraRotation.Y, "Zoom", g_Game->GetView()->GetCameraZoom()); Script::SetProperty(rq, guiMetadata, "camera", cameraMetadata); Script::SetProperty(rq, metadata, "gui", guiMetadata); Script::SetProperty(rq, metadata, "description", description); std::string metadataString = Script::StringifyJSON(rq, &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_SavedState(savedState) { ScriptRequest rq(scriptInterface); m_Metadata.init(rq.cx); } 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) { if (pathname == L"metadata.json") { std::string buffer; buffer.resize(fileInfo.Size()); WARN_IF_ERR(archiveFile->Load("", DummySharedPtr((u8*)buffer.data()), buffer.size())); Script::ParseJSON(ScriptRequest(m_ScriptInterface), 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"); ScriptRequest rq(scriptInterface); JS::RootedValue games(rq.cx); Script::CreateArray(rq, &games); 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(rq.cx, loader.GetMetadata()); JS::RootedValue game(rq.cx); Script::CreateObject( rq, &game, "id", pathnames[i].Basename(), "metadata", metadata); Script::SetPropertyInt(rq, games, i, game); } return 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 path if (!VfsFileExists(filename) || g_VFS->GetOriginalPath(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; } Index: ps/trunk/source/ps/scripting/JSInterface_Mod.cpp =================================================================== --- ps/trunk/source/ps/scripting/JSInterface_Mod.cpp (revision 25633) +++ ps/trunk/source/ps/scripting/JSInterface_Mod.cpp (revision 25634) @@ -1,57 +1,168 @@ /* Copyright (C) 2021 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 "JSInterface_Mod.h" #include "ps/Mod.h" +#include "ps/Pyrogenesis.h" #include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/JSON.h" +#include "scriptinterface/ScriptConversions.h" extern void RestartEngine(); +// To avoid copying data needlessly in GetEngineInfo, implement a ToJSVal for pointer types. +using ModDataCPtr = const Mod::ModData*; + +template<> +void Script::ToJSVal(const ScriptRequest& rq, JS::MutableHandleValue ret, const ModDataCPtr& data) +{ + ret.set(Script::CreateObject(rq)); + Script::SetProperty(rq, ret, "mod", data->m_Pathname); + Script::SetProperty(rq, ret, "name", data->m_Name); + Script::SetProperty(rq, ret, "version", data->m_Version); + Script::SetProperty(rq, ret, "ignoreInCompatibilityChecks", data->m_IgnoreInCompatibilityChecks); +} + +// Required by JSVAL_VECTOR, but can't be implemented. +template<> +bool Script::FromJSVal(const ScriptRequest &, const JS::HandleValue, ModDataCPtr&) +{ + LOGERROR("Not implemented"); + return false; +} + +JSVAL_VECTOR(const Mod::ModData*); + +// Implement FromJSVal as a non-pointer type. +template<> +void Script::ToJSVal(const ScriptRequest& rq, JS::MutableHandleValue ret, const Mod::ModData& data) +{ + ret.set(Script::CreateObject(rq)); + Script::SetProperty(rq, ret, "mod", data.m_Pathname); + Script::SetProperty(rq, ret, "name", data.m_Name); + Script::SetProperty(rq, ret, "version", data.m_Version); + Script::SetProperty(rq, ret, "ignoreInCompatibilityChecks", data.m_IgnoreInCompatibilityChecks); +} + +template<> +bool Script::FromJSVal(const ScriptRequest& rq, const JS::HandleValue val, Mod::ModData& data) +{ + // This property is not set in mod.json files, so don't fail if it's not there. + if (Script::HasProperty(rq, val, "mod") && !Script::GetProperty(rq, val, "mod", data.m_Pathname)) + return false; + + if (!Script::GetProperty(rq, val, "version", data.m_Version)) + return false; + if (!Script::GetProperty(rq, val, "name", data.m_Name)) + return false; + + // Optional - this makes the mod 'GUI-only'. + if (Script::HasProperty(rq, val, "ignoreInCompatibilityChecks")) + { + if (!Script::GetProperty(rq, val, "ignoreInCompatibilityChecks", data.m_IgnoreInCompatibilityChecks)) + return false; + } + else + data.m_IgnoreInCompatibilityChecks = false; + + return true; +} + +JSVAL_VECTOR(Mod::ModData); + namespace JSI_Mod { Mod* ModGetter(const ScriptRequest&, JS::CallArgs&) { return &g_Mods; } +JS::Value GetEngineInfo(const ScriptInterface& scriptInterface) +{ + ScriptRequest rq(scriptInterface); + + JS::RootedValue mods(rq.cx); + Script::ToJSVal(rq, &mods, g_Mods.GetEnabledModsData()); + JS::RootedValue metainfo(rq.cx); + + Script::CreateObject( + rq, + &metainfo, + "engine_version", engine_version, + "mods", mods); + + Script::FreezeObject(rq, metainfo, true); + + return metainfo; +} + +JS::Value GetAvailableMods(const ScriptRequest& rq) +{ + JS::RootedValue ret(rq.cx, Script::CreateObject(rq)); + for (const Mod::ModData& data : g_Mods.GetAvailableMods()) + { + JS::RootedValue json(rq.cx); + if (!Script::ParseJSON(rq, data.m_Text, &json)) + { + ScriptException::Raise(rq, "Error parsing mod.json of '%s'", data.m_Pathname.c_str()); + continue; + } + Script::SetProperty(rq, ret, data.m_Pathname.c_str(), json); + } + return ret.get(); +} + +bool AreModsPlayCompatible(const std::vector& a, const std::vector& b) +{ + std::vector modsA, modsB; + modsA.reserve(a.size()); + for (const Mod::ModData& mod : a) + modsA.push_back(&mod); + modsB.reserve(b.size()); + for (const Mod::ModData& mod : b) + modsB.push_back(&mod); + return Mod::AreModsPlayCompatible(modsA, modsB); +} + bool SetModsAndRestartEngine(const ScriptInterface& scriptInterface, const std::vector& mods) { if (!g_Mods.EnableMods(scriptInterface, mods, false)) return false; RestartEngine(); return true; } bool HasIncompatibleMods() { return g_Mods.GetIncompatibleMods().size() > 0; } void RegisterScriptFunctions(const ScriptRequest& rq) { - ScriptFunction::Register<&Mod::GetEngineInfo, ModGetter>(rq, "GetEngineInfo"); - ScriptFunction::Register<&Mod::GetAvailableMods, ModGetter>(rq, "GetAvailableMods"); + ScriptFunction::Register(rq, "GetEngineInfo"); + ScriptFunction::Register(rq, "GetAvailableMods"); ScriptFunction::Register<&Mod::GetEnabledMods, ModGetter>(rq, "GetEnabledMods"); + ScriptFunction::Register(rq, "AreModsPlayCompatible"); ScriptFunction::Register (rq, "HasIncompatibleMods"); ScriptFunction::Register<&Mod::GetIncompatibleMods, ModGetter>(rq, "GetIncompatibleMods"); ScriptFunction::Register<&SetModsAndRestartEngine>(rq, "SetModsAndRestartEngine"); } } Index: ps/trunk/source/ps/tests/test_Mod.h =================================================================== --- ps/trunk/source/ps/tests/test_Mod.h (revision 25633) +++ ps/trunk/source/ps/tests/test_Mod.h (revision 25634) @@ -1,133 +1,172 @@ /* Copyright (C) 2021 Wildfire Games. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "lib/self_test.h" #include "ps/CLogger.h" #include "ps/Mod.h" #include "scriptinterface/JSON.h" #include "scriptinterface/ScriptInterface.h" class TestMod : public CxxTest::TestSuite { Mod m_Mods; public: void test_version_check() { CStr eq = "="; CStr lt = "<"; CStr gt = ">"; CStr leq = "<="; CStr geq = ">="; CStr required = "0.0.24";// 0ad <= required CStr version = "0.0.24";// 0ad version // 0.0.24 = 0.0.24 TS_ASSERT(m_Mods.CompareVersionStrings(version, eq, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, lt, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, gt, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, leq, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, geq, required)); // 0.0.23 <= 0.0.24 version = "0.0.23"; TS_ASSERT(!m_Mods.CompareVersionStrings(version, eq, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, lt, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, gt, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, leq, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, geq, required)); // 0.0.25 >= 0.0.24 version = "0.0.25"; TS_ASSERT(!m_Mods.CompareVersionStrings(version, eq, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, lt, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, gt, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, leq, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, geq, required)); // 0.0.9 <= 0.1.0 version = "0.0.9"; required = "0.1.0"; TS_ASSERT(!m_Mods.CompareVersionStrings(version, eq, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, lt, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, gt, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, leq, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, geq, required)); // 5.3 <= 5.3.0 version = "5.3"; required = "5.3.0"; TS_ASSERT(!m_Mods.CompareVersionStrings(version, eq, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, lt, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, gt, required)); TS_ASSERT(m_Mods.CompareVersionStrings(version, leq, required)); TS_ASSERT(!m_Mods.CompareVersionStrings(version, geq, required)); } void test_compatible() { ScriptInterface script("Test", "Test", g_ScriptContext); ScriptRequest rq(script); JS::RootedObject obj(rq.cx, JS_NewPlainObject(rq.cx)); m_Mods.m_AvailableMods = { - Mod::ModData{ "public", "0ad", "0.0.25", {}, "" }, - Mod::ModData{ "wrong", "wrong", "0.0.1", { "0ad=0.0.24" }, "" }, - Mod::ModData{ "good", "good", "0.0.2", { "0ad=0.0.25" }, "" }, - Mod::ModData{ "good2", "good2", "0.0.4", { "0ad>=0.0.24" }, "" }, + Mod::ModData{ "public", "0ad", "0.0.25", {}, false, "" }, + Mod::ModData{ "wrong", "wrong", "0.0.1", { "0ad=0.0.24" }, false, "" }, + Mod::ModData{ "good", "good", "0.0.2", { "0ad=0.0.25" }, false, "" }, + Mod::ModData{ "good2", "good2", "0.0.4", { "0ad>=0.0.24" }, false, "" }, }; std::vector mods; mods.clear(); mods.push_back("public"); TS_ASSERT(m_Mods.CheckForIncompatibleMods(mods).empty()); mods.clear(); mods.push_back("mod"); mods.push_back("public"); TS_ASSERT(m_Mods.CheckForIncompatibleMods(mods).empty()); mods.clear(); mods.push_back("public"); mods.push_back("good"); TS_ASSERT(m_Mods.CheckForIncompatibleMods(mods).empty()); mods.clear(); mods.push_back("public"); mods.push_back("good2"); TS_ASSERT(m_Mods.CheckForIncompatibleMods(mods).empty()); mods.clear(); mods.push_back("public"); mods.push_back("wrong"); TS_ASSERT(!m_Mods.CheckForIncompatibleMods(mods).empty()); mods.clear(); mods.push_back("public"); mods.push_back("does_not_exist"); TS_ASSERT(!m_Mods.CheckForIncompatibleMods(mods).empty()); + } + void test_play_compatible() + { + Mod::ModData a1 = { "a", "a", "0.0.1", {}, false, "" }; + Mod::ModData a2 = { "a", "a", "0.0.2", {}, false, "" }; + Mod::ModData b = { "b", "b", "0.0.1", {}, false, "" }; + Mod::ModData c = { "c", "c", "0.0.1", {}, true, "" }; + + using ModList = std::vector; + { + ModList l1 = { &a1 }; + ModList l2 = { &a2 }; + TS_ASSERT(!Mod::AreModsPlayCompatible(l1, l2)); + } + { + ModList l1 = { &a1, &b }; + ModList l2 = { &a1, &b, &c }; + TS_ASSERT(Mod::AreModsPlayCompatible(l1, l2)); + } + { + ModList l1 = { &c, &b, &a1 }; + ModList l2 = { &b, &c, &a1 }; + TS_ASSERT(Mod::AreModsPlayCompatible(l1, l2)); + } + { + ModList l1 = { &b, &c, &a1 }; + ModList l2 = { &b, &c, &a2 }; + TS_ASSERT(!Mod::AreModsPlayCompatible(l1, l2)); + } + { + ModList l1 = { &c }; + ModList l2 = {}; + TS_ASSERT(Mod::AreModsPlayCompatible(l1, l2)); + } + { + ModList l1 = {}; + ModList l2 = { &b }; + TS_ASSERT(!Mod::AreModsPlayCompatible(l1, l2)); + } } };