-- 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. local dfhack = dfhack local base_env = dfhack.BASE_G local _ENV = base_env 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 -- 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 dfhack.HIDE_CONSOLE_ON_STARTUP = true function dfhack.getHideConsoleOnStartup() return dfhack.HIDE_CONSOLE_ON_STARTUP end dfhack.HIDE_ARMOK_TOOLS = false function dfhack.getHideArmokTools() return dfhack.HIDE_ARMOK_TOOLS end -- Error handling safecall = dfhack.safecall curry = dfhack.curry function dfhack.pcall(f, ...) return xpcall(f, dfhack.onerror, ...) end 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 function dfhack.with_finalize(...) return dfhack.call_with_finalizer(0,true,...) end function dfhack.with_onerror(...) return dfhack.call_with_finalizer(0,false,...) end local function call_delete(obj) if obj then obj:delete() end end 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 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 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 DEFAULT_NIL = DEFAULT_NIL or {} -- Unique token function defclass(...) return require('class').defclass(...) end function mkinstance(...) return require('class').mkinstance(...) 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 function printall(table) safe_iterate(table, pairs, print_element) end 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 function copyall(table) local rv = {} for k,v in pairs(table) do rv[k] = v end return rv end 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 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 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 function get_path_xyz(path,i) return path.x[i], path.y[i], path.z[i] end function pos2xy(pos) if pos then local x = pos.x if x and x >= 0 then return x, pos.y end end end function xy2pos(x,y) if x then return {x=x,y=y} else return {x=-30000,y=-30000} end end function same_xy(a,b) return a and b and a.x == b.x and a.y == b.y end function get_path_xy(path,i) return path.x[i], path.y[i] end function safe_index(obj,idx,...) if obj == nil or idx == nil then return nil end if type(idx) == 'number' and type(obj) == '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 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 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 function string:startswith(prefix) return self:sub(1, #prefix) == prefix end -- suffix is a literal string, not a pattern 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. 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. function string:trim() local _, _, content = self:find('^%s*(.-)%s*$') return content end -- Inserts newlines into a string so no individual line exceeds the given width. -- Lines are split at space-separated word boundaries. Any existing newlines are -- kept in place. If a single word is longer than width, it is split over -- multiple lines. If width is not specified, 72 is used. function string:wrap(width) width = width or 72 if width <= 0 then error('expected width > 0; got: '..tostring(width)) end local wrapped_text = {} for line in self:gmatch('[^\n]*') do local line_start_pos = 1 local wrapped_line = line:gsub( '%s*()(%S+)()', function(start_pos, word, end_pos) -- word fits within the current line if end_pos - line_start_pos <= width then return end -- word needs to go on the next line, but is not itself longer -- than the specified width if #word <= width then line_start_pos = start_pos return '\n' .. word end -- word is too long to fit on one line and needs to be split up local num_chars, str = 0, start_pos == 1 and '' or '\n' repeat local word_frag = word:sub(num_chars + 1, num_chars + width) str = str .. word_frag num_chars = num_chars + #word_frag if num_chars < #word then str = str .. '\n' end line_start_pos = start_pos + num_chars until end_pos - line_start_pos <= width return str .. word:sub(num_chars + 1) end) table.insert(wrapped_text, wrapped_line) end return 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')..'])' function string:escape_pattern() return self:gsub(regex_chars_pattern, '%%%1') end -- String conversions function dfhack.persistent:__tostring() return "" end function dfhack.matinfo:__tostring() return "" end dfhack.random.__index = dfhack.random function dfhack.random:__tostring() return "" end dfhack.penarray.__index = dfhack.penarray function dfhack.penarray.__tostring() return "" end function dfhack.maps.getSize() local map = df.global.world.map return map.x_count_block, map.y_count_block, map.z_count_block end function dfhack.maps.getTileSize() local map = df.global.world.map return map.x_count, map.y_count, map.z_count end 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 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 -- Interactive local print_banner = true function dfhack.interpreter(prompt,hfile,env) if not dfhack.is_interactive() then return nil, 'not interactive' end print("Type quit to exit interactive lua interpreter.") if print_banner then print("Shortcuts:\n".. " '= foo' => '_1,_2,... = foo'\n".. " '! foo' => 'print(foo)'\n".. " '~ foo' => 'printall(foo)'\n".. " '^ foo' => 'printall_recurse(foo)'\n".. " '@ foo' => 'printall_ipairs(foo)'\n".. "All of these save the first result as '_'.") 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 {} local hack_path = dfhack.getHackPath() 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 warned_scripts = {} function dfhack.run_script(name,...) if not warned_scripts[name] then local helpdb = require('helpdb') if helpdb.is_entry(name) and helpdb.get_entry_tags(name).unavailable then warned_scripts[name] = true 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