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