# Copyright 2004-2019 Tom Rothamel # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # This file contains functions that load and save the game state. from __future__ import print_function import pickle import cPickle from cStringIO import StringIO import zipfile import re import threading import types import shutil import os import sys import renpy from renpy import six from json import dumps as json_dumps # Dump that chooses which pickle to use: def dump(o, f): if renpy.config.use_cpickle: cPickle.dump(o, f, cPickle.HIGHEST_PROTOCOL) else: pickle.dump(o, f, pickle.HIGHEST_PROTOCOL) def dumps(o): if renpy.config.use_cpickle: return cPickle.dumps(o, cPickle.HIGHEST_PROTOCOL) else: return pickle.dumps(o, pickle.HIGHEST_PROTOCOL) def loads(s): if renpy.config.use_cpickle: return cPickle.loads(s) else: return pickle.loads(s) # This is used as a quick and dirty way of versioning savegame # files. savegame_suffix = renpy.savegame_suffix def save_dump(roots, log): """ Dumps information about the save to save_dump.txt. We dump the size of the object (including unique children), the path to the object, and the type or repr of the object. """ o_repr_cache = { } def visit(o, path): ido = id(o) if ido in o_repr_cache: f.write("{0: 7d} {1} = alias {2}\n".format(0, path, o_repr_cache[ido])) return 0 if isinstance(o, (int, float, types.NoneType, types.ModuleType, types.ClassType)): o_repr = repr(o) elif isinstance(o, (str, unicode)): if len(o) <= 80: o_repr = repr(o).encode("utf-8") else: o_repr = repr(o[:80] + "...").encode("utf-8") elif isinstance(o, (tuple, list)): o_repr = "<" + o.__class__.__name__ + ">" elif isinstance(o, dict): o_repr = "<" + o.__class__.__name__ + ">" elif isinstance(o, types.MethodType): o_repr = "".format(o.im_class.__name__, o.im_func.__name__) elif isinstance(o, object): o_repr = "<{0}>".format(type(o).__name__) else: o_repr = "BAD TYPE <{0}>".format(type(o).__name__) o_repr_cache[ido] = o_repr if isinstance(o, (int, float, types.NoneType, types.ModuleType, types.ClassType)): size = 1 elif isinstance(o, (str, unicode)): size = len(o) / 40 + 1 elif isinstance(o, (tuple, list)): size = 1 for i, oo in enumerate(o): size += 1 size += visit(oo, "{0}[{1!r}]".format(path, i)) elif isinstance(o, dict): size = 2 for k, v in o.iteritems(): size += 2 size += visit(v, "{0}[{1!r}]".format(path, k)) elif isinstance(o, types.MethodType): size = 1 + visit(o.im_self, path + ".im_self") else: try: reduction = o.__reduce_ex__(2) except: reduction = [ ] o_repr = "BAD REDUCTION " + o_repr # Gets an element from the reduction, or o if we don't have # such an element. def get(idx, default): if idx < len(reduction) and reduction[idx] is not None: return reduction[idx] else: return default # An estimate of the size of the object, in arbitrary units. (These units are about 20-25 bytes on # my computer.) size = 1 state = get(2, { }) if isinstance(state, dict): for k, v in state.iteritems(): size += 2 size += visit(v, path + "." + k) else: size += visit(state, path + ".__getstate__()") for i, oo in enumerate(get(3, [])): size += 1 size += visit(oo, "{0}[{1}]".format(path, i)) for i in get(4, []): if len(i) != 2: continue k, v = i size += 2 size += visit(v, "{0}[{1!r}]".format(path, k)) f.write("{0: 7d} {1} = {2}\n".format(size, path, o_repr_cache[ido])) return size f, _ = renpy.error.open_error_file("save_dump.txt", "w") visit(roots, "roots") visit(log, "log") f.close() def find_bad_reduction(roots, log): """ Finds objects that can't be reduced properly. """ seen = set() def visit(o, path): ido = id(o) if ido in seen: return seen.add(ido) if isinstance(o, (int, float, types.NoneType, types.ClassType)): return if isinstance(o, (tuple, list)): for i, oo in enumerate(o): rv = visit(oo, "{0}[{1!r}]".format(path, i)) if rv is not None: return rv elif isinstance(o, dict): for k, v in o.iteritems(): rv = visit(v, "{0}[{1!r}]".format(path, k)) if rv is not None: return rv elif isinstance(o, types.MethodType): return visit(o.im_self, path + ".im_self") elif isinstance(o, types.ModuleType): return "{} = {}".format(path, repr(o)[:160]) else: try: reduction = o.__reduce_ex__(2) except: import copy try: copy.copy(o) return None except: pass return "{} = {}".format(path, repr(o)[:160]) # Gets an element from the reduction, or o if we don't have # such an element. def get(idx, default): if idx < len(reduction) and reduction[idx] is not None: return reduction[idx] else: return default state = get(2, { }) if isinstance(state, dict): for k, v in state.iteritems(): rv = visit(v, path + "." + k) if rv is not None: return rv else: rv = visit(state, path + ".__getstate__()") if rv is not None: return rv for i, oo in enumerate(get(3, [])): rv = visit(oo, "{0}[{1}]".format(path, i)) if rv is not None: return rv for i in get(4, []): if len(i) != 2: continue k, v = i rv = visit(v, "{0}[{1!r}]".format(path, k)) if rv is not None: return rv return None for k, v in roots.items(): rv = visit(v, k) if rv is not None: return rv return visit(log, "renpy.game.log") ################################################################################ # Saving ################################################################################ # Used to indicate an aborted save, due to the game being mutated # while the save is in progress. class SaveAbort(Exception): pass def safe_rename(old, new): """ Safely rename old to new. """ if os.path.exists(new): os.unlink(new) try: os.rename(old, new) except: # If the rename failed, try again. try: os.unlink(new) os.rename(old, new) except: # If it fails a second time, give up. try: os.unlink(old) except: pass class SaveRecord(object): """ This is passed to the save locations. It contains the information that goes into a save file in uncompressed form, and the logic to save that information to a Ren'Py-standard format save file. """ def __init__(self, screenshot, extra_info, json, log): self.screenshot = screenshot self.extra_info = extra_info self.json = json self.log = log self.first_filename = None def write_file(self, filename): """ This writes a standard-format savefile to `filename`. """ filename_new = filename + ".new" # For speed, copy the file after we've written it at least once. if self.first_filename is not None: shutil.copyfile(self.first_filename, filename_new) safe_rename(filename_new, filename) return zf = zipfile.ZipFile(filename_new, "w", zipfile.ZIP_DEFLATED) # Screenshot. zf.writestr("screenshot.png", self.screenshot) # Extra info. zf.writestr("extra_info", self.extra_info.encode("utf-8")) # Json zf.writestr("json", self.json) # Version. zf.writestr("renpy_version", renpy.version) # The actual game. zf.writestr("log", self.log) zf.close() safe_rename(filename_new, filename) self.first_filename = filename def save(slotname, extra_info='', mutate_flag=False): """ :doc: loadsave :args: (filename, extra_info='') Saves the game state to a save slot. `filename` A string giving the name of a save slot. Despite the variable name, this corresponds only loosely to filenames. `extra_info` An additional string that should be saved to the save file. Usually, this is the value of :var:`save_name`. :func:`renpy.take_screenshot` should be called before this function. """ if mutate_flag: renpy.python.mutate_flag = False roots = renpy.game.log.freeze(None) if renpy.config.save_dump: save_dump(roots, renpy.game.log) logf = StringIO() try: dump((roots, renpy.game.log), logf) except: t, e, tb = sys.exc_info() if mutate_flag: six.reraise(t, e, tb) try: bad = find_bad_reduction(roots, renpy.game.log) except: six.reraise(t, e, tb) if bad is None: six.reraise(t, e, tb) e.args = ( e.args[0] + ' (perhaps {})'.format(bad), ) + e.args[1:] six.reraise(t, e, tb) if mutate_flag and renpy.python.mutate_flag: raise SaveAbort() screenshot = renpy.game.interface.get_screenshot() json = { "_save_name" : extra_info, "_renpy_version" : list(renpy.version_tuple), "_version" : renpy.config.version } for i in renpy.config.save_json_callbacks: i(json) json = json_dumps(json) sr = SaveRecord(screenshot, extra_info, json, logf.getvalue()) location.save(slotname, sr) location.scan() clear_slot(slotname) # The thread used for autosave. autosave_thread = None # Flag that lets us know if an autosave is in progress. autosave_not_running = threading.Event() autosave_not_running.set() # The number of times autosave has been called without a save occuring. autosave_counter = 0 def autosave_thread_function(take_screenshot): global autosave_counter try: try: cycle_saves("auto-", renpy.config.autosave_slots) if renpy.config.auto_save_extra_info: extra_info = renpy.config.auto_save_extra_info() else: extra_info = "" if take_screenshot: renpy.exports.take_screenshot(background=True) save("auto-1", mutate_flag=True, extra_info=extra_info) autosave_counter = 0 except: pass finally: autosave_not_running.set() if renpy.emscripten: import emscripten emscripten.syncfs() def autosave(): global autosave_counter if not renpy.config.autosave_frequency: return # That is, autosave is running. if not autosave_not_running.isSet(): return if renpy.config.skipping: return if len(renpy.game.contexts) > 1: return autosave_counter += 1 if autosave_counter < renpy.config.autosave_frequency: return if renpy.store.main_menu: return force_autosave(True) # This assumes a screenshot has already been taken. def force_autosave(take_screenshot=False, block=False): """ :doc: other Forces a background autosave to occur. `take_screenshot` If True, a new screenshot will be taken. If False, the existing screenshot will be used. `block` If True, blocks until the autosave completes. """ global autosave_thread if renpy.game.after_rollback or renpy.exports.in_rollback(): return # That is, autosave is running. if not autosave_not_running.isSet(): return # Join the autosave thread to clear resources. if autosave_thread is not None: autosave_thread.join() autosave_thread = None # Do not save if we're in the main menu. if renpy.store.main_menu: return # Do not save if we're in a replay. if renpy.store._in_replay: return if block: if renpy.config.auto_save_extra_info: extra_info = renpy.config.auto_save_extra_info() else: extra_info = "" cycle_saves("auto-", renpy.config.autosave_slots) if take_screenshot: renpy.exports.take_screenshot() save("auto-1", extra_info=extra_info) return autosave_not_running.clear() if not renpy.emscripten: autosave_thread = threading.Thread(target=autosave_thread_function, args=(take_screenshot,)) autosave_thread.daemon = True autosave_thread.start() else: import emscripten emscripten.async_call(autosave_thread_function, take_screenshot, -1) ################################################################################ # Loading and Slot Manipulation ################################################################################ def scan_saved_game(slotname): c = get_cache(slotname) mtime = c.get_mtime() if mtime is None: return None json = c.get_json() if json is None: return None extra_info = json.get("_save_name", "") screenshot = c.get_screenshot() if screenshot is None: return None return extra_info, screenshot, mtime def list_saved_games(regexp=r'.', fast=False): """ :doc: loadsave Lists the save games. For each save game, returns a tuple containing: * The filename of the save. * The extra_info that was passed in. * A displayable that, when displayed, shows the screenshot that was used when saving the game. * The time the game was stayed at, in seconds since the UNIX epoch. `regexp` A regular expression that is matched against the start of the filename to filter the list. `fast` If fast is true, the filename is returned instead of the tuple. """ # A list of save slots. slots = location.list() if regexp is not None: slots = [ i for i in slots if re.match(regexp, i) ] slots.sort() if fast: return slots rv = [ ] for s in slots: c = get_cache(s) if c is not None: json = c.get_json() if json is not None: extra_info = json.get("_save_name", "") else: extra_info = "" screenshot = c.get_screenshot() mtime = c.get_mtime() rv.append((s, extra_info, screenshot, mtime)) return rv def list_slots(regexp=None): """ :doc: loadsave Returns a list of non-empty save slots. If `regexp` exists, only slots that begin with `regexp` are returned. The slots are sorted in string-order. """ # A list of save slots. slots = location.list() if regexp is not None: slots = [ i for i in slots if re.match(regexp, i) ] slots.sort() return slots # A cache for newest slot info. newest_slot_cache = { } def newest_slot(regexp=None): """ :doc: loadsave Returns the name of the newest save slot (the save slot with the most recent modification time), or None if there are no (matching) saves. If `regexp` exists, only slots that begin with `regexp` are returned. """ rv = newest_slot_cache.get(regexp, unknown) if rv is unknown: max_mtime = 0 rv = None slots = location.list() for i in slots: if (regexp is not None) and (not re.match(regexp, i)): continue mtime = get_cache(i).get_mtime() if mtime is None: continue if mtime >= max_mtime: rv = i max_mtime = mtime newest_slot_cache[regexp] = rv return rv def slot_mtime(slotname): """ :doc: loadsave Returns the modification time for `slot`, or None if the slot is empty. """ return get_cache(slotname).get_mtime() def slot_json(slotname): """ :doc: loadsave Returns the json information for `slotname`, or None if the slot is empty. """ return get_cache(slotname).get_json() def slot_screenshot(slotname): """ :doc: loadsave Returns a display that can be used as the screenshot for `slotname`, or None if the slot is empty. """ return get_cache(slotname).get_screenshot() def can_load(filename, test=False): """ :doc: loadsave Returns true if `filename` exists as a save slot, and False otherwise. """ c = get_cache(filename) if c.get_mtime(): return True else: return False def load(filename): """ :doc: loadsave Loads the game state from the save slot `filename`. If the file is loaded successfully, this function never returns. """ roots, log = loads(location.load(filename)) log.unfreeze(roots, label="_after_load") def unlink_save(filename): """ :doc: loadsave Deletes the save slot with the given name. """ location.unlink(filename) clear_slot(filename) def rename_save(old, new): """ :doc: loadsave Renames a save from `old` to `new`. (Does nothing if `old` does not exist.) """ location.rename(old, new) clear_slot(old) clear_slot(new) def copy_save(old, new): """ :doc: loadsave Copies the save at `old` to `new`. (Does nothing if `old` does not exist.) """ location.copy(old, new) clear_slot(new) def cycle_saves(name, count): """ :doc: loadsave Rotates the first `count` saves beginning with `name`. For example, if the name is auto- and the count is 10, then auto-9 will be renamed to auto-10, auto-8 will be renamed to auto-9, and so on until auto-1 is renamed to auto-2. """ for i in range(count - 1, 0, -1): rename_save(name + str(i), name + str(i + 1)) ################################################################################ # Cache ################################################################################ # None is a possible value for some of the attributes. unknown = renpy.object.Sentinel("unknown") class Cache(object): """ This represents cached information about a save slot. """ def __init__(self, slotname): self.slotname = slotname self.clear() def clear(self): # The time the save was created. self.mtime = unknown # The json object loaded from the save slot. self.json = unknown # The screenshot associated with the save slot. self.screenshot = unknown def get_mtime(self): rv = self.mtime if rv is unknown: rv = self.mtime = location.mtime(self.slotname) return rv def get_json(self): rv = self.json if rv is unknown: rv = self.json = location.json(self.slotname) return rv def get_screenshot(self): rv = self.screenshot if rv is unknown: rv = self.screenshot = location.screenshot(self.slotname) return self.screenshot def preload(self): """ Preloads all the save data (that won't take up a ton of memory). """ self.get_mtime() self.get_json() self.get_screenshot() # A map from slotname to cache object. This is used to cache savegame scan # data until the slot changes. cache = { } def get_cache(slotname): rv = cache.get(slotname, None) if rv is None: rv = cache[slotname] = Cache(slotname) return rv def clear_slot(slotname): """ Clears a single slot in the cache. """ get_cache(slotname).clear() newest_slot_cache.clear() renpy.exports.restart_interaction() def clear_cache(): """ Clears the entire cache. """ for c in cache.values(): c.clear() newest_slot_cache.clear() renpy.exports.restart_interaction() def init(): """ Scans all the metadata from the save slot cache. """ for i in list_slots(): if not i.startswith("_"): get_cache(i).preload() # Save locations are places where saves are saved to or loaded from, or a # collection of such locations. This is the default save location. location = None if False: location = renpy.savelocation.FileLocation("blah")