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