Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Template for new versions:
## New Features

## Fixes
- `load-save`: fix script for the current title screen, which no longer responds to `sel_menu_line` and requires mouse input
- `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record
- `fix/loyaltycascade`: guard against citizens that are not historical figures and emit a warning.
- `gui/siegemanager`: fix nil index if there are no siege engines on the map
Expand Down
6 changes: 3 additions & 3 deletions docs/load-save.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ load-save

.. dfhack-tool::
:summary: Load a savegame.
:tags: unavailable
:tags: dfhack

When run on the Dwarf Fortress title screen or "load game" screen, this script
will load the save with the given folder name without requiring interaction.
When run on the Dwarf Fortress title screen, this script will load the save
with the given folder name without requiring interaction.
Note that inactive saves (i.e. saves under the "start game" menu that have gone
through world generation but have not had a fort or adventure game started in
them yet) cannot be loaded by this script.
Expand Down
172 changes: 129 additions & 43 deletions load-save.lua
Original file line number Diff line number Diff line change
@@ -1,63 +1,149 @@
-- load a save non-interactively - intended to be run on startup

local gui = require 'gui'
local script = require('gui.script')

local folder_name = ({...})[1] or qerror("No folder name given")

local loadgame_screen = dfhack.gui.getViewscreenByType(df.viewscreen_loadgamest, 0)
if not loadgame_screen then
local title_screen = dfhack.gui.getViewscreenByType(df.viewscreen_titlest, 0)
if not title_screen then
qerror("Can't find title or load game screen")
end
local found = false
for idx, item in ipairs(title_screen.menu_line_id) do
if item == df.main_choice_type.Continue then
found = true
title_screen.sel_menu_line = idx
break
-- the current title screen manages the whole continue-game flow: mode 0 is
-- the main menu, mode 2 the active world list, and mode 3 the save list.
-- getViewscreenByType finds it even when a child screen sits on top.
local function title_screen()
return dfhack.gui.getViewscreenByType(df.viewscreen_titlest, 0)
end

local function load_screen()
return dfhack.gui.getViewscreenByType(df.viewscreen_loadgamest, 0)
end

local function click_row(scr, y)
local sw, _ = dfhack.screen.getWindowSize()
df.global.gps.mouse_x = sw // 2
df.global.gps.mouse_y = y
df.global.gps.precise_mouse_x = df.global.gps.mouse_x * df.global.gps.tile_pixel_x
df.global.gps.precise_mouse_y = df.global.gps.mouse_y * df.global.gps.tile_pixel_y
gui.simulateInput(scr, '_MOUSE_L')
end

-- wait for the title screen to react to a click, polling every frame; mode
-- transitions play a fade animation and `mode` only flips once it
-- completes. the timeout is wall-clock because the title screen can
-- render far faster than 60 fps, making frame counts unreliable
local function wait_for_change(prev_mode, timeout_ms)
local deadline = dfhack.getTickCount() + (timeout_ms or 2000)
repeat
script.sleep(1, 'frames')
if load_screen() then return 'loading' end
local scr = title_screen()
if not scr then return 'other' end
if scr.mode ~= prev_mode then return 'mode' end
until dfhack.getTickCount() >= deadline
return 'timeout'
end

-- List rows sit a few tiles below the screen center and are a couple of
-- tiles tall each; the pitch is not exact, so probe a small y band around
-- the estimate until the screen reacts.
local function click_list_entry(scr, index)
local prev_mode = scr.mode
local _, sh = dfhack.screen.getWindowSize()
local base_y = sh < 60 and 25 or (sh // 2) + 3
local est_y = base_y + math.floor(index * 8 / 3 + 0.5)
for _, y in ipairs({est_y, est_y - 1, est_y + 1, est_y - 2, est_y + 2}) do
click_row(scr, y)
local result = wait_for_change(prev_mode)
if result ~= 'timeout' then
return result
end
end
if not found then
qerror("Can't find 'Continue Playing' option")
return 'timeout'
end

local function find_save_index(scr)
for idx = 0, #scr.savegame_header_game - 1 do
if scr.savegame_header_game[idx].filename_noext == folder_name then
return idx
end
end
gui.simulateInput(title_screen, 'SELECT')
return nil
end

loadgame_screen = dfhack.gui.getViewscreenByType(df.viewscreen_loadgamest, 0) or
qerror("Can't find load game screen")
local function main()
local load = load_screen()
if load then
local name = load.cur_save and load.cur_save.filename_noext
if name == folder_name then
return -- already loading the requested save
end
-- the header may not be populated yet, so the name can be empty
qerror('A save is already loading' ..
(name and name ~= '' and (': ' .. name) or ''))
end

local found = false
for idx, save in ipairs(loadgame_screen.saves) do
if save.folder_name == folder_name then
found = true
loadgame_screen.sel_idx = idx
break
local scr = title_screen()
if not scr then
qerror("Can't find title or load game screen")
end
end
if not found then
qerror("Can't find save: " .. folder_name)
end

--[[
If gui/load-screen is active, it will be inserted later this frame and prevent
the viewscreen_loadgamest from working. To work around this, SELECT is fed to
the screen one frame later.
]]
function triggerLoad(loadgame_screen)
-- Get rid of child screens (e.g. gui/load-screen)
if loadgame_screen.child then
local child = loadgame_screen.child
while child.child do
child = child.child
-- mode 0: main menu. "Continue" is the top entry when a resumable game
-- exists; clicking it lands on the world list (mode 2).
if scr.mode == df.title_mode_type.MAIN_MENU then
if scr.menu_line_id[0] ~= df.main_choice_type.Continue then
qerror("Can't find 'Continue Playing' option")
end
while child ~= loadgame_screen do
child = child.parent
dfhack.screen.dismiss(child.child)
local _, sh = dfhack.screen.getWindowSize()
-- capture the mode before clicking: the transition applies
-- synchronously inside the input feed, so reading it after
-- the click would observe the new mode and never detect a change
local prev_mode = scr.mode
click_row(scr, sh < 60 and 25 or (sh // 2) + 3)
if wait_for_change(prev_mode) ~= 'mode' then
qerror('Failed to enter the world list')
end
end

gui.simulateInput(loadgame_screen, 'SELECT')
-- if a save list (mode 3) is already open, back out to the world list
-- first so the search below can check every world
if scr.mode == df.title_mode_type.CONTINUE_ACTIVE then
local prev_mode = scr.mode
scr:feed_key(df.interface_key.LEAVESCREEN)
wait_for_change(prev_mode)
end

-- mode 2: world list. Enter each world in turn and search its save list
-- (mode 3), backing out to the world list between misses.
if scr.mode ~= df.title_mode_type.CONTINUE_ACTIVE_WORLD then
qerror(('Unexpected screen state: title screen mode %s'):format(
tostring(scr.mode)))
end
for world_idx = 0, #scr.savegame_header_world - 1 do
if click_list_entry(scr, world_idx) ~= 'mode' then
qerror('Failed to select a world')
end
local save_idx = find_save_index(scr)
if save_idx then
if click_list_entry(scr, save_idx) == 'loading' then
local loading = load_screen()
if loading.cur_save.filename_noext == folder_name then
print(('Loading save "%s"'):format(folder_name))
return
end
qerror(('Started loading "%s" instead of "%s"'):format(
tostring(loading.cur_save.filename_noext), folder_name))
end
qerror('Failed to start loading the save')
end
-- wrong world; back out to the world list and try the next one
local prev_mode = scr.mode
scr:feed_key(df.interface_key.LEAVESCREEN)
wait_for_change(prev_mode)
if not title_screen()
or scr.mode ~= df.title_mode_type.CONTINUE_ACTIVE_WORLD then
qerror('Failed to return to the world list')
end
end
qerror("Can't find save: " .. folder_name)
end

dfhack.timeout(1, 'frames', function() triggerLoad(loadgame_screen) end)
-- frame-level waiting needs a script coroutine
script.start(main)