gamedemo.cpp
1 //------------------------------------------------------------------------------
2 // gamedemo.cpp
3 //------------------------------------------------------------------------------
4 //
5 // This library is free software; you can redistribute it and/or
6 // modify it under the terms of the GNU Lesser General Public
7 // License as published by the Free Software Foundation; either
8 // version 2.1 of the License, or (at your option) any later version.
9 //
10 // This library is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 // Lesser General Public License for more details.
14 //
15 // You should have received a copy of the GNU Lesser General Public
16 // License along with this library; if not, write to the Free Software
17 // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
18 // 02110-1301 USA
19 //
20 //------------------------------------------------------------------------------
21 // Copyright (C) 2014 "Zalewa" <zalewapl@gmail.com>
22 //------------------------------------------------------------------------------
23 #include "gamedemo.h"
24 
25 #include "configuration/doomseekerconfig.h"
26 #include "datapaths.h"
27 #include "datetime.h"
28 #include "fileutils.h"
29 #include "ini/ini.h"
30 #include "ini/settingsproviderqt.h"
31 #include "ntfsperm.h"
32 #include "plugins/engineplugin.h"
33 #include "plugins/pluginloader.h"
35 #include <cassert>
36 #include <QDateTime>
37 #include <QDir>
38 #include <QDirIterator>
39 #include <QFileInfo>
40 #include <QJsonDocument>
41 #include <QTimeZone>
42 #include <QMessageBox>
43 #include <QVariant>
44 #include <QVariantList>
45 
46 class GameDemoTr : public QObject
47 {
48  Q_OBJECT;
49 
50 public:
51  static QString title()
52  {
53  return tr("Doomseeker - Record Demo");
54  }
55 
56  static QString pathDoesntExist(const QString &path, const QString &details)
57  {
58  QString detailsMsg;
59  if (!details.isEmpty())
60  {
61  detailsMsg = QString("\n") + tr("Error: %1").arg(details);
62  }
63  return tr("The demo storage directory doesn't exist "
64  "and cannot be created!%1\n\n%2").arg(detailsMsg).arg(path);
65  }
66 
67  static QString pathMissingPermissions(const QString &path)
68  {
69  return tr("The demo storage directory exists but "
70  "lacks the necessary permissions!\n\n%1").arg(path);
71  }
72 
73 private:
74  GameDemoTr() = delete;
75 };
76 
77 /*
78  GameDemo
79 */
83 static void loadDemoMetaDataFromFilenameV1(const QString &path, GameDemo &demo)
84 {
85  QString metaData = QFileInfo(path).completeBaseName();
86  // We need to split manually to handle escaping.
87  QStringList demoData;
88  for (int i = 0; i < metaData.length(); ++i)
89  {
90  if (metaData[i] == '_')
91  {
92  // If our underscore is followed by another just continue on...
93  if (i + 1 < metaData.length() && metaData[i + 1] == '_')
94  {
95  ++i;
96  continue;
97  }
98 
99  // Split the meta data and then restart from the beginning.
100  demoData << metaData.left(i).replace("__", "_");
101  metaData = metaData.mid(i + 1);
102  i = 0;
103  }
104  }
105  // Whatever is left is a part of our data.
106  demoData << metaData.replace("__", "_");
107  // Should have at least 3 elements game, date, time[, iwad[, pwads]]
108  if (demoData.size() < 3)
109  return;
110 
111  QDate date = QDate::fromString(demoData[1], "dd.MM.yyyy");
112  QTime time = QTime::fromString(demoData[2], "hh.mm.ss");
113 
114  demo.game = demoData[0];
115  demo.time = QDateTime(date, time, QTimeZone::systemTimeZone());
116  if (demoData.size() >= 4)
117  {
118  bool hasIwad = false;
119  QStringList wadnames = demoData.mid(3);
120  for (QString wad : wadnames)
121  {
122  if (!wad.isEmpty())
123  {
124  if (!hasIwad)
125  {
126  demo.iwad = wad;
127  hasIwad = true;
128  }
129  else
130  {
131  demo.wads << PWad(wad);
132  }
133  }
134  }
135  }
136 }
137 
141 static void loadDemoMetaDataFromFilenameV2(const QString &path, GameDemo &demo)
142 {
143  QFileInfo fileinfo(path);
144 
145  // Get the player info from the directory.
146  QDir yearDir = fileinfo.dir();
147  if (yearDir.path() != ".")
148  {
149  QDir playerDir = QFileInfo(yearDir.path()).dir();
150  if (playerDir.path() != ".")
151  {
152  demo.author = playerDir.dirName();
153  }
154  }
155 
156  QStringList tokens = fileinfo.baseName().split("_");
157  demo.time = DateTime::fromPathFriendlyUTCISO8601(tokens.takeLast());
158  demo.game = tokens.join("_");
159 }
160 
164 static void loadDemoMetaDataFromExportedFilename(const QString &path, GameDemo &demo)
165 {
166  QStringList tokens = QFileInfo(path).baseName().split("_");
167  demo.time = DateTime::fromPathFriendlyUTCISO8601(tokens.takeLast());
168  if (tokens.size() >= 1)
169  demo.game = tokens.takeLast();
170  if (tokens.size() >= 1)
171  demo.author = tokens.join("_");
172 }
173 
174 QString GameDemo::exportedName() const
175 {
176  const QString suffix = QFileInfo(this->demopath).suffix();
177  return QString("%1.%2").arg(exportedNameNoExtension(), suffix);
178 }
179 
181 {
182  static const QRegularExpression mangler("[^A-Za-z0-9-]");
183 
184  const QString mangledAuthor = !this->author.isEmpty()
185  ? QString(this->author)
186  .replace(mangler, "")
187  .left(MAX_AUTHOR_FILENAME_LEN)
188  : "unknownplayer";
189  const QString mangledGame = !this->game.isEmpty()
190  ? QString(this->game).replace(mangler, "")
191  : "unknowngame";
192  const QString formattedDate = this->time.isValid()
194  : "unknowntime";
195  return QString("%1_%2_%3")
196  .arg(mangledAuthor)
197  .arg(mangledGame)
198  .arg(formattedDate);
199 }
200 
201 QString GameDemo::managedName() const
202 {
203  const QString suffix = QFileInfo(this->demopath).suffix();
204  return QString("%1.%2").arg(managedNameNoExtension(), suffix);
205 }
206 
208 {
209  static const QRegularExpression mangler("[^A-Za-z0-9-]");
210 
211  QString mangledAuthor = QString(this->author)
212  .replace(mangler, "")
213  .left(MAX_AUTHOR_FILENAME_LEN);
214  if (mangledAuthor.isEmpty())
215  mangledAuthor = "_";
216  const QString year = this->time.isValid() ? QString("%1").arg(this->time.date().year(), 4, 10, QChar('0')) : "_";
217  const QString mangledGame = QString(this->game)
218  .replace(mangler, "");
219  const QString formattedDate = this->time.isValid()
221  : "unknown";
222  return QString("%1/%2/%3_%4")
223  .arg(mangledAuthor)
224  .arg(year)
225  .arg(mangledGame)
226  .arg(formattedDate);
227 }
228 
229 void GameDemo::imprintPath(const QString &path)
230 {
231  // Detect the filename format
232  static const QRegularExpression detectV1(R"(\d{2}\.\d{2}.\d{4}_\d{2}\.\d{2}\.\d{2})");
233  static const QRegularExpression detectV2(R"(\d{4}-\d{2}-\d{2}T\d{6}Z)");
234 
235  QString metaData = QFileInfo(path).completeBaseName();
236  if (detectV2.match(metaData).hasMatch())
237  {
238  if (metaData.count("_") > 1)
239  {
240  loadDemoMetaDataFromExportedFilename(path, *this);
241  }
242  else
243  {
244  loadDemoMetaDataFromFilenameV2(path, *this);
245  }
246  }
247  else if (detectV1.match(metaData).hasMatch())
248  {
249  loadDemoMetaDataFromFilenameV1(path, *this);
250  }
251 }
252 
253 /*
254  DemoRecord
255 */
256 DemoRecord::DemoRecord()
257 {
258  d.control = NoDemo;
259 }
260 
261 DemoRecord::DemoRecord(Control control)
262 {
263  d.control = control;
264 }
265 
266 DemoRecord::operator DemoRecord::Control() const
267 {
268  return d.control;
269 }
270 
271 /*
272  DemoStore
273 */
274 static const int DOOMSEEKER_METADATA_VERSION = 2;
275 
277 static const QString METAFILE_SUFFIX = "ini";
278 static const QString SECTION_DOOMSEEKER = "doomseeker";
279 static const QString SECTION_META = "meta";
280 
281 DClass<DemoStore>
282 {
283 public:
284  QDir root;
285 };
286 
287 DPointered(DemoStore);
288 
290 {
291  d->root = QDir(gDefaultDataPaths->demosDirectoryPath());
292 }
293 
295 {
296  d->root = root;
297 }
298 
300 {
301  QStringList extensionFilters;
302  for (const QString &ext : listDemoExtensions())
303  extensionFilters << QString("*.%1").arg(ext);
304  return extensionFilters;
305 }
306 
307 bool DemoStore::ensureStorageExists(QWidget *parent)
308 {
309  // This is not the proper place for UI code, but it's the convenient
310  // one because two different UI items need to call it. Yes, this also
311  // means this has the anti-pattern of knowing things about the callers.
312  auto popup = [parent](const QString &message) -> bool
313  {
314  return QMessageBox::Ignore ==
315  QMessageBox::warning(parent, GameDemoTr::title(), message,
316  QMessageBox::Abort | QMessageBox::Ignore);
317  };
318 
319  DirErrno mkResult = FileUtils::mkpath(d->root);
320  if (mkResult.isError())
321  {
322  return popup(GameDemoTr::pathDoesntExist(d->root.path(), mkResult.errnoString));
323  }
324 
325  QFileInfo demoDirInfo(d->root.path());
326  ++qt_ntfs_permission_lookup;
327  bool permissions = demoDirInfo.isReadable()
328  && demoDirInfo.isWritable()
329  && demoDirInfo.isExecutable();
330  --qt_ntfs_permission_lookup;
331  if (!permissions)
332  {
333  return popup(GameDemoTr::pathMissingPermissions(d->root.path()));
334  }
335 
336  return true;
337 }
338 
339 QStringList DemoStore::listDemoExtensions()
340 {
341  QStringList demoExtensions;
342  for (unsigned i = 0; i < gPlugins->numPlugins(); ++i)
343  {
344  QString spExt = gPlugins->info(i)->data()->demoExtension;
345  if (!demoExtensions.contains(spExt))
346  demoExtensions << spExt;
347 
348  QString mpExt = gPlugins->info(i)->data()->multiplayerDemoExtension;
349  if (!demoExtensions.contains(mpExt))
350  demoExtensions << mpExt;
351  }
352  return demoExtensions;
353 }
354 
356 {
357  const QString rootpath = d->root.path();
358  QDirIterator dirit(rootpath, demoFileFilters(),
359  QDir::Files, QDirIterator::Subdirectories);
360  QStringList result;
361  while (dirit.hasNext())
362  {
363  QString path = dirit.next();
364  result << path.mid(rootpath.length() + 1);
365  }
366  return result;
367 }
368 
370 {
371  QFile importedDemo(demo.demopath);
372  if (!importedDemo.exists())
373  return false;
374 
375  QFile managedDemo(d->root.filePath(demo.managedName()));
376  QFile metafile(DemoStore::metafile(managedDemo.fileName()));
377 
378  QDir managedDemoDir = QFileInfo(managedDemo).dir();
379  if (FileUtils::mkpath(managedDemoDir).isError())
380  return false;
381 
382  if (managedDemo.exists())
383  managedDemo.remove();
384  if (metafile.exists())
385  metafile.remove();
386 
387  if (!importedDemo.copy(managedDemo.fileName()))
388  return false;
389  saveDemoMetaData(demo, metafile.fileName());
390  return metafile.exists();
391 }
392 
393 QString DemoStore::metafile(const QString &path)
394 {
395  return path + "." + METAFILE_SUFFIX;
396 }
397 
398 QString DemoStore::mkDemoFullPath(DemoRecord::Control control, const EnginePlugin &plugin, const bool isMultiplayer)
399 {
400  GameDemo demo;
401  demo.demopath = QString("demo.%1").arg(isMultiplayer
402  ? plugin.data()->multiplayerDemoExtension
403  : plugin.data()->demoExtension);
404  demo.author = gConfig.doomseeker.realPlayerName();
405  demo.time = QDateTime::currentDateTime();
406  demo.game = plugin.nameCanonical();
407 
408  const bool extensionAddedByGame = isMultiplayer
409  ? plugin.data()->multiplayerDemoExtensionAutomatic
410  : plugin.data()->demoExtensionAutomatic;
411 
412  switch (control)
413  {
414  case DemoRecord::Managed:
415  {
416  QFileInfo demoPath(d->root.filePath(extensionAddedByGame
417  ? demo.managedNameNoExtension()
418  : demo.managedName()));
419  if (FileUtils::mkpath(demoPath.dir()).isError())
420  return QString();
421  return demoPath.absoluteFilePath();
422  }
424  return extensionAddedByGame
425  ? demo.exportedNameNoExtension()
426  : demo.exportedName();
427  case DemoRecord::NoDemo:
428  return QString();
429  default:
430  assert(0 && "Unknown demo control type");
431  return QString();
432  }
433 }
434 
435 bool DemoStore::removeManagedDemo(const QString &name)
436 {
437  QFile demoFile(d->root.filePath(name));
438  if (demoFile.exists())
439  {
440  if (!demoFile.remove())
441  return false;
442  }
443 
444  // Remove the metadata file as well, but don't bother warning
445  // if it can't be deleted for whatever reason.
446  QFile metaFile(metafile(demoFile.fileName()));
447  metaFile.remove();
448  return true;
449 }
450 
451 const QDir &DemoStore::root() const
452 {
453  return d->root;
454 }
455 
456 void DemoStore::saveDemoMetaData(const QString &demoName, const EnginePlugin &plugin,
457  const QString &iwad, const QList<PWad> &pwads, const bool isMultiplayer,
458  const QString &gameVersion)
459 {
460  // If the extension is automatic we need to add it here
461  bool isDemoExtensionAutomatic = isMultiplayer ?
462  plugin.data()->multiplayerDemoExtensionAutomatic : plugin.data()->demoExtensionAutomatic;
463  QString demoExtension = isMultiplayer ?
464  plugin.data()->multiplayerDemoExtension : plugin.data()->demoExtension;
465 
466  QString demoFileName = demoName;
467  if (isDemoExtensionAutomatic)
468  demoFileName += "." + plugin.data()->demoExtension;
469  QString metaFileName = metafile(demoFileName);
470 
471  GameDemo demo;
472  demo.imprintPath(demoFileName);
473  demo.game = plugin.nameCanonical();
474  demo.gameVersion = gameVersion;
475  demo.author = gConfig.doomseeker.realPlayerName();
476  demo.demopath = demoFileName;
477  demo.iwad = iwad;
478  demo.wads = pwads;
479 
480  saveDemoMetaData(demo, metaFileName);
481 }
482 
483 void DemoStore::saveDemoMetaData(const GameDemo &demo, const QString &path)
484 {
485  QSettings settings(path, QSettings::IniFormat);
486  SettingsProviderQt settingsProvider(&settings);
487  Ini metaFile(&settingsProvider);
488 
489  IniSection doomseekerSection = metaFile.section(SECTION_DOOMSEEKER);
490  doomseekerSection.setValue("version", DOOMSEEKER_METADATA_VERSION);
491 
492  IniSection metaSection = metaFile.section(SECTION_META);
493 
494  metaSection.setValue("author", demo.author);
495  metaSection.setValue("game", demo.game);
496  metaSection.setValue("gameVersion", demo.gameVersion);
497  metaSection.setValue("createdtime", DateTime::toISO8601(demo.time));
498 
499  metaSection.setValue("iwad", demo.iwad.toLower());
500  QVariantList wads;
501  for (const PWad &wad : demo.wads)
502  {
503  QVariantList serializedWad;
504  serializedWad << wad.name() << wad.isOptional();
505  wads << QVariant(serializedWad);
506  }
507 
508  // Convert the WAD objects to JSON to have them saved in a format
509  // that is at least somewhat human-readable.
510  metaSection.setValue("wads", QString::fromUtf8(
511  QJsonDocument::fromVariant(wads).toJson(QJsonDocument::Compact)));
512 
513  // Backward-compatibility with Doomseeker <1.5 (V1).
514  QStringList requiredPwads;
515  QStringList optionalPwads;
516  for (const PWad &wad : demo.wads)
517  {
518  if (wad.isOptional())
519  optionalPwads << wad.name();
520  else
521  requiredPwads << wad.name();
522  }
523  metaSection.setValue("pwads", requiredPwads.join(";"));
524  metaSection.setValue("optionalPwads", optionalPwads);
525 }
526 
527 static void loadDemoMetaDataFromIniV1(Ini &metaData, GameDemo &demo)
528 {
529  demo.iwad = metaData.retrieveSetting(SECTION_META, "iwad").valueString();
530  QString pwads = metaData.retrieveSetting(SECTION_META, "pwads");
531  if (pwads.length() > 0)
532  {
533  for (QString wad : pwads.split(";"))
534  {
535  if (!wad.isEmpty())
536  demo.wads << PWad(wad);
537  }
538  }
539 
540  for (QString wad : metaData.retrieveSetting(SECTION_META, "optionalPwads").value().toStringList())
541  {
542  demo.wads << PWad(wad, true) ;
543  }
544 }
545 
546 static void loadDemoMetaDataFromIniV2(Ini &metaData, GameDemo &demo)
547 {
548  demo.author = metaData.retrieveSetting(SECTION_META, "author").valueString();
549  demo.game = metaData.retrieveSetting(SECTION_META, "game").valueString();
550  demo.gameVersion = metaData.retrieveSetting(SECTION_META, "gameVersion").valueString();
551  demo.time = QDateTime::fromString(metaData.retrieveSetting(SECTION_META, "createdtime").valueString(), Qt::ISODate);
552  demo.iwad = metaData.retrieveSetting(SECTION_META, "iwad").valueString();
553  QVariantList wads = QJsonDocument::fromJson(metaData.retrieveSetting(SECTION_META, "wads").valueString().toUtf8()).toVariant().toList();
554  for (const QVariant &wad : wads)
555  {
556  QVariantList wadTokens = wad.toList();
557  if (wadTokens.size() >= 2)
558  {
559  QString name = wadTokens[0].toString();
560  bool optional = wadTokens[1].toBool();
561  demo.wads << PWad(name, optional);
562  }
563  }
564 }
565 
566 static void loadDemoMetaDataFromIni(const QString &path, GameDemo &demo)
567 {
568  QFileInfo iniFile(path + ".ini");
569  if (!iniFile.exists())
570  return;
571 
572  QSettings settings(iniFile.filePath(), QSettings::IniFormat);
573  SettingsProviderQt settingsProvider(&settings);
574  Ini metaData(&settingsProvider);
575  int version = metaData.section(SECTION_DOOMSEEKER)["version"].value().toInt();
576 
577  if (version >= 2) {
578  // Try to load any version greater than V2 as V2, assuming that
579  // future versions will maintain backward-compatibility with the
580  // most recent previous version.
581  loadDemoMetaDataFromIniV2(metaData, demo);
582  } else {
583  loadDemoMetaDataFromIniV1(metaData, demo);
584  }
585 }
586 
587 GameDemo DemoStore::loadGameDemo(const QString &path)
588 {
589  GameDemo demo;
590  demo.demopath = path.endsWith("." + METAFILE_SUFFIX)
591  ? path.left(path.length() - (1 + METAFILE_SUFFIX.length()))
592  : path;
593  demo.imprintPath(demo.demopath);
594  loadDemoMetaDataFromIni(demo.demopath, demo);
595  return demo;
596 }
597 
599 {
600  GameDemo demo;
601  QFile demoFile(d->root.filePath(name));
602  if (demoFile.exists())
603  demo = loadGameDemo(demoFile.fileName());
604  return demo;
605 }
606 
607 #include "gamedemo.moc"