diff --git a/changelog.txt b/changelog.txt index 1ed30ae50..74286450c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -33,6 +33,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 diff --git a/docs/load-save.rst b/docs/load-save.rst index c5f60fe51..1681de46b 100644 --- a/docs/load-save.rst +++ b/docs/load-save.rst @@ -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. diff --git a/load-save.lua b/load-save.lua index 1fff6953e..d71c7c10b 100644 --- a/load-save.lua +++ b/load-save.lua @@ -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)