Skip to content
Snippets Groups Projects
SavefilePipe.lua 10.4 KiB
Newer Older
DarkGod's avatar
DarkGod committed
-- Copyright (C) 2009 - 2015 Nicolas Casalini
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.
--
-- Nicolas Casalini "DarkGod"
-- darkgod@te4.org

require "engine.class"
dg's avatar
dg committed
local Dialog = require "engine.ui.Dialog"

--- Savefile code
-- Creates a savefile pipe, savefiles requests can be pushed into it, it will execute them
-- in order and, as much as possible, as a background process, thus not preventing the game from running.<br/>
-- There can only be one pipe! DO NOT TRY TO MAKE MORE
-- @classmod engine.SavefilePipe
module(..., package.seeall, class.make)

_M.current = nil

--- Init a savefile pipe
-- @param class the name of the Savefile class to use. Defaults to engine.Savefile
-- @param max_before_wait Number of saves allowed in the pipe before the game pauses to do the saves (this is to prevent pushing too many saves and making it very very long)
function _M:init(class, max_before_wait)
	assert(not _M.current, "Tried to create more than one savefile pipe!")
	_M.current = self

	self.saveclass = class or "engine.Savefile"
	self.pipe = {}
DarkGod's avatar
DarkGod committed
	self.pipe_types = {}
dg's avatar
dg committed
	self.max_before_wait = max_before_wait or 6
	self.current_nb = 0
	self.total_nb = 1
DarkGod's avatar
DarkGod committed
--- Cleanup old saves in steam cloud if needed
function _M:steamCleanup()
	if not util.steamCanCloud() then return end
DarkGod's avatar
DarkGod committed
	local oldns = core.steam.getFileNamespace()
	core.steam.setFileNamespace("")

	print("[SAVEFILE PIPE] Cleaning up steam cloud")
	local avail = core.steam.getCloudSize() / 1024 / 1024
	print("[SAVEFILE PIPE] Cloud has "..avail.."MB available")

	-- Still have room left
DarkGod's avatar
DarkGod committed
	local MAX_ALLOWED = 200
DarkGod's avatar
DarkGod committed
	if avail >= MAX_ALLOWED then core.steam.setFileNamespace(oldns) return end
	print("[SAVEFILE PIPE] Steam cloud missing space, cleaning up old saves")

	local saves = {}
	local list = core.steam.listFilesEndingWith("game.teag")
	for _, file in ipairs(list) do
		local _, _, modname, char = file:find("^([^/]+)/save/([^/]+)/game%.teag$")
		if modname then
			local ts = core.steam.timestampFile(file)
			saves[#saves+1] = {file=file, ts=ts, name=char, mod=modname}
		end
	end
	table.sort(saves, function(a, b) return a.ts < b.ts end)

	-- Forget until we have space
	while avail < MAX_ALLOWED and #saves > 0 do
		local save = table.remove(saves, 1)
		print("[SAVEFILE PIPE] Cloud Forgetting save", save.mod, save.name)
		for _, file in ipairs(core.steam.listFilesStartingWith(save.mod.."/save/"..save.name.."/")) do
			print("\t* "..file)
			core.steam.forgetFile(file)
		end
		avail = core.steam.getCloudSize() / 1024 / 1024
		print("\t => Cloud has "..avail.."MB available")
	end


	core.steam.setFileNamespace(oldns)
end

DarkGod's avatar
DarkGod committed
--- Disables/enables clean saves
-- @param v true or false
function _M:ignoreCloudSave(v)
	self.disable_cloud_saves = v
end

--- Push a savefile request
-- @param savename the name of the savefile to handle
-- @param type the Savefile method to use. I.e: "game", "level", "zone". This will cann the Savefile:saveGame, Savefile:saveLevel, Savefile:saveZone methods
-- @param object the object to save
-- @param class a class name, if different from the default one
-- @param on_end a function to call when this object is saved; this can be nil
function _M:push(savename, type, object, class, on_end)
DarkGod's avatar
DarkGod committed
	core.game.resetLocale()
dg's avatar
dg committed
	if game.onSavefilePush then game:onSavefilePush(savename, type, object, class) end

DarkGod's avatar
DarkGod committed
	-- Cant save twice the same thing before it finishes
	if self.pipe_types[type] and self.pipe_types[type][savename] then
		print("[SAVEFILE PIPE] Already saving data", type, savename, "waiting for finish before piping...")
		self:forceWait()
		print("[SAVEFILE PIPE] All pipe saving emptied, resuming next save")
	end

	local screenshot = nil
	if type == "game" then
		screenshot = game:takeScreenshot(true)
	end

	class = class or self.saveclass
	local Savefile = require(class)
	local id = Savefile["nameSave"..type:lower():capitalize()](Savefile, object)

DarkGod's avatar
DarkGod committed
	if #self.pipe == 0 then savefile_pipe.start_time = os.time() savefile_pipe.current_nb = 0 self:steamCleanup() end
DarkGod's avatar
DarkGod committed
	local clone, nb = object:cloneForSave()
DarkGod's avatar
DarkGod committed
	self.pipe_types[type] = self.pipe_types[type] or {}
	self.pipe_types[type][savename] = true

	self.pipe[#self.pipe+1] = {id=id, savename = savename, type=type, object=clone, nb_objects=nb, baseobject=object, class=class, saveversion=game:saveVersion("new"), screenshot=screenshot, on_end=on_end}
	local total_nb = 0
	for i, p in ipairs(self.pipe) do total_nb = total_nb + p.nb_objects end
	self.total_nb = total_nb
	if not self.co or coroutine.status(self.co) == "dead" then
		self.co = coroutine.create(function() return self:doThread() end)
		game:registerCoroutine("savefilepipe", self.co)
	end

	if game.onSavefilePushed then game:onSavefilePushed(savename, type, object, class) end

DarkGod's avatar
DarkGod committed

	-- Refuse to continue, make the user wait
DarkGod's avatar
DarkGod committed
	if #self.pipe >= self.max_before_wait or not config.settings.background_saves then
dg's avatar
dg committed

	return clone
--- Push a generic action to be done once saves complete
function _M:pushGeneric(name, fct)
	self.on_done[#self.on_done+1] = {name=name, fct=fct}
end

--- Actually do the saves
-- This should run in a coroutine.<br/>
-- Do not call this, this is automatic!
function _M:doThread()
	self.saving = true
	collectgarbage("collect")
	if game:getPlayer() then game:getPlayer().changed = true end
DarkGod's avatar
DarkGod committed

	local waiton = {}
	self.waiton = waiton

	while #self.pipe > 0 do
		local p = self.pipe[1]
		local Savefile = require(p.class)
		local o = p.object

		print("[SAVEFILE PIPE] new save running in the pipe:", p.savename, p.type, "::", p.id, "::", p.baseobject, "=>", p.object, "("..p.nb_objects..")")
dg's avatar
dg committed
		local save = Savefile.new(p.savename, config.settings.background_saves)
		o.__saved_saveversion = p.saveversion
		save["save"..p.type:lower():capitalize()](save, o, true)
		if p.screenshot then save:saveScreenshot(p.screenshot) end
DarkGod's avatar
DarkGod committed
		p.save = save
		waiton[save.current_save_zip:gsub("%.tmp$", "")] = p
		save:close()

		table.remove(self.pipe, 1)
	end
DarkGod's avatar
DarkGod committed

	-- Unleash the fury of the offthread saving
	print("[SAVEFILE PIPE] unleashing save thread")
	core.serial.threadSave()

	-- Wait for feedback
	while next(waiton) do
		local pop = core.serial.popSaveReturn()
		if not pop then coroutine.yield()
		else
			local dontremove = false
			local p = waiton[pop]
			if p then
				local Savefile = require(p.class)

				print("[SAVEFILE PIPE] Checking save", p.savename, p.type, p.save.current_save_zip)
				local save = Savefile.new(p.savename, config.settings.background_saves)
DarkGod's avatar
DarkGod committed
				local okmain = save:checkValidity(p.type, p.object)
				save:close()

				if not okmain then
					print("[SAVEFILE PIPE] *RE*new save running in the pipe:", p.savename, p.type, "::", p.id, "::", p.baseobject, "=>", p.object, "("..p.nb_objects..")")

					local o = p.object
					local save = Savefile.new(p.savename, config.settings.background_saves)
					o.__saved_saveversion = p.saveversion
					save["save"..p.type:lower():capitalize()](save, o, true)
					if p.screenshot then save:saveScreenshot(p.screenshot) end
					p.save = save
					waiton[save.current_save_zip:gsub("%.tmp$", "")] = p
					save:close()
					core.serial.threadSave()
					print("[SAVEFILE PIPE] Resaving sent", p.savename, p.type, p.save.current_save_zip)
					dontremove = true
				end
			end

			if not dontremove then
				if p and p.on_end then
					p.on_end(p.save)
				end
DarkGod's avatar
DarkGod committed
				self.pipe_types[p.type][p.savename] = nil
DarkGod's avatar
DarkGod committed
			end
		end
	end
	self.waiton = nil

	if game.log then game.log("Saving done.") end
	self.saving = false
	if game:getPlayer() then game:getPlayer().changed = true end

	-- Run the generic stuff
	while #self.on_done > 0 do
		local p = self.on_done[1]
		print("[SAVEFILE PIPE] on end", p.name)
		p.fct()
		table.remove(self.on_done, 1)
	end
DarkGod's avatar
DarkGod committed

	if savefile_pipe.start_time then
		print("[SAVEFILE PIPE] saving took "..(os.time() - savefile_pipe.start_time).." seconds")
	end
end

--- Force to wait for the saves
function _M:forceWait()
DarkGod's avatar
DarkGod committed
	print("[SAVEFILE PIPE] force waiting")
DarkGod's avatar
DarkGod committed
	if #self.pipe == 0 and (not self.waiton or not next(self.waiton)) then return end
dg's avatar
dg committed
	local popup = Dialog:simpleWaiter("Saving...", "Please wait while saving...", nil, 1000, self.total_nb)
	local cnt = 0
	while coroutine.status(self.co) ~= "dead" do
		cnt = cnt + 1
		if cnt == 1000 then core.display.forceRedraw() if game:getPlayer() then game:getPlayer().changed = true end cnt = 0 end
dg's avatar
dg committed
	popup:done()
--- Allow to ignore saveversion token
function _M:ignoreSaveToken(v)
	self.ignore_save_token = v
end

--- Load a savefile
-- @param savename the name of the savefile to handle
-- @param type the Savefile method to use. I.e: "game", "level", "zone". This will cann the Savefile:saveGame, Savefile:saveLevel, Savefile:saveZone methods
-- @param class a class name, if different from the default one
function _M:doLoad(savename, type, class, ...)
	class = class or self.saveclass
	local Savefile = require(class)
	local id = Savefile["nameLoad"..type:lower():capitalize()](Savefile, ...)

	-- Look for it in the pipe
	for i = 1, #self.pipe do
		local p = self.pipe[i]

		print("[SAVEFILE PIPE] looking for savefile to recover in the memory pipe", p.id, "?=?", id)
		if p.id == id then
			-- It is still in the save pipe: return it
			print("[SAVEFILE PIPE] recovering save from memory in pipe:", savename, type, "::", id)
			return p.baseobject
		end
	end

	local cur = Savefile:getCurrent()
	local save = Savefile.new(savename)
	local ret = save["load"..type:lower():capitalize()](save, ...)
	save:close()
	Savefile:setCurrent(cur)
	if _G.type(ret) == "table" and type ~= "game" and type ~= "world" and not self.ignore_save_token then
		if not game:saveVersion(ret.__saved_saveversion) then
			print("Loading savefile", savename, type, class," with id", ret.__saved_saveversion, "but current game does not know this token => ignoring")
			return nil
		end
	end