-- Common startup file for all dfhack scripts and plugins with lua support -- The global dfhack table is already created by C++ init code. -- Setup the global environment. -- BASE_G is the original lua global environment, -- preserved as a common denominator for all modules. -- This file uses it instead of the new default one. ---@class dfhack ---@field BASE_G _G Original Lua global environment ---@field is_core_context boolean ---@field is_interactive fun(): boolean local dfhack = dfhack local base_env = dfhack.BASE_G local _ENV = base_env -- Types ---@alias dfhack.truthy ---| true ---| integer ---| string ---| table ---| function ---| userdata ---| fun(...): true|string|integer|table|function|userdata CR_LINK_FAILURE = -3 CR_NEEDS_CONSOLE = -2 CR_NOT_IMPLEMENTED = -1 CR_OK = 0 CR_FAILURE = 1 CR_WRONG_USAGE = 2 CR_NOT_FOUND = 3 -- Console color constants COLOR_RESET = -1 COLOR_BLACK = 0 COLOR_BLUE = 1 COLOR_GREEN = 2 COLOR_CYAN = 3 COLOR_RED = 4 COLOR_MAGENTA = 5 COLOR_BROWN = 6 COLOR_GREY = 7 COLOR_DARKGREY = 8 COLOR_LIGHTBLUE = 9 COLOR_LIGHTGREEN = 10 COLOR_LIGHTCYAN = 11 COLOR_LIGHTRED = 12 COLOR_LIGHTMAGENTA = 13 COLOR_YELLOW = 14 COLOR_WHITE = 15 COLOR_GRAY = COLOR_GREY COLOR_DARKGRAY = COLOR_DARKGREY ---@alias dfhack.color ---| `COLOR_RESET` ---| `COLOR_BLACK` ---| `COLOR_BLUE` ---| `COLOR_GREEN` ---| `COLOR_CYAN` ---| `COLOR_RED` ---| `COLOR_MAGENTA` ---| `COLOR_BROWN` ---| `COLOR_GREY` ---| `COLOR_DARKGREY` ---| `COLOR_LIGHTBLUE` ---| `COLOR_LIGHTGREEN` ---| `COLOR_LIGHTCYAN` ---| `COLOR_LIGHTRED` ---| `COLOR_LIGHTMAGENTA` ---| `COLOR_YELLOW` ---| `COLOR_WHITE` ---| `COLOR_GRAY` ---| `COLOR_DARKGRAY` -- Events if dfhack.is_core_context then SC_WORLD_LOADED = 0 SC_WORLD_UNLOADED = 1 SC_MAP_LOADED = 2 SC_MAP_UNLOADED = 3 SC_VIEWSCREEN_CHANGED = 4 SC_CORE_INITIALIZED = 5 SC_PAUSED = 7 SC_UNPAUSED = 8 end -- User-changeable options local HIDE_CONSOLE_ON_STARTUP = true ---@nodiscard ---@return boolean function dfhack.getHideConsoleOnStartup() return HIDE_CONSOLE_ON_STARTUP end function dfhack.setHideConsoleOnStartup(value) HIDE_CONSOLE_ON_STARTUP = value end local HIDE_ARMOK_TOOLS = false ---@nodiscard ---@return boolean function dfhack.getMortalMode() return HIDE_ARMOK_TOOLS end function dfhack.setMortalMode(value) HIDE_ARMOK_TOOLS = value dfhack.internal.setMortalMode(value) end -- Error handling safecall = dfhack.safecall curry = dfhack.curry ---@generic T ---@param f fun(...): T ---@param ... any ---@return boolean success ---@return T|string ... function dfhack.pcall(f, ...) return xpcall(f, dfhack.onerror, ...) end ---@param msg string ---@param level? integer function qerror(msg, level) local name = dfhack.current_script_name() if name and not tostring(msg):match(name) then msg = name .. ': ' .. tostring(msg) end dfhack.error(msg, (level or 1) + 1, false) end ---@generic T ---@param cleanup_fn function ---@param fn fun(...): T ---@param ... any ---@return T function dfhack.with_finalize(cleanup_fn,fn,...) return dfhack.call_with_finalizer(0,true,cleanup_fn,fn,...) end ---@generic T ---@param cleanup_fn function ---@param fn fun(...): T ---@param ... any ---@return T function dfhack.with_onerror(cleanup_fn,fn,...) return dfhack.call_with_finalizer(0,false,cleanup_fn,fn,...) end ---@param obj DFObject local function call_delete(obj) if obj then obj:delete() end end ---@generic T ---@param obj DFObject ---@param fn fun(...):T ---@param ... any ---@return T ... function dfhack.with_temp_object(obj,fn,...) return dfhack.call_with_finalizer(1,true,call_delete,obj,fn,obj,...) end dfhack.exception.__index = dfhack.exception -- Module loading local function find_required_module_arg() -- require -> module code -> mkmodule -> find_... if debug.getinfo(4,'f').func == require then return debug.getlocal(4, 1) end -- reload -> dofile -> module code -> mkmodule -> find_... if debug.getinfo(5,'f').func == reload then return debug.getlocal(5, 1) end end ---@nodiscard ---@param module string ---@param env? table|metatable ---@return _G pkg function mkmodule(module,env) -- Verify that the module name is correct local _, rq_modname = find_required_module_arg() if not rq_modname then error('The mkmodule function must be used at the start of a module') end if rq_modname ~= module then error('Found module '..module..' during require '..rq_modname) end -- Reuse the already loaded module table local pkg = package.loaded[module] if pkg == nil then pkg = {} else if type(pkg) ~= 'table' then error("Not a table in package.loaded["..module.."]") end end -- Inject the plugin-exported functions when appropriate local plugname = string.match(module,'^plugins%.([%w%-]+)$') if plugname then dfhack.open_plugin(pkg,plugname) end setmetatable(pkg, { __index = (env or base_env) }) return pkg end ---@param module string function reload(module) if type(package.loaded[module]) ~= 'table' then error("Module not loaded: "..module) end local path,err = package.searchpath(module,package.path) if not path then error(err) end dofile(path) end -- Trivial classes function rawset_default(target,source) for k,v in pairs(source) do if rawget(target,k) == nil then rawset(target,k,v) end end end ---@type any DEFAULT_NIL = DEFAULT_NIL or {} -- Unique token -- Create or updates a class; a class has metamethods and thus own metatable. ---@nodiscard ---@generic T: dfhack.class ---@param class? T ---@param parent? table ---@return T function defclass(class,parent) return require('class').defclass(class,parent) end -- An instance uses the class as metatable ---@generic T: dfhack.class ---@param class T ---@param table? table ---@return T function mkinstance(class,table) return require('class').mkinstance(class,table) end -- Misc functions NEWLINE = "\n" COMMA = "," PERIOD = "." local function _wrap_iterator(next_fn, ...) local wrapped_iter = function(...) local ret = {pcall(next_fn, ...)} local ok = table.remove(ret, 1) if ok then return table.unpack(ret) end end return wrapped_iter, ... end function safe_pairs(t, iterator_fn) iterator_fn = iterator_fn or pairs if (pcall(iterator_fn, t)) then return _wrap_iterator(iterator_fn(t)) else return function() end end end -- calls elem_cb(k, v) for each element of the table -- returns true if we iterated successfully, false if not -- this differs from safe_pairs() above in that it only calls pcall() once per -- full iteration and it returns whether iteration succeeded or failed. local function safe_iterate(table, iterator_fn, elem_cb) local function iterate() for k,v in iterator_fn(table) do elem_cb(k, v) end end return pcall(iterate) end local function print_element(k, v) dfhack.println(string.format("%-23s\t = %s", tostring(k), tostring(v))) end ---@param table table function printall(table) safe_iterate(table, pairs, print_element) end ---@param table table function printall_ipairs(table) safe_iterate(table, ipairs, print_element) end local do_print_recurse local function print_string(printfn, v, seen, indent) local str = tostring(v) printfn(str) return #str; end local fill_chars = { __index = function(table, key, value) local rv = string.rep(' ', 23 - key) .. ' = ' rawset(table, key, rv) return rv end, } setmetatable(fill_chars, fill_chars) local function print_fields(value, seen, indent, prefix) local prev_value = "not a value" local repeated = 0 local print_field = function(k, v) -- Only show set values of bitfields if value._kind ~= "bitfield" or v then local continue = false if type(k) == "number" then if prev_value == v then repeated = repeated + 1 continue = true else prev_value = v end else prev_value = "not a value" end if not continue then if repeated > 0 then dfhack.println(prefix .. "") repeated = 0 end dfhack.print(prefix) local len = do_print_recurse(dfhack.print, k, seen, indent + 1) dfhack.print(fill_chars[len <= 23 and len or 23]) do_print_recurse(dfhack.println, v, seen, indent + 1) end end end if not safe_iterate(value, pairs, print_field) then dfhack.print(prefix) dfhack.println('') elseif repeated > 0 then dfhack.println(prefix .. "") end return 0 end -- This should be same as print_array but userdata doesn't compare equal even if -- they hold same pointer. local function print_userdata(printfn, value, seen, indent) local prefix = string.rep(' ', indent) local strvalue = tostring(value) dfhack.println(strvalue) if seen[strvalue] then dfhack.print(prefix) dfhack.println('\n') return 0 end seen[strvalue] = true return print_fields(value, seen, indent, prefix) end local function print_array(printfn, value, seen, indent) local prefix = string.rep(' ', indent) dfhack.println(tostring(value)) if seen[value] then dfhack.print(prefix) dfhack.println('\n') return 0 end seen[value] = true return print_fields(value, seen, indent, prefix) end local recurse_type_map = { number = print_string, string = print_string, boolean = print_string, ['function'] = print_string, ['nil'] = print_string, userdata = print_userdata, table = print_array, } do_print_recurse = function(printfn, value, seen, indent) local t = type(value) if not recurse_type_map[t] then printfn("Unknown type " .. t .. " " .. tostring(value)) return end return recurse_type_map[t](printfn, value, seen, indent) end function printall_recurse(value, seen) local seen = seen or {} do_print_recurse(dfhack.println, value, seen, 0) end ---@generic T ---@param table T ---@return T function copyall(table) local rv = {} for k,v in pairs(table) do rv[k] = v end return rv end ---@param pos df.coord ---@return number? x ---@return number? y ---@return number? z function pos2xyz(pos) if pos then local x = pos.x if x and x >= 0 then return x, pos.y, pos.z end end end ---@nodiscard ---@param x number ---@param y number ---@param z number ---@return df.coord function xyz2pos(x,y,z) if x then return {x=x,y=y,z=z} else return {x=-30000,y=-30000,z=-30000} end end ---@nodiscard ---@param a df.coord ---@param b df.coord ---@return boolean function same_xyz(a,b) return a and b and a.x == b.x and a.y == b.y and a.z == b.z end ---@nodiscard ---@param path df.coord_path ---@param i number ---@return number x ---@return number y ---@return number z function get_path_xyz(path,i) return path.x[i], path.y[i], path.z[i] end ---@nodiscard ---@param pos df.coord|df.coord2d ---@return number? x ---@return number? y function pos2xy(pos) if pos then local x = pos.x if x and x >= 0 then return x, pos.y end end end ---@nodiscard ---@param x number ---@param y number ---@return df.coord2d function xy2pos(x,y) if x then return {x=x,y=y} else return {x=-30000,y=-30000} end end ---@nodiscard ---@param a df.coord|df.coord2d ---@param b df.coord|df.coord2d ---@return boolean function same_xy(a,b) return a and b and a.x == b.x and a.y == b.y end ---@nodiscard ---@param path df.coord_path|df.coord2d_path ---@param i number ---@return integer x ---@return integer y function get_path_xy(path,i) return path.x[i], path.y[i] end -- Walks a sequence of dereferences, which may be represented by numbers or -- strings. Returns nil if any of obj or indices is nil, or a numeric index is -- out of array bounds. ---@param obj table ---@param idx number|string ---@param ... number|string ---@return any obj function safe_index(obj,idx,...) local obj_type = type(obj) if obj == nil or idx == nil or (obj_type ~= "table" and obj_type ~= "userdata") then return nil end if type(idx) == 'number' and obj_type == 'userdata' and -- this check is only relevant for c++ (idx < 0 or idx >= #obj) then return nil end obj = obj[idx] if select('#',...) > 0 then return safe_index(obj,...) else return obj end end ---@param t table ---@param key integer|string ---@param default_value? any ---@return any function ensure_key(t, key, default_value) if t[key] == nil then t[key] = (default_value ~= nil) and default_value or {} end return t[key] end ---@param t table ---@param key integer|string ---@param ... integer|string ---@return table function ensure_keys(t, key, ...) t = ensure_key(t, key) if select('#', ...) > 0 then return ensure_keys(t, ...) end return t end -- String class extentions -- prefix is a literal string, not a pattern ---@nodiscard ---@param self string ---@param prefix string ---@return boolean function string:startswith(prefix) return self:sub(1, #prefix) == prefix end -- suffix is a literal string, not a pattern ---@nodiscrd ---@param self string ---@param suffix string ---@return boolean function string:endswith(suffix) return self:sub(-#suffix) == suffix or #suffix == 0 end -- Split a string by the given delimiter. If no delimiter is specified, space -- (' ') is used. The delimter is treated as a pattern unless a is -- specified and set to true. To treat multiple successive delimiter characters -- as a single delimiter, e.g. to avoid getting empty string elements, pass a -- pattern like ' +'. Be aware that passing patterns that match empty strings -- (like ' *') will result in improper string splits. ---@nodiscard ---@param self string ---@param delimiter? string ---@param plain? boolean ---@return string[] function string:split(delimiter, plain) delimiter = delimiter or ' ' local result = {} local from = 1 local delim_from, delim_to = self:find(delimiter, from, plain) -- delim_from will be greater than delim_to when the delimiter matches the -- empty string, which would lead to an infinite loop if we didn't check it while delim_from and delim_from <= delim_to do table.insert(result, self:sub(from, delim_from - 1)) from = delim_to + 1 delim_from, delim_to = self:find(delimiter, from, plain) end table.insert(result, self:sub(from)) return result end -- Removes spaces (i.e. everything that matches '%s') from the start and end of -- a string. Spaces between non-space characters are left untouched. ---@nodiscard ---@param self string ---@return string function string:trim() return self:match('^%s*(.-)%s*$') end local function insert_wrapped_line(wrapped_text, words, opts) local wrapped_line = table.concat(words, '') if not opts.keep_trailing_spaces then local content, eol = wrapped_line:match('(.-)%s*(\n?)$') wrapped_line = content .. eol end table.insert(wrapped_text, wrapped_line) end local function wrap_word(word, width, wrapped_text, words, cur_line_len, opts) local word_len = #word -- word fits within the current line if cur_line_len + word_len <= width then table.insert(words, word) cur_line_len = cur_line_len + word_len return words, cur_line_len end local trimmed_word = word if not opts.keep_trailing_spaces then trimmed_word = word:trim() -- trimmed word fits on the current line and ends it if cur_line_len + #trimmed_word <= width then table.insert(words, trimmed_word) insert_wrapped_line(wrapped_text, words, opts) return {}, 0 end end -- word needs to go on the next line, but is not itself longer -- than the specified width if word_len <= width then insert_wrapped_line(wrapped_text, words, opts) return {word}, word_len end -- word is too long to fit on one line and needs to be split up local emitted_chars, trimmed_word_len = 0, #trimmed_word repeat if #words > 0 then insert_wrapped_line(wrapped_text, words, opts) end local word_frag = word:sub(emitted_chars + 1, emitted_chars + width) words, cur_line_len = {word_frag}, #word_frag emitted_chars = emitted_chars + cur_line_len until emitted_chars >= trimmed_word_len return words, cur_line_len end -- Inserts newlines into a string so no individual line exceeds the given width. ---@nodiscard ---@param self string ---@param width number ---@param opts {return_as_table:boolean, keep_trailing_spaces:boolean, keep_original_newlines:boolean} ---@return string|string[] function string:wrap(width, opts) width, opts = width or 72, opts or {} if width <= 0 then error('expected width > 0; got: '..tostring(width)) end local wrapped_text = {} for line in self:gmatch('[^\n]*'..(opts.keep_original_newlines and '\n?' or '')) do local prespace = line:match('^(%s*)') local words, cur_line_len = {}, 0 if #prespace > 0 then words, cur_line_len = wrap_word(prespace, width, wrapped_text, words, cur_line_len, opts) end for word in line:gmatch('%S+%s*') do words, cur_line_len = wrap_word(word, width, wrapped_text, words, cur_line_len, opts) end insert_wrapped_line(wrapped_text, words, opts) end if self:sub(#self) == '\n' then table.insert(wrapped_text, '') end return opts.return_as_table and wrapped_text or table.concat(wrapped_text, '\n') end -- Escapes regex special chars in a string. E.g. "a+b" -> "a%+b" local regex_chars_pattern = '(['..('%^$()[].*+-?'):gsub('(.)', '%%%1')..'])' ---@nodiscard ---@param self string ---@return string function string:escape_pattern() return self:gsub(regex_chars_pattern, '%%%1') end -- String conversions ---@nodiscard ---@param self self ---@return string function dfhack.matinfo:__tostring() return "" end dfhack.random.__index = dfhack.random ---@nodiscard ---@param self self ---@return string function dfhack.random:__tostring() return "" end dfhack.penarray.__index = dfhack.penarray ---@nodiscard ---@return string function dfhack.penarray.__tostring() return "" end ---@nodiscard ---@return number x ---@return number y ---@return number z function dfhack.maps.getSize() local map = df.global.world.map return map.x_count_block, map.y_count_block, map.z_count_block end ---@nodiscard ---@return number x ---@return number y ---@return number z function dfhack.maps.getTileSize() local map = df.global.world.map return map.x_count, map.y_count, map.z_count end ---@param bld building ---@return number width ---@return number height ---@return number centerx ---@return number centery function dfhack.buildings.getSize(bld) local x, y = bld.x1, bld.y1 return bld.x2+1-x, bld.y2+1-y, bld.centerx-x, bld.centery-y end ---@nodiscard ---@param scr_type _viewscreen ---@param n? number ---@return viewscreen|nil function dfhack.gui.getViewscreenByType(scr_type, n) -- translated from modules/Gui.cpp if n == nil then n = 1 end local limit = (n > 0) local scr = dfhack.gui.getCurViewscreen() while scr do if limit then n = n - 1 if n < 0 then return nil end end if scr_type:is_instance(scr) then return scr end scr = scr.parent end end ---@nodiscard ---@return world_site|nil function dfhack.world.getCurrentSite() return df.world_site.find(dfhack.world.GetCurrentSiteId()) end ---@nodiscard ---@param which string ---@param key string ---@param default? any ---@return any local function persistent_getData(which, key, default) local serialized = dfhack.persistent['get'..which..'DataString'](key) if not serialized then return default end return require('json').decode(serialized) or default end ---@param which string ---@param key string ---@param data any local function persistent_saveData(which, key, data) local serialized = require('json').encode(data) dfhack.persistent['save'..which..'DataString'](key, serialized) end ---@nodiscard ---@param key string ---@param default? any ---@return any function dfhack.persistent.getSiteData(key, default) return persistent_getData('Site', key, default) end ---@param key string ---@param data any function dfhack.persistent.saveSiteData(key, data) persistent_saveData('Site', key, data) end ---@param key string ---@param default? any ---@return any function dfhack.persistent.getWorldData(key, default) return persistent_getData('World', key, default) end ---@param key string ---@param data any function dfhack.persistent.saveWorldData(key, data) persistent_saveData('World', key, data) end -- Interactive local print_banner = true ---@param prompt? string ---@param hfile? string ---@param env? table|metatable ---@return boolean|nil ---@return string|nil function dfhack.interpreter(prompt,hfile,env) if not dfhack.is_interactive() then return nil, 'not interactive' end local function print_keyword(pre, keyword, post) dfhack.color(COLOR_RESET) if pre ~= nil then dfhack.print(pre) end dfhack.color(COLOR_YELLOW) dfhack.print(keyword) dfhack.color(COLOR_RESET) if post ~= nil then print(post) end end print_keyword("Type ", "quit", " to exit interactive lua interpreter.") if print_banner then print("Shortcuts:") print_keyword(" '", "= foo", "' => '_1,_2,... = foo'") print_keyword(" '", "! foo", "' => 'print(foo)'") print_keyword(" '", "~ foo", "' => 'printall(foo)'") print_keyword(" '", "^ foo", "' => 'printall_recurse(foo)'") print_keyword(" '", "@ foo", "' => 'printall_ipairs(foo)'") print_keyword("All of these save the first result as '", "_", "'.") print("These keywords refer to the currently-selected object in the game:") print_keyword(" ", "unit item plant building bld job workshop_job wsjob screen scr", "") print_banner = false end local prompt_str = "["..(prompt or 'lua').."]# " local prompt_cont = string.rep(' ',#prompt_str-4)..">>> " local prompt_env = {} local cmdlinelist = {} local t_prompt = nil local vcnt = 1 local pfix_handlers = { ['!'] = function(data) print(table.unpack(data,2,data.n)) end, ['~'] = function(data) print(table.unpack(data,2,data.n)) printall(data[2]) end, ['@'] = function(data) print(table.unpack(data,2,data.n)) printall_ipairs(data[2]) end, ['^'] = function(data) printall_recurse(data[2]) end, ['='] = function(data) for i=2,data.n do local varname = '_'..vcnt prompt_env[varname] = data[i] dfhack.print(varname..' = ') safecall(print, data[i]) vcnt = vcnt + 1 end end } setmetatable(prompt_env, { __index = env or _G }) while true do local cmdline = dfhack.lineedit(t_prompt or prompt_str, hfile) if cmdline == nil or cmdline == 'quit' then break elseif cmdline ~= '' then local pfix = string.sub(cmdline,1,1) if not t_prompt and pfix_handlers[pfix] then cmdline = 'return '..string.sub(cmdline,2) else pfix = nil end table.insert(cmdlinelist,cmdline) cmdline = table.concat(cmdlinelist,'\n') local code,err = load(cmdline, '=(interactive)', 't', prompt_env) if code == nil then if not pfix and err:sub(-5)=="" then t_prompt=prompt_cont else dfhack.printerr(err) cmdlinelist={} t_prompt=nil end else cmdlinelist={} t_prompt=nil local data = table.pack(safecall(code)) if data[1] and data.n > 1 then prompt_env._ = data[2] safecall(pfix_handlers[pfix], data) end end end end return true end -- Command scripts local internal = dfhack.internal Script = defclass(Script) function Script:init(path) self.path = path self.mtime = dfhack.filesystem.mtime(path) self._flags = {} end function Script:needs_update() return (not self.env) or self.mtime ~= dfhack.filesystem.mtime(self.path) end function Script:get_flags() local mtime = dfhack.filesystem.mtime(self.path) if self.flags_mtime ~= mtime then self.flags_mtime = mtime self._flags = {} local f = io.open(self.path) for line in f:lines() do local at_tag = line:match('^%-%-@(.+)') if not at_tag then goto continue end local chunk = load(at_tag, self.path, 't', self._flags) if chunk then chunk() else dfhack.printerr('Parse error: ' .. line) end ::continue:: end f:close() end return self._flags end internal.scripts = internal.scripts or {} function dfhack.findScript(name) return dfhack.internal.findScript(name .. '.lua') end local valid_script_flags = { enable = {required = true, error = 'Does not recognize enable/disable commands'}, enable_state = {required = false}, module = { required = function(flags) if flags.module_strict == false then return false end return true end, error = 'Cannot be used as a module' }, module_strict = {required = false}, alias = {required = false}, alias_count = {required = false}, scripts = {required = false}, } local checked_scripts = {} function dfhack.run_script(name,...) if not checked_scripts[name] then checked_scripts[name] = true local helpdb = require('helpdb') if helpdb.has_tag(name, 'unavailable') then dfhack.printerr(('UNTESTED WARNING: the "%s" script has not been validated to work well with this version of DF.'):format(name)) dfhack.printerr('It may not work as expected, or it may corrupt your game.') qerror('Please run the command again to ignore this warning and proceed.') end end return dfhack.run_script_with_env(nil, name, nil, ...) end function dfhack.enable_script(name, state) local res, err = dfhack.pcall(dfhack.run_script_with_env, nil, name, {enable=true, enable_state=state}) if not res then dfhack.printerr(err.message) qerror(('Cannot %s Lua script: %s'):format(state and 'enable' or 'disable', name)) end end function dfhack.reqscript(name) return dfhack.script_environment(name, true) end reqscript = dfhack.reqscript function dfhack.script_environment(name, strict) local scripts = internal.scripts local path = dfhack.findScript(name) if not scripts[path] or scripts[path]:needs_update() then local _, env = dfhack.run_script_with_env(nil, name, { module=true, module_strict=(strict and true or false) -- ensure that this key is present if 'strict' is nil }) return env else if strict and not scripts[path]:get_flags().module then error(('%s: %s'):format(name, valid_script_flags.module.error)) end return scripts[path].env end end function dfhack.run_script_with_env(envVars, name, flags, ...) if type(flags) ~= 'table' then flags = {} end local file = dfhack.findScript(name) if not file then error('Could not find script ' .. name) end local scripts = flags.scripts or internal.scripts if scripts[file] == nil then scripts[file] = Script(file) end local script_flags = scripts[file]:get_flags() if script_flags.alias then flags.alias_count = (flags.alias_count or 0) + 1 if flags.alias_count > 10 then error('Too many script aliases: ' .. flags.alias_count) end return dfhack.run_script_with_env(envVars, script_flags.alias, flags, ...) end for flag, value in pairs(flags) do if value then local v = valid_script_flags[flag] if not v then error('Invalid flag: ' .. flag) elseif ((type(v.required) == 'boolean' and v.required) or (type(v.required) == 'function' and v.required(flags))) then if not script_flags[flag] then local msg = v.error or ('Flag "' .. flag .. '" not recognized') error(name .. ': ' .. msg) end end end end local env = scripts[file].env if env == nil then env = {} setmetatable(env, { __index = base_env }) end for x,y in pairs(envVars or {}) do env[x] = y end env.dfhack_flags = flags env.moduleMode = flags.module local script_code local perr local time = dfhack.filesystem.mtime(file) if time == scripts[file].mtime and scripts[file].run then script_code = scripts[file].run else --reload script_code, perr = loadfile(file, 't', env) if not script_code then error(perr) end -- avoid updating mtime if the script failed to load scripts[file].mtime = time end scripts[file].env = env scripts[file].run = script_code local args = {...} for i,v in ipairs(args) do args[i] = tostring(v) -- ensure passed parameters are strings end return script_code(table.unpack(args)), env end function dfhack.current_script_name() local frame = 1 while true do local info = debug.getinfo(frame, 'f') if not info then break end if info.func == dfhack.run_script_with_env then local i = 1 while true do local name, value = debug.getlocal(frame, i) if not name then break end if name == 'name' then return value end i = i + 1 end break end frame = frame + 1 end end function dfhack.script_help(script_name, extension) script_name = script_name or dfhack.current_script_name() return require('helpdb').get_entry_long_help(script_name) end local function _run_command(args, use_console) if type(args[1]) == 'table' then command = args[1] elseif #args > 1 and type(args[2]) == 'table' then -- {args[1]} + args[2] command = args[2] table.insert(command, 1, args[1]) elseif #args == 1 and type(args[1]) == 'string' then command = args[1] elseif #args > 1 and type(args[1]) == 'string' then command = args else error('Invalid arguments') end return internal.runCommand(command, use_console) end function dfhack.run_command_silent(...) local result = _run_command({...}) local output = "" for i, f in pairs(result) do if type(f) == 'table' then output = output .. f[2] end end return output, result.status end function dfhack.run_command(...) local result = _run_command({...}, true) return result.status end -- Per-save init file function dfhack.getSavePath() if dfhack.isWorldLoaded() then return dfhack.getDFPath() .. '/save/' .. df.global.world.cur_savegame.save_dir end end if dfhack.is_core_context then local function loadInitFile(path, name) local env = setmetatable({ SAVE_PATH = path }, { __index = base_env }) local f,perr = loadfile(name, 't', env) if f == nil then if dfhack.filesystem.exists(name) then dfhack.printerr(perr) end elseif safecall(f) then if not internal.save_init then internal.save_init = {} end table.insert(internal.save_init, env) end end dfhack.onStateChange.DFHACK_PER_SAVE = function(op) if op == SC_WORLD_LOADED or op == SC_WORLD_UNLOADED then if internal.save_init then for k,v in ipairs(internal.save_init) do if v.onUnload then safecall(v.onUnload) end end internal.save_init = nil end local path = dfhack.getSavePath() if path and op == SC_WORLD_LOADED then loadInitFile(path, path..'/init.lua') local dirlist = dfhack.internal.getDir(path..'/init.d/') if dirlist then table.sort(dirlist) for i,name in ipairs(dirlist) do if string.match(name,'%.lua$') then loadInitFile(path, path..'/init.d/'..name) end end end end elseif internal.save_init then for k,v in ipairs(internal.save_init) do if v.onStateChange then safecall(v.onStateChange, op) end end end end end -- Feed the table back to the require() mechanism. return dfhack