CampBuddy/Camp.Buddy v2.2.1/Camp_Buddy-2.2.1-pc/renpy/loadsave.py
2025-03-03 23:00:33 +01:00

945 lines
22 KiB
Python

# Copyright 2004-2019 Tom Rothamel <pytom@bishoujo.us>
#
# 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 = "<method {0}.{1}>".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")