0ad/source/ps/VisualReplay.cpp

503 lines
14 KiB
C++
Raw Normal View History

/* Copyright (C) 2020 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 <http://www.gnu.org/licenses/>.
*/
#include "precompiled.h"
#include "VisualReplay.h"
#include "graphics/GameView.h"
#include "lib/timer.h"
#include "lib/utf8.h"
#include "lib/allocators/shared_ptr.h"
#include "lib/external_libraries/libsdl.h"
#include "network/NetClient.h"
#include "network/NetServer.h"
#include "ps/CLogger.h"
#include "ps/Filesystem.h"
#include "ps/Game.h"
#include "ps/GameSetup/Paths.h"
#include "ps/Mod.h"
#include "ps/Pyrogenesis.h"
#include "ps/Replay.h"
#include "ps/Util.h"
#include "scriptinterface/ScriptInterface.h"
#include "scriptinterface/ScriptExtraHeaders.h"
/**
* Filter too short replays (value in seconds).
*/
const u8 minimumReplayDuration = 3;
OsPath VisualReplay::GetDirectoryPath()
{
return Paths(g_args).UserData() / "replays" / engine_version;
}
OsPath VisualReplay::GetCacheFilePath()
{
return GetDirectoryPath() / L"replayCache.json";
}
OsPath VisualReplay::GetTempCacheFilePath()
{
return GetDirectoryPath() / L"replayCache_temp.json";
}
bool VisualReplay::StartVisualReplay(const OsPath& directory)
{
ENSURE(!g_NetServer);
ENSURE(!g_NetClient);
ENSURE(!g_Game);
const OsPath replayFile = VisualReplay::GetDirectoryPath() / directory / L"commands.txt";
if (!FileExists(replayFile))
return false;
Remove default CGame constructor values to make the code less error-prone, use CRenderer::IsInitialised() to test if the CGame should be rendered to remove indirection/proxies, making the code easier to read. 1c0536bf08 introduced a disableGraphics bool with a default value and relied on the default being reasonable except for the few needed cases. be93b31411 introduced the replayLog argument with a default value and relied on the default being reasonable except for the few needed cases. 5747619c39 fixed a bug in that commit because the default value hadn't actually been considered to be correct for all CGame constructor calls and was wrong for two. By requiring callers to specify the value, authors are forced to establish thought which value is the correct one, as opposed to hoping that a default value will be good by default. As you can see in the diff, it also makes it easier to compare what values changed if they are always defined in the caller. Use CRenderer::IsInitialised() to determine if this is a non-visual CGame, for the purpose of removing less transparent proxy functions that are unneeded as long as there are about 30 other calls testing for CRenderer::IsInitialised() to determine if the Game should be rendered. Supersedes: * CGame constructor argument bool disableGraphics from 1c0536bf08. * CGame::IsGraphicsDisabled() proxy from a533fff883 to the proxy from 1c0536bf08 and two local nonVisual = args.Has("autostart-nonvisual") variables in GameSetup.cpp from a533fff883. Call the Renderer destructor instead of calling delete on the non-pointer (SAFE_DELETE would not be supported for instance). Started as a preparation for D2197, but actually independent. Differential Revision: https://code.wildfiregames.com/D2211 This was SVN commit r22785.
2019-08-25 13:02:55 +02:00
g_Game = new CGame(false);
return g_Game->StartVisualReplay(replayFile);
}
bool VisualReplay::ReadCacheFile(const ScriptInterface& scriptInterface, JS::MutableHandleObject cachedReplaysObject)
{
if (!FileExists(GetCacheFilePath()))
return false;
std::ifstream cacheStream(OsString(GetCacheFilePath()).c_str());
CStr cacheStr((std::istreambuf_iterator<char>(cacheStream)), std::istreambuf_iterator<char>());
cacheStream.close();
ScriptRequest rq(scriptInterface);
JS::RootedValue cachedReplays(rq.cx);
if (scriptInterface.ParseJSON(cacheStr, &cachedReplays))
{
cachedReplaysObject.set(&cachedReplays.toObject());
Upgrade SpiderMonkey to version 45.0.2, refs #4893. - Various build changes, in particular NSPR is not needed on Unix anymore - Add js/Initialization.h to source/scriptinterface/ScriptEngine.h - Use nullptr instead of JS::NullPtr(), see https://bugzilla.mozilla.org/show_bug.cgi?id=1164602 - Remove `JS::RuntimeOptionsRef.varObjFix`, see https://bugzilla.mozilla.org/show_bug.cgi?id=1171177 - Remove uses of `AutoIdArray`, see https://bugzilla.mozilla.org/show_bug.cgi?id=1191529 - `JS_InternUCStringN` has been renamed, see https://bugzilla.mozilla.org/show_bug.cgi?id=1178581 - `JS::Evaluate` now takes scope chains explicitly, see https://bugzilla.mozilla.org/show_bug.cgi?id=1097987 - Array functions (such as `JS_IsArrayObject`) are fallible and output to params, see https://bugzilla.mozilla.org/show_bug.cgi?id=f3d35d8 - Remove `JSCLASS_CACHED_PROTO_WIDTH` workaround in our code, see https://bugzilla.mozilla.org/show_bug.cgi?id=1236373 - Remove compile'n go (`setCompileAndGo`) and replace it by `setIsRunOnce` which will become the default in the future, see https://bugzilla.mozilla.org/show_bug.cgi?id=679939 - Mark shared memory in direct access operations (`JS_GetUint16ArrayData` and `JS_GetUint8ArrayData`), see https://bugzilla.mozilla.org/show_bug.cgi?id=1176214 - Use new `JS::ObjectOpResult`, see https://bugzilla.mozilla.org/show_bug.cgi?id=1113369 Thanks to wraitii, elexis, Krinkle and historic_bruno for contributions and comments, and to gentz, madpilot, s0600204 and Stan for testing and indirect contributions. Differential Revision: https://code.wildfiregames.com/D1510 This was SVN commit r22627.
2019-08-08 00:37:43 +02:00
bool isArray;
if (JS::IsArrayObject(rq.cx, cachedReplaysObject, &isArray) && isArray)
return true;
}
LOGWARNING("The replay cache file is corrupted, it will be deleted");
wunlink(GetCacheFilePath());
return false;
}
void VisualReplay::StoreCacheFile(const ScriptInterface& scriptInterface, JS::HandleObject replays)
{
ScriptRequest rq(scriptInterface);
JS::RootedValue replaysRooted(rq.cx, JS::ObjectValue(*replays));
std::ofstream cacheStream(OsString(GetTempCacheFilePath()).c_str(), std::ofstream::out | std::ofstream::trunc);
cacheStream << scriptInterface.StringifyJSON(&replaysRooted);
cacheStream.close();
wunlink(GetCacheFilePath());
if (wrename(GetTempCacheFilePath(), GetCacheFilePath()))
LOGERROR("Could not store the replay cache");
}
JS::HandleObject VisualReplay::ReloadReplayCache(const ScriptInterface& scriptInterface, bool compareFiles)
{
TIMER(L"ReloadReplayCache");
ScriptRequest rq(scriptInterface);
// Maps the filename onto the index and size
typedef std::map<OsPath, std::pair<u32, off_t>> replayCacheMap;
replayCacheMap fileList;
JS::RootedObject cachedReplaysObject(rq.cx);
if (ReadCacheFile(scriptInterface, &cachedReplaysObject))
{
// Create list of files included in the cache
u32 cacheLength = 0;
JS::GetArrayLength(rq.cx, cachedReplaysObject, &cacheLength);
for (u32 j = 0; j < cacheLength; ++j)
{
JS::RootedValue replay(rq.cx);
JS_GetElement(rq.cx, cachedReplaysObject, j, &replay);
JS::RootedValue file(rq.cx);
OsPath fileName;
double fileSize;
scriptInterface.GetProperty(replay, "directory", fileName);
scriptInterface.GetProperty(replay, "fileSize", fileSize);
fileList[fileName] = std::make_pair(j, fileSize);
}
}
JS::RootedObject replays(rq.cx, JS::NewArrayObject(rq.cx, 0));
DirectoryNames directories;
if (GetDirectoryEntries(GetDirectoryPath(), nullptr, &directories) != INFO::OK)
return replays;
bool newReplays = false;
std::vector<u32> copyFromOldCache;
// Specifies where the next replay should be kept
u32 i = 0;
for (const OsPath& directory : directories)
{
// This cannot use IsQuitRequested(), because the current loop and that function both run in the main thread.
// So SDL events are not processed unless called explicitly here.
if (SDL_QuitRequested())
// Don't return, because we want to save our progress
break;
const OsPath replayFile = GetDirectoryPath() / directory / L"commands.txt";
bool isNew = true;
replayCacheMap::iterator it = fileList.find(directory);
if (it != fileList.end())
{
if (compareFiles)
{
if (!FileExists(replayFile))
continue;
CFileInfo fileInfo;
GetFileInfo(replayFile, &fileInfo);
if (fileInfo.Size() == it->second.second)
isNew = false;
}
else
isNew = false;
}
if (isNew)
{
JS::RootedValue replayData(rq.cx, LoadReplayData(scriptInterface, directory));
if (replayData.isNull())
{
if (!FileExists(replayFile))
continue;
CFileInfo fileInfo;
GetFileInfo(replayFile, &fileInfo);
ScriptInterface::CreateObject(
rq,
&replayData,
"directory", directory.string(),
"fileSize", static_cast<double>(fileInfo.Size()));
}
JS_SetElement(rq.cx, replays, i++, replayData);
newReplays = true;
}
else
copyFromOldCache.push_back(it->second.first);
}
debug_printf(
"Loading %lu cached replays, removed %lu outdated entries, loaded %i new entries\n",
(unsigned long)fileList.size(), (unsigned long)(fileList.size() - copyFromOldCache.size()), i);
if (!newReplays && fileList.empty())
return replays;
// No replay was changed, so just return the cache
if (!newReplays && fileList.size() == copyFromOldCache.size())
return cachedReplaysObject;
{
// Copy the replays from the old cache that are not deleted
if (!copyFromOldCache.empty())
for (u32 j : copyFromOldCache)
{
JS::RootedValue replay(rq.cx);
JS_GetElement(rq.cx, cachedReplaysObject, j, &replay);
JS_SetElement(rq.cx, replays, i++, replay);
}
}
StoreCacheFile(scriptInterface, replays);
return replays;
}
JS::Value VisualReplay::GetReplays(const ScriptInterface& scriptInterface, bool compareFiles)
{
TIMER(L"GetReplays");
ScriptRequest rq(scriptInterface);
JS::RootedObject replays(rq.cx, ReloadReplayCache(scriptInterface, compareFiles));
// Only take entries with data
JS::RootedValue replaysWithoutNullEntries(rq.cx);
ScriptInterface::CreateArray(rq, &replaysWithoutNullEntries);
u32 replaysLength = 0;
JS::GetArrayLength(rq.cx, replays, &replaysLength);
for (u32 j = 0, i = 0; j < replaysLength; ++j)
{
JS::RootedValue replay(rq.cx);
JS_GetElement(rq.cx, replays, j, &replay);
if (scriptInterface.HasProperty(replay, "attribs"))
scriptInterface.SetPropertyInt(replaysWithoutNullEntries, i++, replay);
}
return replaysWithoutNullEntries;
}
/**
* Move the cursor backwards until a newline was read or the beginning of the file was found.
* Either way the cursor points to the beginning of a newline.
*
* @return The current cursor position or -1 on error.
*/
inline off_t goBackToLineBeginning(std::istream* replayStream, const OsPath& fileName, off_t fileSize)
{
int currentPos;
char character;
for (int characters = 0; characters < 10000; ++characters)
{
currentPos = (int) replayStream->tellg();
// Stop when reached the beginning of the file
if (currentPos == 0)
return currentPos;
if (!replayStream->good())
{
LOGERROR("Unknown error when returning to the last line (%i of %lu) of %s", currentPos, fileSize, fileName.string8().c_str());
return -1;
}
// Stop when reached newline
replayStream->get(character);
if (character == '\n')
return currentPos;
// Otherwise go back one character.
// Notice: -1 will set the cursor back to the most recently read character.
replayStream->seekg(-2, std::ios_base::cur);
}
LOGERROR("Infinite loop when going back to a line beginning in %s", fileName.string8().c_str());
return -1;
}
/**
* Compute game duration in seconds. Assume constant turn length.
* Find the last line that starts with "turn" by reading the file backwards.
*
* @return seconds or -1 on error
*/
inline int getReplayDuration(std::istream* replayStream, const OsPath& fileName, off_t fileSize)
{
CStr type;
// Move one character before the file-end
replayStream->seekg(-2, std::ios_base::end);
// Infinite loop protection, should never occur.
// There should be about 5 lines to read until a turn is found.
for (int linesRead = 1; linesRead < 1000; ++linesRead)
{
off_t currentPosition = goBackToLineBeginning(replayStream, fileName, fileSize);
// Read error or reached file beginning. No turns exist.
if (currentPosition < 1)
return -1;
if (!replayStream->good())
{
LOGERROR("Read error when determining replay duration at %i of %llu in %s", currentPosition - 2, fileSize, fileName.string8().c_str());
return -1;
}
// Found last turn, compute duration.
if (currentPosition + 4 < fileSize && (*replayStream >> type).good() && type == "turn")
{
u32 turn = 0, turnLength = 0;
*replayStream >> turn >> turnLength;
return (turn+1) * turnLength / 1000; // add +1 as turn numbers starts with 0
}
// Otherwise move cursor back to the character before the last newline
replayStream->seekg(currentPosition - 2, std::ios_base::beg);
}
LOGERROR("Infinite loop when determining replay duration for %s", fileName.string8().c_str());
return -1;
}
JS::Value VisualReplay::LoadReplayData(const ScriptInterface& scriptInterface, const OsPath& directory)
{
// The directory argument must not be constant, otherwise concatenating will fail
const OsPath replayFile = GetDirectoryPath() / directory / L"commands.txt";
if (!FileExists(replayFile))
return JS::NullValue();
// Get file size and modification date
CFileInfo fileInfo;
GetFileInfo(replayFile, &fileInfo);
const off_t fileSize = fileInfo.Size();
if (fileSize == 0)
return JS::NullValue();
std::ifstream* replayStream = new std::ifstream(OsString(replayFile).c_str());
CStr type;
if (!(*replayStream >> type).good())
{
LOGERROR("Couldn't open %s.", replayFile.string8().c_str());
SAFE_DELETE(replayStream);
return JS::NullValue();
}
if (type != "start")
{
LOGWARNING("The replay %s doesn't begin with 'start'!", replayFile.string8().c_str());
SAFE_DELETE(replayStream);
return JS::NullValue();
}
// Parse header / first line
CStr header;
std::getline(*replayStream, header);
ScriptRequest rq(scriptInterface);
JS::RootedValue attribs(rq.cx);
if (!scriptInterface.ParseJSON(header, &attribs))
{
LOGERROR("Couldn't parse replay header of %s", replayFile.string8().c_str());
SAFE_DELETE(replayStream);
return JS::NullValue();
}
// Ensure "turn" after header
if (!(*replayStream >> type).good() || type != "turn")
{
SAFE_DELETE(replayStream);
return JS::NullValue(); // there are no turns at all
}
// Don't process files of rejoined clients
u32 turn = 1;
*replayStream >> turn;
if (turn != 0)
{
SAFE_DELETE(replayStream);
return JS::NullValue();
}
int duration = getReplayDuration(replayStream, replayFile, fileSize);
SAFE_DELETE(replayStream);
// Ensure minimum duration
if (duration < minimumReplayDuration)
return JS::NullValue();
// Return the actual data
JS::RootedValue replayData(rq.cx);
ScriptInterface::CreateObject(
rq,
&replayData,
"directory", directory.string(),
"fileSize", static_cast<double>(fileSize),
"duration", duration);
scriptInterface.SetProperty(replayData, "attribs", attribs);
return replayData;
}
bool VisualReplay::DeleteReplay(const OsPath& replayDirectory)
{
if (replayDirectory.empty())
return false;
const OsPath directory = GetDirectoryPath() / replayDirectory;
return DirectoryExists(directory) && DeleteDirectory(directory) == INFO::OK;
}
JS::Value VisualReplay::GetReplayAttributes(const ScriptInterface& scriptInterface, const OsPath& directoryName)
{
// Create empty JS object
ScriptRequest rq(scriptInterface);
JS::RootedValue attribs(rq.cx);
ScriptInterface::CreateObject(rq, &attribs);
// Return empty object if file doesn't exist
const OsPath replayFile = GetDirectoryPath() / directoryName / L"commands.txt";
if (!FileExists(replayFile))
return attribs;
// Open file
std::istream* replayStream = new std::ifstream(OsString(replayFile).c_str());
CStr type, line;
ENSURE((*replayStream >> type).good() && type == "start");
// Read and return first line
std::getline(*replayStream, line);
scriptInterface.ParseJSON(line, &attribs);
SAFE_DELETE(replayStream);;
return attribs;
}
void VisualReplay::AddReplayToCache(const ScriptInterface& scriptInterface, const CStrW& directoryName)
{
TIMER(L"AddReplayToCache");
ScriptRequest rq(scriptInterface);
JS::RootedValue replayData(rq.cx, LoadReplayData(scriptInterface, OsPath(directoryName)));
if (replayData.isNull())
return;
JS::RootedObject cachedReplaysObject(rq.cx);
if (!ReadCacheFile(scriptInterface, &cachedReplaysObject))
cachedReplaysObject = JS::NewArrayObject(rq.cx, 0);
u32 cacheLength = 0;
JS::GetArrayLength(rq.cx, cachedReplaysObject, &cacheLength);
JS_SetElement(rq.cx, cachedReplaysObject, cacheLength, replayData);
StoreCacheFile(scriptInterface, cachedReplaysObject);
}
bool VisualReplay::HasReplayMetadata(const OsPath& directoryName)
{
const OsPath filePath(GetDirectoryPath() / directoryName / L"metadata.json");
if (!FileExists(filePath))
return false;
CFileInfo fileInfo;
GetFileInfo(filePath, &fileInfo);
return fileInfo.Size() > 0;
}
JS::Value VisualReplay::GetReplayMetadata(const ScriptInterface& scriptInterface, const OsPath& directoryName)
{
if (!HasReplayMetadata(directoryName))
return JS::NullValue();
ScriptRequest rq(scriptInterface);
JS::RootedValue metadata(rq.cx);
std::ifstream* stream = new std::ifstream(OsString(GetDirectoryPath() / directoryName / L"metadata.json").c_str());
ENSURE(stream->good());
CStr line;
std::getline(*stream, line);
stream->close();
SAFE_DELETE(stream);
scriptInterface.ParseJSON(line, &metadata);
return metadata;
}