Line data Source code
1 : /* Copyright (C) 2021 Wildfire Games.
2 : * This file is part of 0 A.D.
3 : *
4 : * 0 A.D. is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 2 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * 0 A.D. is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
16 : */
17 :
18 : #include "precompiled.h"
19 :
20 : #include "VisualReplay.h"
21 : #include "graphics/GameView.h"
22 : #include "lib/timer.h"
23 : #include "lib/utf8.h"
24 : #include "lib/allocators/shared_ptr.h"
25 : #include "lib/file/file_system.h"
26 : #include "lib/external_libraries/libsdl.h"
27 : #include "network/NetClient.h"
28 : #include "network/NetServer.h"
29 : #include "ps/CLogger.h"
30 : #include "ps/Filesystem.h"
31 : #include "ps/Game.h"
32 : #include "ps/GameSetup/CmdLineArgs.h"
33 : #include "ps/GameSetup/Paths.h"
34 : #include "ps/Mod.h"
35 : #include "ps/Pyrogenesis.h"
36 : #include "ps/Replay.h"
37 : #include "ps/Util.h"
38 : #include "scriptinterface/JSON.h"
39 :
40 : #include <fstream>
41 :
42 : /**
43 : * Filter too short replays (value in seconds).
44 : */
45 : const u8 minimumReplayDuration = 3;
46 :
47 0 : OsPath VisualReplay::GetDirectoryPath()
48 : {
49 0 : return Paths(g_CmdLineArgs).UserData() / "replays" / engine_version;
50 : }
51 :
52 0 : OsPath VisualReplay::GetCacheFilePath()
53 : {
54 0 : return GetDirectoryPath() / L"replayCache.json";
55 : }
56 :
57 0 : OsPath VisualReplay::GetTempCacheFilePath()
58 : {
59 0 : return GetDirectoryPath() / L"replayCache_temp.json";
60 : }
61 :
62 0 : bool VisualReplay::StartVisualReplay(const OsPath& directory)
63 : {
64 0 : ENSURE(!g_NetServer);
65 0 : ENSURE(!g_NetClient);
66 0 : ENSURE(!g_Game);
67 :
68 0 : const OsPath replayFile = VisualReplay::GetDirectoryPath() / directory / L"commands.txt";
69 :
70 0 : if (!FileExists(replayFile))
71 0 : return false;
72 :
73 0 : g_Game = new CGame(false);
74 0 : return g_Game->StartVisualReplay(replayFile);
75 : }
76 :
77 0 : bool VisualReplay::ReadCacheFile(const ScriptInterface& scriptInterface, JS::MutableHandleObject cachedReplaysObject)
78 : {
79 0 : if (!FileExists(GetCacheFilePath()))
80 0 : return false;
81 :
82 0 : std::ifstream cacheStream(OsString(GetCacheFilePath()).c_str());
83 0 : CStr cacheStr((std::istreambuf_iterator<char>(cacheStream)), std::istreambuf_iterator<char>());
84 0 : cacheStream.close();
85 :
86 0 : ScriptRequest rq(scriptInterface);
87 :
88 0 : JS::RootedValue cachedReplays(rq.cx);
89 0 : if (Script::ParseJSON(rq, cacheStr, &cachedReplays))
90 : {
91 0 : cachedReplaysObject.set(&cachedReplays.toObject());
92 : bool isArray;
93 0 : if (JS::IsArrayObject(rq.cx, cachedReplaysObject, &isArray) && isArray)
94 0 : return true;
95 : }
96 :
97 0 : LOGWARNING("The replay cache file is corrupted, it will be deleted");
98 0 : wunlink(GetCacheFilePath());
99 0 : return false;
100 : }
101 :
102 0 : void VisualReplay::StoreCacheFile(const ScriptInterface& scriptInterface, JS::HandleObject replays)
103 : {
104 0 : ScriptRequest rq(scriptInterface);
105 :
106 0 : JS::RootedValue replaysRooted(rq.cx, JS::ObjectValue(*replays));
107 0 : std::ofstream cacheStream(OsString(GetTempCacheFilePath()).c_str(), std::ofstream::out | std::ofstream::trunc);
108 0 : cacheStream << Script::StringifyJSON(rq, &replaysRooted);
109 0 : cacheStream.close();
110 :
111 0 : wunlink(GetCacheFilePath());
112 0 : if (RenameFile(GetTempCacheFilePath(), GetCacheFilePath()))
113 0 : LOGERROR("Could not store the replay cache");
114 0 : }
115 :
116 0 : JS::HandleObject VisualReplay::ReloadReplayCache(const ScriptInterface& scriptInterface, bool compareFiles)
117 : {
118 0 : TIMER(L"ReloadReplayCache");
119 0 : ScriptRequest rq(scriptInterface);
120 :
121 : // Maps the filename onto the index, mtime and size
122 : using replayCacheMap = std::map<OsPath, std::tuple<u32, u64, off_t>>;
123 :
124 0 : replayCacheMap fileList;
125 :
126 0 : JS::RootedObject cachedReplaysObject(rq.cx);
127 0 : if (ReadCacheFile(scriptInterface, &cachedReplaysObject))
128 : {
129 : // Create list of files included in the cache
130 0 : u32 cacheLength = 0;
131 0 : JS::GetArrayLength(rq.cx, cachedReplaysObject, &cacheLength);
132 0 : for (u32 j = 0; j < cacheLength; ++j)
133 : {
134 0 : JS::RootedValue replay(rq.cx);
135 0 : JS_GetElement(rq.cx, cachedReplaysObject, j, &replay);
136 :
137 0 : JS::RootedValue file(rq.cx);
138 0 : OsPath fileName;
139 : double fileSize;
140 : double fileMtime;
141 0 : Script::GetProperty(rq, replay, "directory", fileName);
142 0 : Script::GetProperty(rq, replay, "fileSize", fileSize);
143 0 : Script::GetProperty(rq, replay, "fileMTime", fileMtime);
144 :
145 0 : fileList[fileName] = std::make_tuple(j, fileMtime, fileSize);
146 : }
147 : }
148 :
149 0 : JS::RootedObject replays(rq.cx, JS::NewArrayObject(rq.cx, 0));
150 0 : DirectoryNames directories;
151 :
152 0 : if (GetDirectoryEntries(GetDirectoryPath(), nullptr, &directories) != INFO::OK)
153 0 : return replays;
154 :
155 0 : bool newReplays = false;
156 0 : std::vector<u32> copyFromOldCache;
157 : // Specifies where the next replay should be kept
158 0 : u32 i = 0;
159 :
160 0 : for (const OsPath& directory : directories)
161 : {
162 : // This cannot use IsQuitRequested(), because the current loop and that function both run in the main thread.
163 : // So SDL events are not processed unless called explicitly here.
164 0 : if (SDL_QuitRequested())
165 : // Don't return, because we want to save our progress
166 0 : break;
167 :
168 0 : const OsPath replayFile = GetDirectoryPath() / directory / L"commands.txt";
169 :
170 0 : bool isNew = true;
171 0 : replayCacheMap::iterator it = fileList.find(directory);
172 0 : if (it != fileList.end())
173 : {
174 0 : if (compareFiles)
175 : {
176 0 : if (!FileExists(replayFile))
177 0 : continue;
178 0 : CFileInfo fileInfo;
179 0 : GetFileInfo(replayFile, &fileInfo);
180 0 : if ((u64)fileInfo.MTime() == std::get<1>(it->second) && (off_t)fileInfo.Size() == std::get<2>(it->second))
181 0 : isNew = false;
182 : }
183 : else
184 0 : isNew = false;
185 : }
186 :
187 0 : if (isNew)
188 : {
189 0 : JS::RootedValue replayData(rq.cx, LoadReplayData(scriptInterface, directory));
190 0 : if (replayData.isNull())
191 : {
192 0 : if (!FileExists(replayFile))
193 0 : continue;
194 0 : CFileInfo fileInfo;
195 0 : GetFileInfo(replayFile, &fileInfo);
196 :
197 0 : Script::CreateObject(
198 : rq,
199 : &replayData,
200 0 : "directory", directory.string(),
201 0 : "fileMTime", static_cast<double>(fileInfo.MTime()),
202 0 : "fileSize", static_cast<double>(fileInfo.Size()));
203 : }
204 0 : JS_SetElement(rq.cx, replays, i++, replayData);
205 0 : newReplays = true;
206 : }
207 : else
208 0 : copyFromOldCache.push_back(std::get<0>(it->second));
209 : }
210 :
211 0 : debug_printf(
212 : "Loading %lu cached replays, removed %lu outdated entries, loaded %i new entries\n",
213 0 : (unsigned long)fileList.size(), (unsigned long)(fileList.size() - copyFromOldCache.size()), i);
214 :
215 0 : if (!newReplays && fileList.empty())
216 0 : return replays;
217 :
218 : // No replay was changed, so just return the cache
219 0 : if (!newReplays && fileList.size() == copyFromOldCache.size())
220 0 : return cachedReplaysObject;
221 :
222 : {
223 : // Copy the replays from the old cache that are not deleted
224 0 : if (!copyFromOldCache.empty())
225 0 : for (u32 j : copyFromOldCache)
226 : {
227 0 : JS::RootedValue replay(rq.cx);
228 0 : JS_GetElement(rq.cx, cachedReplaysObject, j, &replay);
229 0 : JS_SetElement(rq.cx, replays, i++, replay);
230 : }
231 : }
232 0 : StoreCacheFile(scriptInterface, replays);
233 0 : return replays;
234 : }
235 :
236 0 : JS::Value VisualReplay::GetReplays(const ScriptInterface& scriptInterface, bool compareFiles)
237 : {
238 0 : TIMER(L"GetReplays");
239 :
240 0 : ScriptRequest rq(scriptInterface);
241 0 : JS::RootedObject replays(rq.cx, ReloadReplayCache(scriptInterface, compareFiles));
242 : // Only take entries with data
243 0 : JS::RootedValue replaysWithoutNullEntries(rq.cx);
244 0 : Script::CreateArray(rq, &replaysWithoutNullEntries);
245 :
246 0 : u32 replaysLength = 0;
247 0 : JS::GetArrayLength(rq.cx, replays, &replaysLength);
248 0 : for (u32 j = 0, i = 0; j < replaysLength; ++j)
249 : {
250 0 : JS::RootedValue replay(rq.cx);
251 0 : JS_GetElement(rq.cx, replays, j, &replay);
252 0 : if (Script::HasProperty(rq, replay, "attribs"))
253 0 : Script::SetPropertyInt(rq, replaysWithoutNullEntries, i++, replay);
254 : }
255 0 : return replaysWithoutNullEntries;
256 : }
257 :
258 : /**
259 : * Move the cursor backwards until a newline was read or the beginning of the file was found.
260 : * Either way the cursor points to the beginning of a newline.
261 : *
262 : * @return The current cursor position or -1 on error.
263 : */
264 0 : inline off_t goBackToLineBeginning(std::istream* replayStream, const OsPath& fileName, off_t fileSize)
265 : {
266 : int currentPos;
267 : char character;
268 0 : for (int characters = 0; characters < 10000; ++characters)
269 : {
270 0 : currentPos = (int) replayStream->tellg();
271 :
272 : // Stop when reached the beginning of the file
273 0 : if (currentPos == 0)
274 0 : return currentPos;
275 :
276 0 : if (!replayStream->good())
277 : {
278 0 : LOGERROR("Unknown error when returning to the last line (%i of %lu) of %s", currentPos, fileSize, fileName.string8().c_str());
279 0 : return -1;
280 : }
281 :
282 : // Stop when reached newline
283 0 : replayStream->get(character);
284 0 : if (character == '\n')
285 0 : return currentPos;
286 :
287 : // Otherwise go back one character.
288 : // Notice: -1 will set the cursor back to the most recently read character.
289 0 : replayStream->seekg(-2, std::ios_base::cur);
290 : }
291 :
292 0 : LOGERROR("Infinite loop when going back to a line beginning in %s", fileName.string8().c_str());
293 0 : return -1;
294 : }
295 :
296 : /**
297 : * Compute game duration in seconds. Assume constant turn length.
298 : * Find the last line that starts with "turn" by reading the file backwards.
299 : *
300 : * @return seconds or -1 on error
301 : */
302 0 : inline int getReplayDuration(std::istream* replayStream, const OsPath& fileName, off_t fileSize)
303 : {
304 0 : CStr type;
305 :
306 : // Move one character before the file-end
307 0 : replayStream->seekg(-2, std::ios_base::end);
308 :
309 : // Infinite loop protection, should never occur.
310 : // There should be about 5 lines to read until a turn is found.
311 0 : for (int linesRead = 1; linesRead < 1000; ++linesRead)
312 : {
313 0 : off_t currentPosition = goBackToLineBeginning(replayStream, fileName, fileSize);
314 :
315 : // Read error or reached file beginning. No turns exist.
316 0 : if (currentPosition < 1)
317 0 : return -1;
318 :
319 0 : if (!replayStream->good())
320 : {
321 0 : LOGERROR("Read error when determining replay duration at %i of %llu in %s", currentPosition - 2, fileSize, fileName.string8().c_str());
322 0 : return -1;
323 : }
324 :
325 : // Found last turn, compute duration.
326 0 : if (currentPosition + 4 < fileSize && (*replayStream >> type).good() && type == "turn")
327 : {
328 0 : u32 turn = 0, turnLength = 0;
329 0 : *replayStream >> turn >> turnLength;
330 0 : return (turn+1) * turnLength / 1000; // add +1 as turn numbers starts with 0
331 : }
332 :
333 : // Otherwise move cursor back to the character before the last newline
334 0 : replayStream->seekg(currentPosition - 2, std::ios_base::beg);
335 : }
336 :
337 0 : LOGERROR("Infinite loop when determining replay duration for %s", fileName.string8().c_str());
338 0 : return -1;
339 : }
340 :
341 0 : JS::Value VisualReplay::LoadReplayData(const ScriptInterface& scriptInterface, const OsPath& directory)
342 : {
343 : // The directory argument must not be constant, otherwise concatenating will fail
344 0 : const OsPath replayFile = GetDirectoryPath() / directory / L"commands.txt";
345 :
346 0 : if (!FileExists(replayFile))
347 0 : return JS::NullValue();
348 :
349 : // Get file size and modification date
350 0 : CFileInfo fileInfo;
351 0 : GetFileInfo(replayFile, &fileInfo);
352 0 : const off_t fileSize = fileInfo.Size();
353 :
354 0 : if (fileSize == 0)
355 0 : return JS::NullValue();
356 :
357 0 : std::ifstream* replayStream = new std::ifstream(OsString(replayFile).c_str());
358 :
359 0 : CStr type;
360 0 : if (!(*replayStream >> type).good())
361 : {
362 0 : LOGERROR("Couldn't open %s.", replayFile.string8().c_str());
363 0 : SAFE_DELETE(replayStream);
364 0 : return JS::NullValue();
365 : }
366 :
367 0 : if (type != "start")
368 : {
369 0 : LOGWARNING("The replay %s doesn't begin with 'start'!", replayFile.string8().c_str());
370 0 : SAFE_DELETE(replayStream);
371 0 : return JS::NullValue();
372 : }
373 :
374 : // Parse header / first line
375 0 : CStr header;
376 0 : std::getline(*replayStream, header);
377 0 : ScriptRequest rq(scriptInterface);
378 0 : JS::RootedValue attribs(rq.cx);
379 0 : if (!Script::ParseJSON(rq, header, &attribs))
380 : {
381 0 : LOGERROR("Couldn't parse replay header of %s", replayFile.string8().c_str());
382 0 : SAFE_DELETE(replayStream);
383 0 : return JS::NullValue();
384 : }
385 :
386 : // Ensure "turn" after header
387 0 : if (!(*replayStream >> type).good() || type != "turn")
388 : {
389 0 : SAFE_DELETE(replayStream);
390 0 : return JS::NullValue(); // there are no turns at all
391 : }
392 :
393 : // Don't process files of rejoined clients
394 0 : u32 turn = 1;
395 0 : *replayStream >> turn;
396 0 : if (turn != 0)
397 : {
398 0 : SAFE_DELETE(replayStream);
399 0 : return JS::NullValue();
400 : }
401 :
402 0 : int duration = getReplayDuration(replayStream, replayFile, fileSize);
403 :
404 0 : SAFE_DELETE(replayStream);
405 :
406 : // Ensure minimum duration
407 0 : if (duration < minimumReplayDuration)
408 0 : return JS::NullValue();
409 :
410 : // Return the actual data
411 0 : JS::RootedValue replayData(rq.cx);
412 :
413 0 : Script::CreateObject(
414 : rq,
415 : &replayData,
416 0 : "directory", directory.string(),
417 0 : "fileSize", static_cast<double>(fileSize),
418 0 : "fileMTime", static_cast<double>(fileInfo.MTime()),
419 : "duration", duration);
420 :
421 0 : Script::SetProperty(rq, replayData, "attribs", attribs);
422 :
423 0 : return replayData;
424 : }
425 :
426 0 : bool VisualReplay::DeleteReplay(const OsPath& replayDirectory)
427 : {
428 0 : if (replayDirectory.empty())
429 0 : return false;
430 :
431 0 : const OsPath directory = GetDirectoryPath() / replayDirectory;
432 0 : return DirectoryExists(directory) && DeleteDirectory(directory) == INFO::OK;
433 : }
434 :
435 0 : JS::Value VisualReplay::GetReplayAttributes(const ScriptInterface& scriptInterface, const OsPath& directoryName)
436 : {
437 : // Create empty JS object
438 0 : ScriptRequest rq(scriptInterface);
439 0 : JS::RootedValue attribs(rq.cx);
440 0 : Script::CreateObject(rq, &attribs);
441 :
442 : // Return empty object if file doesn't exist
443 0 : const OsPath replayFile = GetDirectoryPath() / directoryName / L"commands.txt";
444 0 : if (!FileExists(replayFile))
445 0 : return attribs;
446 :
447 : // Open file
448 0 : std::istream* replayStream = new std::ifstream(OsString(replayFile).c_str());
449 0 : CStr type, line;
450 0 : ENSURE((*replayStream >> type).good() && type == "start");
451 :
452 : // Read and return first line
453 0 : std::getline(*replayStream, line);
454 0 : Script::ParseJSON(rq, line, &attribs);
455 0 : SAFE_DELETE(replayStream);;
456 0 : return attribs;
457 : }
458 :
459 0 : void VisualReplay::AddReplayToCache(const ScriptInterface& scriptInterface, const CStrW& directoryName)
460 : {
461 0 : TIMER(L"AddReplayToCache");
462 0 : ScriptRequest rq(scriptInterface);
463 :
464 0 : JS::RootedValue replayData(rq.cx, LoadReplayData(scriptInterface, OsPath(directoryName)));
465 0 : if (replayData.isNull())
466 0 : return;
467 :
468 0 : JS::RootedObject cachedReplaysObject(rq.cx);
469 0 : if (!ReadCacheFile(scriptInterface, &cachedReplaysObject))
470 0 : cachedReplaysObject = JS::NewArrayObject(rq.cx, 0);
471 :
472 0 : u32 cacheLength = 0;
473 0 : JS::GetArrayLength(rq.cx, cachedReplaysObject, &cacheLength);
474 0 : JS_SetElement(rq.cx, cachedReplaysObject, cacheLength, replayData);
475 :
476 0 : StoreCacheFile(scriptInterface, cachedReplaysObject);
477 : }
478 :
479 0 : bool VisualReplay::HasReplayMetadata(const OsPath& directoryName)
480 : {
481 0 : const OsPath filePath(GetDirectoryPath() / directoryName / L"metadata.json");
482 :
483 0 : if (!FileExists(filePath))
484 0 : return false;
485 :
486 0 : CFileInfo fileInfo;
487 0 : GetFileInfo(filePath, &fileInfo);
488 :
489 0 : return fileInfo.Size() > 0;
490 : }
491 :
492 0 : JS::Value VisualReplay::GetReplayMetadata(const ScriptInterface& scriptInterface, const OsPath& directoryName)
493 : {
494 0 : if (!HasReplayMetadata(directoryName))
495 0 : return JS::NullValue();
496 :
497 0 : ScriptRequest rq(scriptInterface);
498 0 : JS::RootedValue metadata(rq.cx);
499 :
500 0 : std::ifstream* stream = new std::ifstream(OsString(GetDirectoryPath() / directoryName / L"metadata.json").c_str());
501 0 : ENSURE(stream->good());
502 0 : CStr line;
503 0 : std::getline(*stream, line);
504 0 : stream->close();
505 0 : SAFE_DELETE(stream);
506 0 : Script::ParseJSON(rq, line, &metadata);
507 :
508 0 : return metadata;
509 3 : }
|