From a4a4ece1db22f21f3d32060abea2164e9e11229d Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 01:15:33 +0200 Subject: [PATCH 1/3] autosave: new tool to save the game on a real-time schedule --- autosave.lua | 154 ++++++++++++++++++++++++++++ changelog.txt | 1 + docs/autosave.rst | 37 +++++++ internal/control-panel/registry.lua | 1 + 4 files changed, 193 insertions(+) create mode 100644 autosave.lua create mode 100644 docs/autosave.rst diff --git a/autosave.lua b/autosave.lua new file mode 100644 index 000000000..bca8e0030 --- /dev/null +++ b/autosave.lua @@ -0,0 +1,154 @@ +-- Automatically save the game on a real-time schedule. +--@module = true +--@enable = true + +local argparse = require('argparse') +local json = require('json') +local utils = require('utils') + +local GLOBAL_KEY = 'autosave' -- used for state change hooks and persistence +local CONFIG_FILE_PATH = 'dfhack-config/autosave.json' + +local DEFAULT_INTERVAL_MINUTES = 30 + +-- how often (in rendered frames) to check whether a save is due. rendered +-- frames keep ticking while the game is paused, so the interval stays in +-- real time +local POLL_FRAMES = 100 + +-- if a requested save hasn't completed within this long, assume it failed +-- and allow another attempt +local SAVE_TIMEOUT_MS = 10 * 60 * 1000 + +local function get_default_state() + return { + enabled=false, + } +end + +state = state or get_default_state() +config = config or json.open(CONFIG_FILE_PATH) + +function isEnabled() + return state.enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +local function get_interval_minutes() + return config.data.interval_minutes or DEFAULT_INTERVAL_MINUTES +end + +local function save_now() + save_requested_ms = dfhack.getTickCount() + dfhack.run_script('quicksave') +end + +local function event_loop() + if not state.enabled then return end + + local interval_sec = get_interval_minutes() * 60 + local unsaved_sec = dfhack.persistent.getUnsavedSeconds() + + if save_requested_ms then + -- the unsaved counter resets when the save completes; if it never + -- does (e.g. the save failed), eventually give up and try again + if unsaved_sec < interval_sec or + dfhack.getTickCount() - save_requested_ms > SAVE_TIMEOUT_MS then + save_requested_ms = nil + end + elseif unsaved_sec >= interval_sec and + dfhack.isMapLoaded() and dfhack.world.isFortressMode() then + save_now() + end + + timeout_id = dfhack.timeout(POLL_FRAMES, 'frames', event_loop) +end + +local function do_enable() + if state.enabled then return end + state.enabled = true + event_loop() +end + +local function do_disable() + if not state.enabled then return end + state.enabled = false + if timeout_id then + dfhack.timeout_active(timeout_id, nil) -- cancel callback + timeout_id = nil + end +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return + end + + if sc ~= SC_MAP_LOADED then + return + end + + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + event_loop() +end + +local function status() + print(('autosave is %s'):format(state.enabled and 'enabled' or 'disabled')) + local interval = get_interval_minutes() + print(('autosave interval: %d minute%s'):format( + interval, interval == 1 and '' or 's')) + if dfhack.isMapLoaded() then + local unsaved_min = dfhack.persistent.getUnsavedSeconds() // 60 + print(('time since last save: %d minute%s'):format( + unsaved_min, unsaved_min == 1 and '' or 's')) + end +end + +if dfhack_flags.module then + return +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + do_enable() + else + do_disable() + end + persist_state() +end + +local help = false +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, +}) + +local command = table.remove(positionals, 1) +if help or command == 'help' then + print(dfhack.script_help()) + return +end + +if not command or command == 'status' then + status() +elseif command == 'set' then + local minutes = tonumber(positionals[1]) + if not minutes or minutes <= 0 then + qerror('interval must be a positive number of minutes') + end + config.data.interval_minutes = minutes + config:write() + print(('autosave interval set to %s minute%s'):format( + minutes, minutes == 1 and '' or 's')) +elseif command == 'now' then + if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + qerror('a fortress must be loaded to save') + end + save_now() +else + qerror(('unrecognized command: "%s"'):format(command)) +end diff --git a/changelog.txt b/changelog.txt index b492b6d2e..c0f4d2148 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,6 +27,7 @@ Template for new versions: # Future ## New Tools +- `autosave`: automatically save the game every N minutes of real time ## New Features diff --git a/docs/autosave.rst b/docs/autosave.rst new file mode 100644 index 000000000..0765028be --- /dev/null +++ b/docs/autosave.rst @@ -0,0 +1,37 @@ +autosave +======== + +.. dfhack-tool:: + :summary: Automatically save the game on a real-time schedule. + :tags: fort gameplay + +When enabled, ``autosave`` periodically checks how much real time has passed +since the game was last saved (or loaded), and runs `quicksave` once the +configured interval has elapsed. + +Unlike the vanilla seasonal autosaves, the interval is measured in real time +rather than game time, and it keeps counting even while the game is paused. +The interval applies globally across all your forts and worlds, while the +enabled state is remembered per fort. + +Usage +----- + +:: + + enable autosave + autosave [status] + autosave set + autosave now + +``autosave`` or ``autosave status`` + Show whether autosave is enabled, the configured interval, and how much + time has passed since the last save. +``autosave set `` + Set how often the game is saved, in minutes of real time. The default is + 30 minutes. +``autosave now`` + Save the game immediately. + +You can also enable and disable ``autosave`` on the Automation tab of the +DFHack control panel. diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 0759ed398..84b21de98 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -30,6 +30,7 @@ COMMANDS_BY_IDX = { desc='Automatically milk creatures that are ready for milking.', params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', '"{\\"job\\":\\"MilkCreature\\",\\"item_conditions\\":[{\\"condition\\":\\"AtLeast\\",\\"value\\":2,\\"flags\\":[\\"empty\\"],\\"item_type\\":\\"BUCKET\\"}]}"', ']'}}, {command='autonestbox', group='automation', mode='enable'}, + {command='autosave', group='automation', mode='enable'}, {command='autoshear', help_command='workorder', group='automation', mode='repeat', desc='Automatically shear creatures that are ready for shearing.', params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', 'ShearCreature', ']'}}, From bf62925a6f147843b6e87120453a127701061ebb Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 01:16:01 +0200 Subject: [PATCH 2/3] autosave: fix status output for fractional minute intervals --- autosave.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autosave.lua b/autosave.lua index bca8e0030..0b7c8e96e 100644 --- a/autosave.lua +++ b/autosave.lua @@ -100,7 +100,7 @@ end local function status() print(('autosave is %s'):format(state.enabled and 'enabled' or 'disabled')) local interval = get_interval_minutes() - print(('autosave interval: %d minute%s'):format( + print(('autosave interval: %s minute%s'):format( interval, interval == 1 and '' or 's')) if dfhack.isMapLoaded() then local unsaved_min = dfhack.persistent.getUnsavedSeconds() // 60 From a5849be10f1f4c6ef4e968229cdf5323e714a9a7 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 02:13:17 +0200 Subject: [PATCH 3/3] autosave: detect in-flight saves from engine state Track the save in progress via plotinfo autosave_request and save_progress.substage instead of a request timestamp. A stale timestamp could suppress saves for up to ten minutes after a disable/enable cycle, and could restart an in-progress save. Also add fortress-mode tests that exercise a real save. --- autosave.lua | 32 +++++++++++++------------- test/autosave.lua | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 test/autosave.lua diff --git a/autosave.lua b/autosave.lua index 0b7c8e96e..2c2e9db95 100644 --- a/autosave.lua +++ b/autosave.lua @@ -16,10 +16,6 @@ local DEFAULT_INTERVAL_MINUTES = 30 -- real time local POLL_FRAMES = 100 --- if a requested save hasn't completed within this long, assume it failed --- and allow another attempt -local SAVE_TIMEOUT_MS = 10 * 60 * 1000 - local function get_default_state() return { enabled=false, @@ -41,26 +37,25 @@ local function get_interval_minutes() return config.data.interval_minutes or DEFAULT_INTERVAL_MINUTES end +-- a save is in flight from when it is requested until the saver finishes; +-- save_progress.substage keeps its final value (Finishing) after completion +local function is_save_in_progress() + local main = df.global.plotinfo.main + return main.autosave_request or + (main.save_progress.substage >= df.save_substage.Initializing and + main.save_progress.substage < df.save_substage.Finishing) +end + local function save_now() - save_requested_ms = dfhack.getTickCount() dfhack.run_script('quicksave') end local function event_loop() if not state.enabled then return end - local interval_sec = get_interval_minutes() * 60 - local unsaved_sec = dfhack.persistent.getUnsavedSeconds() - - if save_requested_ms then - -- the unsaved counter resets when the save completes; if it never - -- does (e.g. the save failed), eventually give up and try again - if unsaved_sec < interval_sec or - dfhack.getTickCount() - save_requested_ms > SAVE_TIMEOUT_MS then - save_requested_ms = nil - end - elseif unsaved_sec >= interval_sec and - dfhack.isMapLoaded() and dfhack.world.isFortressMode() then + if dfhack.persistent.getUnsavedSeconds() >= get_interval_minutes() * 60 and + dfhack.isMapLoaded() and dfhack.world.isFortressMode() and + not is_save_in_progress() then save_now() end @@ -148,6 +143,9 @@ elseif command == 'now' then if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then qerror('a fortress must be loaded to save') end + if is_save_in_progress() then + qerror('a save is already in progress') + end save_now() else qerror(('unrecognized command: "%s"'):format(command)) diff --git a/test/autosave.lua b/test/autosave.lua new file mode 100644 index 000000000..947a5940f --- /dev/null +++ b/test/autosave.lua @@ -0,0 +1,57 @@ +config = { + mode = 'fortress', + target = 'autosave', +} + +local autosave = reqscript('autosave') + +-- saving can take a while for large forts +local SAVE_TIMEOUT_FRAMES = 6000 + +local function wait_for_save() + -- the unsaved time counter resets when a save completes, so it decreasing + -- below the value captured before triggering the save means it finished + local before = dfhack.persistent.getUnsavedSeconds() + return function() + return dfhack.persistent.getUnsavedSeconds() < before + end +end + +config.wrapper = function(test_fn) + -- dfhack.run_script is patched during tests to use a test-local script + -- env, but enable/disable go through dfhack.enable_script and act on the + -- real env, so drive the real env directly with run_script_with_env + local orig_interval = autosave.config.data.interval_minutes + dfhack.enable_script('autosave', false) + local ok, err = pcall(test_fn) + dfhack.enable_script('autosave', false) + dfhack.run_script_with_env(nil, 'autosave', {}, 'set', tostring(orig_interval or 30)) + if not ok then error(err) end +end + +function test.now_saves_game() + -- wait a few seconds so that the unsaved time is distinguishably positive + delay_until(function() + return dfhack.persistent.getUnsavedSeconds() >= 3 + end, 1000) + dfhack.run_script('autosave', 'now') + delay_until(wait_for_save(), SAVE_TIMEOUT_FRAMES) +end + +function test.save_fires_when_enabled() + -- wait a few seconds so that the unsaved time is distinguishably positive + -- and its reset on save is observable + delay_until(function() + return dfhack.persistent.getUnsavedSeconds() >= 3 + end, 1000) + dfhack.run_script_with_env(nil, 'autosave', {}, 'set', '0.001') + dfhack.enable_script('autosave', true) + delay_until(wait_for_save(), SAVE_TIMEOUT_FRAMES) +end + +function test.no_save_when_disabled() + local before = dfhack.persistent.getUnsavedSeconds() + -- poll interval is 100 frames; wait well past it + delay(500) + expect.true_(dfhack.persistent.getUnsavedSeconds() >= before) +end