1 /** @file gamestatefolder.cpp Archived game state.
2 *
3 * @authors Copyright © 2016-2017 Jaakko Keränen <jaakko.keranen@iki.fi>
4 * @authors Copyright © 2014 Daniel Swanson <danij@dengine.net>
5 *
6 * @par License
7 * GPL: http://www.gnu.org/licenses/gpl.html
8 *
9 * <small>This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation; either version 2 of the License, or (at your
12 * option) any later version. This program is distributed in the hope that it
13 * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
14 * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
15 * Public License for more details. You should have received a copy of the GNU
16 * General Public License along with this program; if not, see:
17 * http://www.gnu.org/licenses</small>
18 */
19
20 #include "doomsday/GameStateFolder"
21 #include "doomsday/AbstractSession"
22 #include "doomsday/DataBundle"
23
24 #include <de/App>
25 #include <de/ArchiveFolder>
26 #include <de/ArrayValue>
27 #include <de/Info>
28 #include <de/LogBuffer>
29 #include <de/NativePath>
30 #include <de/NumberValue>
31 #include <de/PackageLoader>
32 #include <de/TextValue>
33 #include <de/Writer>
34 #include <de/ZipArchive>
35
36 using namespace de;
37
38 static String const BLOCK_GROUP = "group";
39 static String const BLOCK_GAMERULE = "gamerule";
40
41 /// @todo Refactor this to use ScriptedInfo. -jk
42
makeValueFromInfoValue(de::Info::Element::Value const & v)43 static Value *makeValueFromInfoValue(de::Info::Element::Value const &v)
44 {
45 String const text = v;
46 if (!text.compareWithoutCase("True"))
47 {
48 return new NumberValue(true, NumberValue::Boolean);
49 }
50 else if (!text.compareWithoutCase("False"))
51 {
52 return new NumberValue(false, NumberValue::Boolean);
53 }
54 else
55 {
56 return new TextValue(text);
57 }
58 }
59
DENG2_PIMPL(GameStateFolder)60 DENG2_PIMPL(GameStateFolder)
61 {
62 Metadata metadata; ///< Cached.
63 bool needCacheMetadata;
64
65 Impl(Public *i)
66 : Base(i)
67 , needCacheMetadata(true)
68 {}
69
70 bool readMetadata(Metadata &metadata)
71 {
72 try
73 {
74 Block raw;
75 self().locate<File const>("Info") >> raw;
76
77 metadata.parse(String::fromUtf8(raw));
78
79 // So far so good.
80 return true;
81 }
82 catch (IByteArray::OffsetError const &)
83 {
84 LOG_RES_WARNING("Archive in %s is truncated") << self().description();
85 }
86 catch (IIStream::InputError const &)
87 {
88 LOG_RES_WARNING("%s cannot be read") << self().description();
89 }
90 catch (Archive::FormatError const &)
91 {
92 LOG_RES_WARNING("Archive in %s is invalid") << self().description();
93 }
94 catch (Folder::NotFoundError const &)
95 {
96 LOG_RES_WARNING("%s does not appear to be a .save package") << self().description();
97 }
98 return 0;
99 }
100
101 DENG2_PIMPL_AUDIENCE(MetadataChange)
102 };
103
DENG2_AUDIENCE_METHOD(GameStateFolder,MetadataChange)104 DENG2_AUDIENCE_METHOD(GameStateFolder, MetadataChange)
105
106 GameStateFolder::GameStateFolder(File &sourceArchiveFile, String const &name)
107 : ArchiveFolder(sourceArchiveFile, name)
108 , d(new Impl(this))
109 {}
110
~GameStateFolder()111 GameStateFolder::~GameStateFolder()
112 {
113 DENG2_FOR_AUDIENCE2(Deletion, i) i->fileBeingDeleted(*this);
114 audienceForDeletion().clear();
115 deindex();
116 //Session::savedIndex().remove(path());
117 }
118
119 /*void GameStateFolder::populate(PopulationBehaviors behavior)
120 {
121 ArchiveFolder::populate(behavior);
122 //Session::savedIndex().add(*this);
123 }*/
124
readMetadata()125 void GameStateFolder::readMetadata()
126 {
127 LOGDEV_VERBOSE("Updating GameStateFolder metadata %p") << this;
128
129 // Determine if a .save package exists in the repository and if so, read the metadata.
130 Metadata newMetadata;
131 if (!d->readMetadata(newMetadata))
132 {
133 // Unrecognized or the file could not be accessed (perhaps its a network path?).
134 // Return the session to the "null/invalid" state.
135 newMetadata.set("userDescription", "");
136 newMetadata.set("sessionId", duint32(0));
137 }
138
139 cacheMetadata(newMetadata);
140 }
141
metadata() const142 GameStateFolder::Metadata const &GameStateFolder::metadata() const
143 {
144 if (d->needCacheMetadata)
145 {
146 const_cast<GameStateFolder *>(this)->readMetadata();
147 }
148 return d->metadata;
149 }
150
cacheMetadata(Metadata const & copied)151 void GameStateFolder::cacheMetadata(Metadata const &copied)
152 {
153 d->metadata = copied;
154 d->needCacheMetadata = false;
155 DENG2_FOR_AUDIENCE2(MetadataChange, i)
156 {
157 i->gameStateFolderMetadataChanged(*this);
158 }
159 }
160
stateFilePath(String const & path)161 String GameStateFolder::stateFilePath(String const &path) //static
162 {
163 if (!path.fileName().isEmpty())
164 {
165 return path + "State";
166 }
167 return "";
168 }
169
isPackageAffectingGameplay(String const & packageId)170 bool GameStateFolder::isPackageAffectingGameplay(String const &packageId) // static
171 {
172 /**
173 * @todo The rules here could be more sophisticated when it comes to checking what
174 * exactly do the data bundles contain. Also, packages should be checked for any
175 * gameplay-affecting assets. (2016-07-06: Currently there are none.)
176 */
177 if (auto const *bundle = DataBundle::bundleForPackage(packageId))
178 {
179 // Collections can be configured, so we need to list the actual files in use
180 // rather than just the collection itself.
181 return bundle->format() != DataBundle::Collection;
182 }
183
184 if (File const *selected = PackageLoader::get().select(packageId))
185 {
186 auto const &meta = Package::metadata(*selected);
187 if (meta.has("dataFiles") && meta.geta("dataFiles").size() > 0)
188 {
189 // Data files are assumed to affect gameplay.
190 return true;
191 }
192 }
193 return false;
194 }
195
interpretFile(File * sourceData) const196 File *GameStateFolder::Interpreter::interpretFile(File *sourceData) const
197 {
198 try
199 {
200 if (ZipArchive::recognize(*sourceData))
201 {
202 // It is a ZIP archive: we will represent it as a folder.
203 if (sourceData->extension() == ".save")
204 {
205 /// @todo fixme: Don't assume this is a save package.
206 LOG_RES_XVERBOSE("Interpreted %s as a GameStateFolder", sourceData->description());
207 std::unique_ptr<ArchiveFolder> package;
208 package.reset(new GameStateFolder(*sourceData, sourceData->name()));
209
210 // Archive opened successfully, give ownership of the source to the folder.
211 package->setSource(sourceData);
212 return package.release();
213 }
214 }
215 }
216 catch (Error const &er)
217 {
218 // Even though it was recognized as an archive, the file
219 // contents may still prove to be corrupted.
220 LOG_RES_WARNING("Failed to read archive in %s: %s")
221 << sourceData->description()
222 << er.asText();
223 }
224 return nullptr;
225 }
226
227 //---------------------------------------------------------------------------------------
228
DENG2_PIMPL_NOREF(GameStateFolder::MapStateReader)229 DENG2_PIMPL_NOREF(GameStateFolder::MapStateReader)
230 {
231 GameStateFolder const *session; ///< Saved session being read. Not owned.
232
233 Impl(GameStateFolder const &session) : session(&session)
234 {}
235 };
236
MapStateReader(GameStateFolder const & session)237 GameStateFolder::MapStateReader::MapStateReader(GameStateFolder const &session)
238 : d(new Impl(session))
239 {}
240
~MapStateReader()241 GameStateFolder::MapStateReader::~MapStateReader()
242 {}
243
metadata() const244 GameStateFolder::Metadata const &GameStateFolder::MapStateReader::metadata() const
245 {
246 return d->session->metadata();
247 }
248
folder() const249 Folder const &GameStateFolder::MapStateReader::folder() const
250 {
251 return *d->session;
252 }
253
254 //---------------------------------------------------------------------------------------
255
parse(String const & source)256 void GameStateFolder::Metadata::parse(String const &source)
257 {
258 try
259 {
260 clear();
261
262 Info info;
263 info.setAllowDuplicateBlocksOfType(QStringList() << BLOCK_GROUP << BLOCK_GAMERULE);
264 info.parse(source);
265
266 // Rebuild the game rules subrecord.
267 Record &rules = addSubrecord("gameRules");
268 foreach (Info::Element const *elem, info.root().contentsInOrder())
269 {
270 if (Info::KeyElement const *key = maybeAs<Info::KeyElement>(elem))
271 {
272 QScopedPointer<Value> v(makeValueFromInfoValue(key->value()));
273 add(key->name()) = v.take();
274 continue;
275 }
276 if (Info::ListElement const *list = maybeAs<Info::ListElement>(elem))
277 {
278 QScopedPointer<ArrayValue> arr(new ArrayValue);
279 foreach (Info::Element::Value const &v, list->values())
280 {
281 *arr << makeValueFromInfoValue(v);
282 }
283 addArray(list->name(), arr.take());
284 continue;
285 }
286 if (Info::BlockElement const *block = maybeAs<Info::BlockElement>(elem))
287 {
288 // Perhaps a ruleset group?
289 if (block->blockType() == BLOCK_GROUP)
290 {
291 foreach (Info::Element const *grpElem, block->contentsInOrder())
292 {
293 if (!grpElem->isBlock()) continue;
294
295 // Perhaps a gamerule?
296 Info::BlockElement const &ruleBlock = grpElem->as<Info::BlockElement>();
297 if (ruleBlock.blockType() == BLOCK_GAMERULE)
298 {
299 QScopedPointer<Value> v(makeValueFromInfoValue(ruleBlock.keyValue("value")));
300 rules.add(ruleBlock.name()) = v.take();
301 }
302 }
303 }
304 continue;
305 }
306 }
307
308 // Ensure the map URI has the "Maps" scheme set.
309 if (!gets("mapUri").beginsWith("Maps:", String::CaseInsensitive))
310 {
311 set("mapUri", String("Maps:") + gets("mapUri"));
312 }
313
314 // Ensure the episode is known. Earlier versions of the savegame format did not save
315 // this info explicitly. The assumption was that the episode was inferred by / encoded
316 // in the map URI. If the episode is not present in the metadata then we'll assume it
317 // is encoded in the map URI and extract it.
318 if (!has("episode"))
319 {
320 String const mapUriPath = gets("mapUri").substr(5);
321 if (mapUriPath.beginsWith("MAP", String::CaseInsensitive))
322 {
323 set("episode", "1");
324 }
325 else if (mapUriPath.at(0).toLower() == 'e' && mapUriPath.at(2).toLower() == 'm')
326 {
327 set("episode", mapUriPath.substr(1, 1));
328 }
329 else
330 {
331 // Hmm, very odd...
332 throw Error("GameStateFolder::metadata::parse", "Failed to extract episode id from map URI \"" + gets("mapUri") + "\"");
333 }
334 }
335
336 if (info.root().contains("packages"))
337 {
338 Info::ListElement const &list = info.root().find("packages")->as<Info::ListElement>();
339 auto *pkgs = new ArrayValue;
340 for (auto const &value : list.values())
341 {
342 *pkgs << new TextValue(value.text);
343 }
344 set("packages", pkgs);
345 }
346 else
347 {
348 set("packages", new ArrayValue);
349 }
350
351 // Ensure we have a valid description.
352 if (gets("userDescription").isEmpty())
353 {
354 set("userDescription", "UNNAMED");
355 }
356
357 //qDebug() << "Parsed save metadata:\n" << asText();
358 }
359 catch (Error const &er)
360 {
361 LOG_WARNING(er.asText());
362 }
363 }
364
asStyledText() const365 String GameStateFolder::Metadata::asStyledText() const
366 {
367 String currentMapText = String(
368 _E(Ta)_E(l) " Episode: " _E(.)_E(Tb) "%1\n"
369 _E(Ta)_E(l) " Uri: " _E(.)_E(Tb) "%2")
370 .arg(gets("episode"))
371 .arg(gets("mapUri"));
372 // Is the time in the current map known?
373 if (has("mapTime"))
374 {
375 int time = geti("mapTime") / 35 /*TICRATE*/;
376 int const hours = time / 3600; time -= hours * 3600;
377 int const minutes = time / 60; time -= minutes * 60;
378 int const seconds = time;
379 currentMapText += String("\n" _E(Ta)_E(l) " Time: " _E(.)_E(Tb) "%1:%2:%3" )
380 .arg(hours, 2, 10, QChar('0'))
381 .arg(minutes, 2, 10, QChar('0'))
382 .arg(seconds, 2, 10, QChar('0'));
383 }
384
385 QStringList rules = gets("gameRules", "None").split("\n", QString::SkipEmptyParts);
386 rules.replaceInStrings(QRegExp("\\s*(.*)\\s*:\\s*([^ ].*)\\s*"), _E(l) "\\1: " _E(.) "\\2");
387 String gameRulesText = String::join(toStringList(rules), "\n - ");
388
389 auto const &pkgs = geta("packages");
390 StringList pkgIds;
391 for (auto const *val : pkgs.elements())
392 {
393 pkgIds << Package::splitToHumanReadable(val->asText());
394 }
395
396 return String(_E(1) "%1\n" _E(.)
397 _E(Ta)_E(l) " Game: " _E(.)_E(Tb) "%2\n"
398 _E(Ta)_E(l) " Session ID: " _E(.)_E(Tb)_E(m) "0x%3" _E(.) "\n"
399 _E(T`)_E(D) "Current map:\n" _E(.) "%4\n"
400 _E(T`)_E(D) "Game rules:\n" _E(.) " - %5\n"
401 _E(T`)_E(D) "Packages:\n" _E(.) " - %6")
402 .arg(gets("userDescription", ""))
403 .arg(gets("gameIdentityKey", ""))
404 .arg(getui("sessionId", 0), 0, 16)
405 .arg(currentMapText)
406 .arg(gameRulesText)
407 .arg(String::join(pkgIds, "\n - "));
408 }
409
410 /*
411 * See the Doomsday Wiki for an example of the syntax:
412 * http://dengine.net/dew/index.php?title=Info
413 */
asInfo() const414 String GameStateFolder::Metadata::asInfo() const
415 {
416 /// @todo Use a more generic Record => Info conversion logic.
417
418 String text;
419 QTextStream os(&text);
420 os.setCodec("UTF-8");
421
422 if (has("gameIdentityKey")) os << "gameIdentityKey: " << gets("gameIdentityKey");
423 if (has("packages"))
424 {
425 os << "\npackages " << geta("packages").asInfo();
426 }
427 if (has("episode")) os << "\nepisode: " << gets("episode");
428 if (has("mapTime")) os << "\nmapTime: " << String::number(geti("mapTime"));
429 if (has("mapUri")) os << "\nmapUri: " << gets("mapUri");
430 if (has("players"))
431 {
432 os << "\nplayers <";
433 ArrayValue const &playersArray = geta("players");
434 DENG2_FOR_EACH_CONST(ArrayValue::Elements, i, playersArray.elements())
435 {
436 Value const *value = *i;
437 if (i != playersArray.elements().begin()) os << ", ";
438 os << (value->as<NumberValue>().isTrue()? "True" : "False");
439 }
440 os << ">";
441 }
442 if (has("visitedMaps"))
443 {
444 os << "\nvisitedMaps " << geta("visitedMaps").asInfo();
445 }
446 if (has("sessionId")) os << "\nsessionId: " << String::number(geti("sessionId"));
447 if (has("userDescription")) os << "\nuserDescription: " << gets("userDescription");
448
449 if (hasSubrecord("gameRules"))
450 {
451 os << "\n" << BLOCK_GROUP << " ruleset {";
452
453 Record const &rules = subrecord("gameRules");
454 DENG2_FOR_EACH_CONST(Record::Members, i, rules.members())
455 {
456 Value const &value = i.value()->value();
457 String valueAsText = value.asText();
458 if (is<Value::Text>(value))
459 {
460 valueAsText = "\"" + valueAsText.replace("\"", "''") + "\"";
461 }
462 os << "\n " << BLOCK_GAMERULE << " \"" << i.key() << "\""
463 << " { value = " << valueAsText << " }";
464 }
465
466 os << "\n}";
467 }
468
469 return text;
470 }
471