diff --git a/.github/workflows/ci_suite.yml b/.github/workflows/ci_suite.yml index 86baaafacbd..36d2c3fcde7 100644 --- a/.github/workflows/ci_suite.yml +++ b/.github/workflows/ci_suite.yml @@ -123,6 +123,9 @@ jobs: sudo apt update || true sudo apt install -o APT::Immediate-Configure=false libssl1.1:i386 bash tools/ci/install_rust_g.sh + - name: Install auxlua + run: | + bash tools/ci/install_auxlua.sh - name: Compile Tests run: | bash tools/ci/install_byond.sh diff --git a/.gitignore b/.gitignore index 87b767c1f27..8cc717dae11 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ Temporary Items # TGUI bundles - SKYRAT EDIT /tgui/public/tgui-common.bundle.js +# Built auxtools libraries and intermediate files +aux*.dll +libaux*.so +aux*.pdb + # JavaScript tools **/node_modules diff --git a/.vscode/launch.json b/.vscode/launch.json index b74e0acda04..b3978530ff6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,18 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Debug External Libraries", + "type": "cppvsdbg", + "request": "launch", + "program": "${command:dreammaker.returnDreamDaemonPath}", + "cwd": "${workspaceRoot}", + "args": [ + "${command:dreammaker.getFilenameDmb}", + "-trusted" + ], + "preLaunchTask": "Build All" + }, { "type": "byond", "request": "launch", diff --git a/.vscode/settings.json b/.vscode/settings.json index ef34e7edea3..f262679735c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,6 @@ "editor.rulers": [80], "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true - } + }, + "Lua.diagnostics.enable": false } diff --git a/auxlua.dll b/auxlua.dll new file mode 100755 index 00000000000..3af73919aff Binary files /dev/null and b/auxlua.dll differ diff --git a/code/__DEFINES/admin.dm b/code/__DEFINES/admin.dm index e67a1596793..a13188a8741 100644 --- a/code/__DEFINES/admin.dm +++ b/code/__DEFINES/admin.dm @@ -69,6 +69,8 @@ #define ADMIN_VERBOSEJMP(src) "[src ? src.Admin_Coordinates_Readable(TRUE, TRUE) : "nonexistent location"]" #define ADMIN_INDIVIDUALLOG(user) "(LOGS)" #define ADMIN_TAG(datum) "(TAG)" +#define ADMIN_LUAVIEW(state) "(VIEW STATE)" +#define ADMIN_LUAVIEW_CHUNK(state, log_index) "(VIEW CODE)" /atom/proc/Admin_Coordinates_Readable(area_name, admin_jump_ref) var/turf/T = Safe_COORD_Location() diff --git a/code/__DEFINES/spaceman_dmm.dm b/code/__DEFINES/spaceman_dmm.dm index 7b31c5134e5..a2dbc6bf08e 100644 --- a/code/__DEFINES/spaceman_dmm.dm +++ b/code/__DEFINES/spaceman_dmm.dm @@ -37,8 +37,3 @@ /proc/enable_debugging(mode, port) CRASH("auxtools not loaded") -/world/Del() - var/debug_server = world.GetConfig("env", "AUXTOOLS_DEBUG_DLL") - if (debug_server) - call(debug_server, "auxtools_shutdown")() - . = ..() diff --git a/code/__HELPERS/_auxtools_api.dm b/code/__HELPERS/_auxtools_api.dm new file mode 100644 index 00000000000..c7c63b6c63f --- /dev/null +++ b/code/__HELPERS/_auxtools_api.dm @@ -0,0 +1,30 @@ +#define AUXTOOLS_FULL_INIT 2 +#define AUXTOOLS_PARTIAL_INIT 1 + +GLOBAL_LIST_EMPTY(auxtools_initialized) + +#define AUXTOOLS_CHECK(LIB)\ + if (GLOB.auxtools_initialized[LIB] != AUXTOOLS_FULL_INIT) {\ + if (fexists(LIB)) {\ + var/string = call(LIB,"auxtools_init")();\ + if(findtext(string, "SUCCESS")) {\ + GLOB.auxtools_initialized[LIB] = AUXTOOLS_FULL_INIT;\ + } else {\ + CRASH(string);\ + }\ + } else {\ + CRASH("No file named [LIB] found!")\ + }\ + }\ + +#define AUXTOOLS_SHUTDOWN(LIB)\ + if (GLOB.auxtools_initialized[LIB] == AUXTOOLS_FULL_INIT && fexists(LIB)){\ + call(LIB,"auxtools_shutdown")();\ + GLOB.auxtools_initialized[LIB] = AUXTOOLS_PARTIAL_INIT;\ + }\ + +#define AUXTOOLS_FULL_SHUTDOWN(LIB)\ + if (GLOB.auxtools_initialized[LIB] && fexists(LIB)){\ + call(LIB,"auxtools_full_shutdown")();\ + GLOB.auxtools_initialized[LIB] = FALSE;\ + }\ diff --git a/code/__HELPERS/_lists.dm b/code/__HELPERS/_lists.dm index f5f91f3c8e7..3f220fd69d9 100644 --- a/code/__HELPERS/_lists.dm +++ b/code/__HELPERS/_lists.dm @@ -811,3 +811,90 @@ return ref.resolve() else return element + +#define REFIFY_KVPIFY_MAX_LENGTH 1000 + +/// Returns a copy of the list where any element that is a datum or the world is converted into a ref +/proc/refify_list(list/target_list) + if(length(target_list) > REFIFY_KVPIFY_MAX_LENGTH) + return "list\[[length(target_list)]\]" + var/list/ret = list() + for(var/i in 1 to target_list.len) + var/key = target_list[i] + var/new_key = key + if(isdatum(key)) + new_key = "[key] [REF(key)]" + else if(key == world) + new_key = "world [REF(world)]" + else if(islist(key)) + new_key = refify_list(key) + var/value + if(istext(key) || islist(key) || ispath(key) || isdatum(key) || key == world) + value = target_list[key] + if(isdatum(value)) + value = "[value] [REF(value)]" + else if(value == world) + value = "world [REF(world)]" + else if(islist(value)) + value = refify_list(value) + var/list/to_add = list(new_key) + if(value) + to_add[new_key] = value + ret += to_add + return ret + +/** + * Converts a list into a list of assoc lists of the form ("key" = key, "value" = value) + * so that list keys that are themselves lists can be fully json-encoded + */ +/proc/kvpify_list(list/target_list, depth = INFINITY) + if(length(target_list) > REFIFY_KVPIFY_MAX_LENGTH) + return "list\[[length(target_list)]\]" + var/list/ret = list() + for(var/i in 1 to target_list.len) + var/key = target_list[i] + var/new_key = key + if(islist(key) && depth) + new_key = kvpify_list(key, depth-1) + var/value + if(istext(key) || islist(key) || ispath(key) || isdatum(key) || key == world) + value = target_list[key] + if(islist(value) && depth) + value = kvpify_list(value, depth-1) + if(value) + ret += list(list("key" = new_key, "value" = value)) + else + ret += list(list("key" = i, "value" = new_key)) + return ret + +#undef REFIFY_KVPIFY_MAX_LENGTH + +/// Compares 2 lists, returns TRUE if they are the same +/proc/deep_compare_list(list/list_1, list/list_2) + if(!islist(list_1) || !islist(list_2)) + return FALSE + + if(list_1 == list_2) + return TRUE + + if(list_1.len != list_2.len) + return FALSE + + for(var/i in 1 to list_1.len) + var/key_1 = list_1[i] + var/key_2 = list_2[i] + if (islist(key_1) && islist(key_2)) + if(!deep_compare_list(key_1, key_2)) + return FALSE + else if(key_1 != key_2) + return FALSE + if(istext(key_1) || islist(key_1) || ispath(key_1) || isdatum(key_1) || key_1 == world) + var/value_1 = list_1[key_1] + var/value_2 = list_2[key_1] + if (islist(value_1) && islist(value_2)) + if(!deep_compare_list(value_1, value_2)) + return FALSE + else if(value_1 != value_2) + return FALSE + + return TRUE diff --git a/code/__HELPERS/auxtools.dm b/code/__HELPERS/auxtools.dm new file mode 100644 index 00000000000..d53ad62db23 --- /dev/null +++ b/code/__HELPERS/auxtools.dm @@ -0,0 +1,12 @@ +/// Macro for getting the auxtools library file +#define AUXLUA (world.system_type == MS_WINDOWS ? "auxlua.dll" : __detect_auxtools("auxlua")) + +/proc/__detect_auxtools(library) + if(IsAdminAdvancedProcCall()) + return + if (fexists("./lib[library].so")) + return "./lib[library].so" + else if (fexists("[world.GetConfig("env", "HOME")]/.byond/bin/lib[library].so")) + return "[world.GetConfig("env", "HOME")]/.byond/bin/lib[library].so" + else + CRASH("Could not find lib[library].so") diff --git a/code/__HELPERS/logging/debug.dm b/code/__HELPERS/logging/debug.dm index c812f95518f..6e80f84d0ac 100644 --- a/code/__HELPERS/logging/debug.dm +++ b/code/__HELPERS/logging/debug.dm @@ -58,3 +58,7 @@ WRITE_LOG(GLOB.world_runtime_log, text) #endif SEND_TEXT(world.log, text) + +/// Logging for lua scripting +/proc/log_lua(text) + WRITE_LOG(GLOB.lua_log, text) diff --git a/code/_globalvars/logging.dm b/code/_globalvars/logging.dm index 31447e7cc50..fd513b9b632 100644 --- a/code/_globalvars/logging.dm +++ b/code/_globalvars/logging.dm @@ -67,6 +67,9 @@ GLOBAL_PROTECT(perf_log) GLOBAL_VAR(demo_log) GLOBAL_PROTECT(demo_log) +GLOBAL_VAR(lua_log) +GLOBAL_PROTECT(lua_log) + GLOBAL_LIST_EMPTY(bombers) GLOBAL_PROTECT(bombers) GLOBAL_LIST_EMPTY(admin_log) diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index a42c03f7371..abed471d462 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -399,3 +399,4 @@ integer = FALSE /datum/config_entry/flag/disallow_circuit_sounds + diff --git a/code/controllers/configuration/entries/lua.dm b/code/controllers/configuration/entries/lua.dm new file mode 100644 index 00000000000..fdfe5871165 --- /dev/null +++ b/code/controllers/configuration/entries/lua.dm @@ -0,0 +1,2 @@ +/datum/config_entry/str_list/lua_path + protection = CONFIG_ENTRY_LOCKED | CONFIG_ENTRY_HIDDEN diff --git a/code/controllers/subsystem/lua.dm b/code/controllers/subsystem/lua.dm new file mode 100644 index 00000000000..86923fe23c6 --- /dev/null +++ b/code/controllers/subsystem/lua.dm @@ -0,0 +1,166 @@ +//world/proc/shelleo +#define SHELLEO_ERRORLEVEL 1 +#define SHELLEO_STDOUT 2 +#define SHELLEO_STDERR 3 + +#define SSLUA_INIT_FAILED 2 + +SUBSYSTEM_DEF(lua) + name = "Lua Scripting" + runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT + wait = 0.1 SECONDS + + /// A list of all lua states + var/list/datum/lua_state/states = list() + + /// A list of open editors, with each key in the list associated with a list of editors. + /// Tracks which UIs are open for each state so that they can be updated whenever + /// code is run in the state. + var/list/editors + + var/list/sleeps = list() + var/list/resumes = list() + + var/list/current_run = list() + + /// Protects return values from getting GCed before getting converted to lua values + var/gc_guard + +/datum/controller/subsystem/lua/Initialize(start_timeofday) + try + + // Initialize the auxtools library + AUXTOOLS_CHECK(AUXLUA) + + // Set the wrappers for setting vars and calling procs + __lua_set_set_var_wrapper("/proc/wrap_lua_set_var") + __lua_set_datum_proc_call_wrapper("/proc/wrap_lua_datum_proc_call") + __lua_set_global_proc_call_wrapper("/proc/wrap_lua_global_proc_call") + __lua_set_print_wrapper("/proc/wrap_lua_print") + return ..() + catch(var/exception/e) + // Something went wrong, best not allow the subsystem to run + initialized = SSLUA_INIT_FAILED + can_fire = FALSE + var/time = (REALTIMEOFDAY - start_timeofday) / 10 + var/msg = "Failed to initialize [name] subsystem after [time] seconds!" + to_chat(world, span_boldwarning("[msg]")) + warning(e.name) + return time + +/datum/controller/subsystem/lua/OnConfigLoad() + // Get the current working directory - we need it to set the LUAU_PATH environment variable + var/here = world.shelleo(world.system_type == MS_WINDOWS ? "cd" : "pwd")[SHELLEO_STDOUT] + here = replacetext(here, "\n", "") + var/last_char = copytext_char(here, -1) + if(last_char != "/" && last_char != "\\") + here += "/" + + // Read the paths from the config file + var/list/lua_path = list() + var/list/config_paths = CONFIG_GET(str_list/lua_path) + for(var/path in config_paths) + if(path[1] != "/") + path = here + path + lua_path += path + world.SetConfig("env", "LUAU_PATH", jointext(lua_path, ";")) + +/datum/controller/subsystem/lua/Shutdown() + AUXTOOLS_SHUTDOWN(AUXLUA) + +/datum/controller/subsystem/lua/proc/queue_resume(datum/lua_state/state, index, arguments) + if(initialized != TRUE) + return + if(!istype(state)) + return + if(!arguments) + arguments = list() + else if(!islist(arguments)) + arguments = list(arguments) + resumes += list(list("state" = state, "index" = index, "arguments" = arguments)) + +/datum/controller/subsystem/lua/proc/kill_task(datum/lua_state/state, list/task_info) + if(!istype(state)) + return + if(!islist(task_info)) + return + if(!(istext(task_info["name"]) && istext(task_info["status"]) && isnum(task_info["index"]))) + return + switch(task_info["status"]) + if("sleep") + var/task_index = task_info["index"] + var/state_index = 1 + + // Get the nth sleep in the sleep list corresponding to the target state + for(var/i in 1 to length(sleeps)) + var/datum/lua_state/sleeping_state = sleeps[i] + if(sleeping_state == state) + if(state_index == task_index) + sleeps.Cut(i, i+1) + break + state_index++ + if("yield") + // Remove the resumt from the resumt list + for(var/i in 1 to length(resumes)) + var/resume = resumes[i] + if(resume["state"] == state && resume["index"] == task_info["index"]) + resumes.Cut(i, i+1) + break + state.kill_task(task_info) + +/datum/controller/subsystem/lua/fire(resumed) + // Each fire of SSlua awakens every sleeping task in the order they slept, + // then resumes every yielded task in the order their resumes were queued + if(!resumed) + current_run = list("sleeps" = sleeps.Copy(), "resumes" = resumes.Copy()) + sleeps.Cut() + resumes.Cut() + + var/list/current_sleeps = current_run["sleeps"] + var/list/affected_states = list() + while(length(current_sleeps)) + var/datum/lua_state/state = current_sleeps[1] + current_sleeps.Cut(1,2) + if(!istype(state)) + continue + affected_states |= state + var/result = state.awaken() + state.log_result(result, verbose = FALSE) + + if(MC_TICK_CHECK) + break + + if(!length(current_sleeps)) + var/list/current_resumes = current_run["resumes"] + while(length(current_resumes)) + var/list/resume_params = current_resumes[1] + current_resumes.Cut(1,2) + var/datum/lua_state/state = resume_params["state"] + if(!istype(state)) + continue + var/index = resume_params["index"] + if(isnull(index) || !isnum(index)) + continue + var/arguments = resume_params["arguments"] + if(!islist(arguments)) + continue + affected_states |= state + var/result = state.resume(arglist(list(index) + arguments)) + state.log_result(result, verbose = FALSE) + + if(MC_TICK_CHECK) + break + + // Update every lua editor TGUI open for each state that had a task awakened or resumed + for(var/state in affected_states) + var/list/editor_list = LAZYACCESS(editors, "\ref[state]") + if(editor_list) + for(var/datum/lua_editor/editor in editor_list) + SStgui.update_uis(editor) + +//world/proc/shelleo +#undef SHELLEO_ERRORLEVEL +#undef SHELLEO_STDOUT +#undef SHELLEO_STDERR + +#undef SSLUA_INIT_FAILED diff --git a/code/datums/callback.dm b/code/datums/callback.dm index 3ecc9202e47..3d655ef124e 100644 --- a/code/datums/callback.dm +++ b/code/datums/callback.dm @@ -124,6 +124,8 @@ else calling_arguments = args if(datum_flags & DF_VAR_EDITED) + if(usr && usr != GLOB.AdminProcCallHandler && !usr.client?.ckey) //This ONLY happens when usr's client switches mobs just before the callback gets invoked. + return HandleUserlessProcCall(usr, object, delegate, calling_arguments) return WrapAdminProcCall(object, delegate, calling_arguments) if (object == GLOBAL_PROC) return call(delegate)(arglist(calling_arguments)) @@ -159,6 +161,8 @@ else calling_arguments = args if(datum_flags & DF_VAR_EDITED) + if(usr != GLOB.AdminProcCallHandler && !usr?.client?.ckey) //This happens when a timer invokes a callback + return HandleUserlessProcCall(usr, object, delegate, calling_arguments) return WrapAdminProcCall(object, delegate, calling_arguments) if (object == GLOBAL_PROC) return call(delegate)(arglist(calling_arguments)) diff --git a/code/game/world.dm b/code/game/world.dm index c1c0add8822..8a10446469b 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -163,6 +163,8 @@ GLOBAL_VAR(restart_counter) GLOB.event_vote_log = "[GLOB.log_directory]/event_vote.log" // SKYRAT EDIT ADDITION + GLOB.lua_log = "[GLOB.log_directory]/lua.log" + #ifdef UNIT_TESTS GLOB.test_log = "[GLOB.log_directory]/tests.log" start_log(GLOB.test_log) @@ -300,8 +302,18 @@ GLOBAL_VAR(restart_counter) TgsReboot() shutdown_logging() // Past this point, no logging procs can be used, at risk of data loss. + AUXTOOLS_FULL_SHUTDOWN(AUXLUA) ..() + +/world/Del() + AUXTOOLS_FULL_SHUTDOWN(AUXLUA) + var/debug_server = world.GetConfig("env", "AUXTOOLS_DEBUG_DLL") + if (debug_server) + call(debug_server, "auxtools_shutdown")() + . = ..() + /* SKYRAT EDIT CHANGE - MOVED TO MODULAR + /world/proc/update_status() var/list/features = list() diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 130a55ce41e..f97196a2951 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -224,6 +224,7 @@ GLOBAL_PROTECT(admin_verbs_debug) /client/proc/cmd_admin_toggle_fov, /client/proc/cmd_admin_debug_traitor_objectives, /client/proc/spawn_debug_full_crew, + /client/proc/open_lua_editor, /client/proc/validate_puzzgrids, /client/proc/debug_spell_requirements, ) diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index 555bae65a72..f4201da79fa 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -2003,3 +2003,22 @@ if(!datum_to_mark) return return usr.client?.mark_datum(datum_to_mark) + + else if(href_list["lua_state"]) + if(!check_rights(R_DEBUG)) + return + var/datum/lua_state/state_to_view = locate(href_list["lua_state"]) + if(!state_to_view) + return + var/datum/lua_editor/editor = new(state_to_view) + var/log_index = href_list["log_index"] + if(log_index) + log_index = text2num(log_index) + if(log_index <= state_to_view.log.len) + var/list/log_entry = state_to_view.log[log_index] + if(log_entry["chunk"]) + LAZYINITLIST(editor.tgui_shared_states) + editor.tgui_shared_states["viewedChunk"] = json_encode(log_entry["chunk"]) + editor.tgui_shared_states["modal"] = json_encode("viewChunk") + editor.ui_interact(usr) + editor.tgui_shared_states = null diff --git a/code/modules/admin/verbs/SDQL2/SDQL_2_wrappers.dm b/code/modules/admin/verbs/SDQL2/SDQL_2_wrappers.dm index dd85da2414c..f5bc9911754 100644 --- a/code/modules/admin/verbs/SDQL2/SDQL_2_wrappers.dm +++ b/code/modules/admin/verbs/SDQL2/SDQL_2_wrappers.dm @@ -3,11 +3,11 @@ /proc/_abs(A) return abs(A) -/proc/_animate(atom/A, set_vars, time = 10, loop = 1, easing = LINEAR_EASING, flags = null) - var/mutable_appearance/MA = new() - for(var/v in set_vars) - MA.vars[v] = set_vars[v] - animate(A, appearance = MA, time, loop, easing, flags) +/proc/_animate(atom/target, set_vars, time = 10, loop = 1, easing = LINEAR_EASING, flags = null) + if(target) + animate(target, appearance = set_vars, time, loop, easing, flags) + else + animate(appearance = set_vars, time, easing = easing, flags) /proc/_arccos(A) return arccos(A) @@ -253,7 +253,34 @@ /proc/_winset(player, control_id, params) winset(player, control_id, params) - + /proc/_winget(player, control_id, params) winget(player, control_id, params) +/proc/_text2path(text) + return text2path(text) + +/proc/_turn(dir, angle) + return turn(dir, angle) + +/// For some reason, an atom's contents are a kind of list that auxtools can't work with as a list +/// This proc returns a copy of contents that is an ordinary list +/proc/_contents(atom/thing) + var/list/ret = list() + if(istype(thing)) + ret += thing.contents + return ret + +/// Auxtools REALLY doesn't know how to handle filters as values; +/// when passed as arguments to auxtools-called procs, they aren't simply treated as nulls - +/// they don't even count towards the length of args. +/// For example, calling some_proc([a filter], foo, bar) from auxtools +/// is equivalent to calling some_proc(foo, bar). Thus, we can't use _animate directly on filters. +/// Use this to perform animation steps on a filter. Consecutive steps on the same filter can be +/// achieved by calling _animate with no target. +/proc/_animate_filter(atom/target, filter_index, set_vars, time = 10, loop = 1, easing = LINEAR_EASING, flags = null) + if(!istype(target)) + return + if(!filter_index || filter_index < 1 || filter_index > length(target.filters)) + return + animate(target.filters[filter_index], appearance = set_vars, time, loop, easing, flags) diff --git a/code/modules/admin/verbs/lua/README.md b/code/modules/admin/verbs/lua/README.md new file mode 100644 index 00000000000..a729ef6b49c --- /dev/null +++ b/code/modules/admin/verbs/lua/README.md @@ -0,0 +1,181 @@ +# Auxlua + +--- + +## Datums +DM datums are treated as Lua userdata, and can be stored in fields. Regular datums are referenced weakly, so if a datum has been deleted, the corresponding userdata will evaluate to `nil` when used in comparisons or functions. + +Keep in mind that BYOND can't see that a datum is referenced in a Lua field, and will garbage collect it if it is not referenced anywhere in DM. + +### datum:get_var(var) +Equivalent to DM's `datum.var` + +### datum:set_var(var, value) +Equivalent to DM's `datum.var = value` + +### datum:call_proc(procName, ...) +Equivalent to DM's `datum.procName(...)` + +--- + +## Lists +In order to allow lists to be modified in-place across the DM-to-Lua language barrier, lists are treated as userdata. Whenever running code that expects a DM value, auxlua will attempt to convert tables into lists. + +List references are subject to the same limitations as datum userdata, but you are less likely to encounter these limitations. + +### list.len +Equivalent to DM's `list.len` + +### list:get(index) +Equivalent to DM's `list[index]` + +### list:set(index, value) +Equivalent to DM's `list[index] = value` + +### list:add(value) +Equivalent to DM's `list.Add(value)` + +### list:to_table() +Converts a DM list into a Lua table. + +--- + +## The dm table +The `dm` table consists of the basic hooks into the DM language. + +### dm.state_id +The address of the Lua state in memory. This is a copy of the internal value used by auxlua to locate the Lua state in a global hash map. + +### dm.global_proc(proc, ...) +Calls the global proc `/proc/[proc]` with `...` as its arguments. + +### dm.world +A reference to DM's `world`, in the form of datum userdata. This reference will never evaluate to `nil`, since `world` always exists. + +Due to limitations inherent in the wrapper functions used on tgstation, `world:set_var` and `world:call_proc` will raise an error. + +### dm.global_vars +A reference to DM's `global`, in the form of datum userdata. Subject to the same limitations as `dm.world` + +### dm.usr +A weak reference to DM's `usr`. As a rule of thumb, this is a reference to the mob of the client who triggered the chain of procs leading to the execution of Lua code. The following is a list of what `usr` is for the most common ways of executing Lua code: +- For resumes and awakens, which are generally executed by the MC, `usr` is (most likely) null. +- `SS13.wait` queues a resume, which gets executed by the MC. Therefore, `usr` is null after `SS13.wait` finishes. +- For chunk loads, `usr` is generally the current mob of the admin that loaded that chunk. +- For function calls done from the Lua editor, `usr` is the current mob of the admin calling the function. +- `SS13.register_signal` creates a `/datum/callback` that gets executed by the `SEND_SIGNAL` macro for the corresponding signal. As such, `usr` is the mob that triggered the chain of procs leading to the invocation of `SEND_SIGNAL`. + +--- + +## Task management +The Lua Scripting subsystem manages the execution of tasks for each Lua state. A single fire of the subsystem behaves as follows: +- All tasks that slept since the last fire are resumed in the order they slept. +- For each queued resume, the corresponding task is resumed. + +### sleep() +Yields the current thread, scheduling it to be resumed during the next fire of SSlua. Use this function to prevent your Lua code from exceeding its allowed execution duration. Under the hood, `sleep` performs the following: + +- Sets the global flag `__sleep_flag` +- Calls `coroutine.yield()` +- Clears the sleep flag when determining whether the task slept or yielded +- Ignores the return values of `coroutine.yield()` once resumed + +--- + +## The SS13 package +The `SS13` package contains various helper functions that use code specific to tgstation. + +### SS13.state +A reference to the state datum (`/datum/lua_state`) handling this Lua state. + +### SS13.global_proc +A wrapper for the magic string used to tell `WrapAdminProcCall` to call a global proc. +For instance, `/datum/callback` must be instantiated with `SS13.global_proc` as its first argument to specify that it will be invoking a global proc. +The following example declares a callback which will execute the global proc `to_chat`: +```lua +local callback = SS13.new("/datum/callback", SS13.global_proc, "to_chat", dm.world, "Hello World") +``` + +### SS13.istype(thing, type) +Equivalent to the DM statement `istype(thing, text2path(type))`. + +### SS13.new(type, ...) +Instantiates a datum of type `type` with `...` as the arguments passed to `/proc/_new` +The following example spawns a singularity at the caller's current turf: +```lua +SS13.new("/obj/singularity", dm.global_proc("_get_step", dm.usr, 0)) +``` + +### SS13.await(thing_to_call, proc_to_call, ...) +Calls `proc_to_call` on `thing_to_call`, with `...` as its arguments, and sleeps until that proc returns. +Returns two return values - the first is the return value of the proc, and the second is the message of any runtime exception thrown by the called proc. +The following example calls and awaits the return of `poll_ghost_candidates`: +```lua +local ghosts, runtime = SS13.await(SS13.global_proc, "poll_ghost_candidates", "Would you like to be considered for something?") +``` + +### SS13.wait(time, timer) +Waits for a number of **seconds** specified with the `time` argument. You can optionally specify a timer subsystem using the `timer` argument. + +Internally, this function creates a timer that will resume the current task after `time` seconds, then yields the current task by calling `coroutine.yield` with no arguments and ignores the return values. If the task is prematurely resumed, the timer will be safely deleted. + +### SS13.register_signal(datum, signal, func, make_easy_clear_function) +Registers the Lua function `func` as a handler for `signal` on `datum`. + +Like with signal handlers written in DM, Lua signal handlers should not sleep (either by calling `sleep` or `coroutine.yield`). + +If `make_easy_clear_function` is truthy, a member function taking no arguments will be created in the `SS13` table to easily unregister the signal handler. + +This function returns the `/datum/callback` created to call `func` from DM. + +The following example defines a function which will register a signal that makes `target` make a honking sound any time it moves: +```lua +function honk(target) + SS13.register_signal(target, "movable_moved", function(source) + dm.global_proc("playsound", target, "sound/items/bikehorn.ogg", 100, true) + end) +end +``` + +### SS13.unregister_signal(datum, signal, callback) +Unregister a signal previously registered using `SS13.register_signal`. `callback` should be a `datum/callback` previously returned by `SS13.register_signal`. If `callback` is not specified, **ALL** signal handlers registered on `datum` for `signal` will be unregistered. + +### SS13.set_timeout(time, func) +Creates a timer which will execute `func` after `time` **seconds**. `func` should not expect to be passed any arguments, as it will not be passed any. Unlike `SS13.wait`, `SS13.set_timeout` does not yield or sleep the current task, making it suitable for use in signal handlers for `SS13.register_signal` + +The following example will output a message to chat after 5 seconds: +```lua +SS13.set_timeout(5, function() + dm.global_proc("to_chat", dm.world, "Hello World!") +end) +``` + +--- + +## Internal globals +Auxlua defines several globals for internal use. These are read-only. + +### __sleep_flag +This flag is used to designate that a yielding task should be put in the sleep queue instead of the yield table. Once auxlua determines that a task should sleep, `__sleep_flag` is cleared. + +### __set_sleep_flag(value) + +A function that sets `__sleep_flag` to `value`. Calling this directly is not recommended, as doing so muddies the distinction between sleeps and yields. + +### __sleep_queue + +A sequence of threads, each corresponding to a task that has slept. When calling `/proc/__lua_awaken`, auxlua will dequeue the first thread from the sequence and resume it. Threads in this queue can be resumed from Lua code, but doing so is heavily advised against. + +### __yield_table + +A table of threads, each corresponding to a coroutine that has yielded. When calling `/proc/__lua_resume`, auxlua will look for a thread at the index specified in the `index` argument, and resume it with the arguments specified in the `arguments` argument. Threads in this table can be resumed from Lua code, but doing so is heavily advised against. + +### __task_info + +A table of key-value-pairs, where the keys are threads, and the values are tables consisting of the following fields: + +- name: A string containing the name of the task +- status: A string, either "sleep" or "yield" +- index: The task's index in `__sleep_queue` or `__yield_table` + +The threads constituting this table's keys can be resumed from Lua code, but doing so is heavily advised against. diff --git a/code/modules/admin/verbs/lua/_hooks.dm b/code/modules/admin/verbs/lua/_hooks.dm new file mode 100644 index 00000000000..a092947e06e --- /dev/null +++ b/code/modules/admin/verbs/lua/_hooks.dm @@ -0,0 +1,239 @@ +/datum + var/__auxtools_weakref_id //used by auxtools for weak references + +/** + * Sets a global proc to call in place of just outright setting a datum's var to a given value + * + * The proc will be called with the arguments (datum/datum_to_modify, var_name, value) + * + * required wrapper text the name of the proc to use as the wrapper + */ +/proc/__lua_set_set_var_wrapper(wrapper) + CRASH("auxlua not loaded") + +/** + * Sets a global proc to call in place of just outright calling a given proc on a datum + * + * The proc will be called with the arguments (datum/thing_to_call, proc_to_call, list/arguments) + * + * required wrapper text the name of the proc to use as the wrapper + */ +/proc/__lua_set_datum_proc_call_wrapper(wrapper) + CRASH("auxlua not loaded") + +/** + * Sets a global proc to call in place of just outright calling a given global proc + * + * The proc will be called with the arguments (proc_to_call, list/arguments) + * + * required wrapper text the name of the proc to use as the wrapper + */ +/proc/__lua_set_global_proc_call_wrapper(wrapper) + CRASH("auxlua not loaded") + +/** + * Sets a global proc as a wrapper for lua's print function + * + * The proc will be called with the arguments (state_id, list/arguments) + * + * required wrapper text the name of the proc to use as the wrapper + */ +/proc/__lua_set_print_wrapper(wrapper) + CRASH("auxlua not loaded") + +/** + * Sets the maximum amount of time a lua chunk or function can execute without sleeping or yielding. + * Chunks/functions that exceed this duration will produce an error. + * + * required limit number the execution limit, in milliseconds + */ +/proc/__lua_set_execution_limit(limit) + CRASH("auxlua not loaded") + +/** + * Creates a new lua state. + * + * return text a pointer to the created state. + */ +/proc/__lua_new_state() + CRASH("auxlua not loaded") + +/** + * Loads a chunk of lua source code and executes it + * + * required state text a pointer to the state + * in which to execute the code + * required script text the lua source code to execute + * optional name text a name to give to the chunk + * + * return list|text a list of lua return information + * or an error message if the state was corrupted + * + * Lua return information is formatted as followed: + * - ["status"]: How the chunk or function stopped code execution + * - "sleeping": The chunk or function called dm.sleep, + * placing it in the sleep queue. Items in the sleep + * queue can be resumed using /proc/__lua_awaken + * - "yielded": The chunk or function called coroutine.yield, + * placing it in the yield table. Items in the yield + * table can can be resumed by passing their index + * to /proc/__lua_resume + * - "finished": The chunk or function finished + * - "errored": The chunk or function produced an error + * - "bad return": The chunk or function yielded or finished, + * but its return value could not be converted to DM values + * - ["param"]: Depends on status. + * - "sleeping": null + * - "yielded" or "finished": The return/yield value(s) + * - "errored" or "bad return": The error message + * - ["yield_index"]: The index in the yield table where the + * chunk or function is located, for calls to __lua_resume + * - ["name"]: The name of the chunk or function, for logging + */ +/proc/__lua_load(state, script, name) + CRASH("auxlua not loaded") + +/** + * Calls a lua function + * + * required state text a pointer to the state + * in which to call the function + * required function text the name of the function to call + * optional arguments list arguments to pass to the function + * + * return list|text a list of lua return information + * or an error message if the state was corrupted + * + * Lua return information is formatted as followed: + * - ["status"]: How the chunk or function stopped code execution + * - "sleeping": The chunk or function called dm.sleep, + * placing it in the sleep queue. Items in the sleep + * queue can be resumed using /proc/__lua_awaken + * - "yielded": The chunk or function called coroutine.yield, + * placing it in the yield table. Items in the yield + * table can can be resumed by passing their index + * to /proc/__lua_resume + * - "finished": The chunk or function finished + * - "errored": The chunk or function produced an error + * - "bad return": The chunk or function yielded or finished, + * but its return value could not be converted to DM values + * - ["param"]: Depends on status. + * - "sleeping": null + * - "yielded" or "finished": The return/yield value(s) + * - "errored" or "bad return": The error message + * - ["yield_index"]: The index in the yield table where the + * chunk or function is located, for calls to __lua_resume + * - ["name"]: The name of the chunk or function, for logging + */ +/proc/__lua_call(state, function, arguments) + CRASH("auxlua not loaded") + +/** + * Dequeues the task at the front of the sleep queue and resumes it + * + * required state text a pointer to the state in which + * to resume a task + * + * return list|text|null a list of lua return information, + * an error message if the state is corrupted, + * or null if the sleep queue is empty + * + * Lua return information is formatted as followed: + * - ["status"]: How the chunk or function stopped code execution + * - "sleeping": The chunk or function called dm.sleep, + * placing it in the sleep queue. Items in the sleep + * queue can be resumed using /proc/__lua_awaken + * - "yielded": The chunk or function called coroutine.yield, + * placing it in the yield table. Items in the yield + * table can can be resumed by passing their index + * to /proc/__lua_resume + * - "finished": The chunk or function finished + * - "errored": The chunk or function produced an error + * - "bad return": The chunk or function yielded or finished, + * but its return value could not be converted to DM values + * - ["param"]: Depends on status. + * - "sleeping": null + * - "yielded" or "finished": The return/yield value(s) + * - "errored" or "bad return": The error message + * - ["yield_index"]: The index in the yield table where the + * chunk or function is located, for calls to __lua_resume + * - ["name"]: The name of the chunk or function, for logging + */ +/proc/__lua_awaken(state) + CRASH("auxlua not loaded") + +/** + * Removes the task at the specified index from the yield table + * and resumes it + * + * required state text a pointer to the state in which to + * resume a task + * required index number the index in the yield table of the + * task to resume + * optional arguments list the arguments to resume the task with + * + * return list|text|null a list of lua return information, + * an error message if the state is corrupted, + * or null if there is no task at the specified index + * + * Lua return information is formatted as followed: + * - ["status"]: How the chunk or function stopped code execution + * - "sleeping": The chunk or function called dm.sleep, + * placing it in the sleep queue. Items in the sleep + * queue can be resumed using /proc/__lua_awaken + * - "yielded": The chunk or function called coroutine.yield, + * placing it in the yield table. Items in the yield + * table can can be resumed by passing their index + * to /proc/__lua_resume + * - "finished": The chunk or function finished + * - "errored": The chunk or function produced an error + * - "bad return": The chunk or function yielded or finished, + * but its return value could not be converted to DM values + * - ["param"]: Depends on status. + * - "sleeping": null + * - "yielded" or "finished": The return/yield value(s) + * - "errored" or "bad return": The error message + * - ["yield_index"]: The index in the yield table where the + * chunk or function is located, for calls to __lua_resume + * - ["name"]: The name of the chunk or function, for logging + */ +/proc/__lua_resume(state, index, arguments) + CRASH("auxlua not loaded") + +/** + * Get the variables within a state's environment. + * Values not convertible to DM values are substituted + * for their types as text + * + * required state text a pointer to the state + * to get the variables from + * + * return list the variables of the state's environment + */ +/proc/__lua_get_globals(state) + CRASH("auxlua not loaded") + +/** + * Get a list of all tasks currently in progress within a state + * + * required state text a pointer to the state + * to get the tasks from + * + * return list a list of the state's tasks, formatted as follows: + * - name: The name of the task + * - status: Whether the task is sleeping or yielding + * - index: The index of the task in the sleep queue + * or yield table, whichever is applicable + */ +/proc/__lua_get_tasks(state) + CRASH("auxlua not loaded") + +/** + * Kills a task in progress + * + * required state text a pointer to the state + * in which to kill a task + * required info list the task info + */ +/proc/__lua_kill_task(state, info) + CRASH("auxlua not loaded") diff --git a/code/modules/admin/verbs/lua/_wrappers.dm b/code/modules/admin/verbs/lua/_wrappers.dm new file mode 100644 index 00000000000..671feb3a514 --- /dev/null +++ b/code/modules/admin/verbs/lua/_wrappers.dm @@ -0,0 +1,33 @@ +/proc/wrap_lua_set_var(datum/thing_to_set, var_name, value) + thing_to_set.vv_edit_var(var_name, value) + +/proc/wrap_lua_datum_proc_call(datum/thing_to_call, proc_name, list/arguments) + if(!usr) + usr = GLOB.lua_usr + if(usr) + SSlua.gc_guard = WrapAdminProcCall(thing_to_call, proc_name, arguments) + else + SSlua.gc_guard = HandleUserlessProcCall("lua", thing_to_call, proc_name, arguments) + return SSlua.gc_guard + +/proc/wrap_lua_global_proc_call(proc_name, list/arguments) + if(!usr) + usr = GLOB.lua_usr + if(usr) + SSlua.gc_guard = WrapAdminProcCall(GLOBAL_PROC, proc_name, arguments) + else + SSlua.gc_guard = HandleUserlessProcCall("lua", GLOBAL_PROC, proc_name, arguments) + return SSlua.gc_guard + +/proc/wrap_lua_print(state_id, list/arguments) + var/datum/lua_state/target_state + for(var/datum/lua_state/state as anything in SSlua.states) + if(state.internal_id == state_id) + target_state = state + break + if(!target_state) + return + var/print_message = jointext(arguments, "\t") + var/result = list("status" = "print", "param" = print_message) + target_state.log_result(result, verbose = TRUE) + log_lua("[target_state]: [print_message]") diff --git a/code/modules/admin/verbs/lua/helpers.dm b/code/modules/admin/verbs/lua/helpers.dm new file mode 100644 index 00000000000..4515bc7b3ff --- /dev/null +++ b/code/modules/admin/verbs/lua/helpers.dm @@ -0,0 +1,32 @@ +#define PROMISE_PENDING 0 +#define PROMISE_RESOLVED 1 +#define PROMISE_REJECTED 2 + +/** + * Auxtools hooks act as "set waitfor = 0" procs. This means that whenever + * a proc directly called from auxtools sleeps, the hook returns with whatever + * the called proc had as its return value at the moment it slept. This may not + * be desired behavior, so this datum exists to wrap these procs. + */ +/datum/auxtools_promise + var/datum/callback/callback + var/return_value + var/runtime_message + var/status = PROMISE_PENDING + +/datum/auxtools_promise/New(...) + callback = CALLBACK(arglist(args)) + perform() + +/datum/auxtools_promise/proc/perform() + set waitfor = 0 + try + return_value = callback.Invoke() + status = PROMISE_RESOLVED + catch(var/exception/e) + runtime_message = e.name + status = PROMISE_REJECTED + +#undef PROMISE_PENDING +#undef PROMISE_RESOLVED +#undef PROMISE_REJECTED diff --git a/code/modules/admin/verbs/lua/lua_editor.dm b/code/modules/admin/verbs/lua/lua_editor.dm new file mode 100644 index 00000000000..157b07b2130 --- /dev/null +++ b/code/modules/admin/verbs/lua/lua_editor.dm @@ -0,0 +1,196 @@ +/datum/lua_editor + var/datum/lua_state/current_state + + /// Code imported from the user's system + var/imported_code + + /// Arguments for a function call or coroutine resume + var/list/arguments = list() + +/datum/lua_editor/New(state, _quick_log_index) + . = ..() + if(state) + current_state = state + LAZYADDASSOCLIST(SSlua.editors, "\ref[current_state]", src) + +/datum/lua_editor/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "LuaEditor", "Lua") + ui.set_autoupdate(FALSE) + ui.open() + +/datum/lua_editor/Destroy(force, ...) + . = ..() + if(current_state) + LAZYREMOVEASSOC(SSlua.editors, "\ref[current_state]", src) + +/datum/lua_editor/ui_state(mob/user) + return GLOB.debug_state + +/datum/lua_editor/ui_static_data(mob/user) + var/list/data = list() + data["documentation"] = file2text('code/modules/admin/verbs/lua/README.md') + return data + +/datum/lua_editor/ui_data(mob/user) + var/list/data = list() + data["noStateYet"] = !current_state + if(current_state) + current_state.get_globals() + if(current_state.log) + data["stateLog"] = kvpify_list(refify_list(current_state.log)) + data["tasks"] = current_state.get_tasks() + if(current_state.globals) + data["globals"] = kvpify_list(refify_list(current_state.globals)) + data["states"] = SSlua.states + data["callArguments"] = kvpify_list(refify_list(arguments)) + return data + +/datum/lua_editor/proc/traverse_list(list/path, list/root, traversal_depth_offset = 0) + var/top_affected_list_depth = LAZYLEN(path)-traversal_depth_offset // The depth of the element to get + if(top_affected_list_depth) + var/list/current_list = root + // We kvpify the list to the depth of the element to get - this allows us to reach list elements contained within a assoc list's key + var/list/path_list = kvpify_list(current_list, top_affected_list_depth-1) + while(LAZYLEN(path) > traversal_depth_offset) + // Navigate to the index of the next path element within the current path element + var/list/path_element = popleft(path) + var/list/list_element = path_list[path_element["index"]] + + // Enter the next path element - be it the key or the value + switch(path_element["type"]) + if("key") + path_list = list_element["key"] + if("value") + path_list = list_element["value"] + else + to_chat(usr, span_warning("invalid path element type \[[path_element["type"]]] for list traversal (expected \"key\" or \"value\"")) + return + // The element we are entering SHOULD be a list, unless we're at the end of the path + if(!islist(path_list) && LAZYLEN(path)) + to_chat(usr, span_warning("invalid path element \[[path_list]] for list traversal (expected a list)")) + return + current_list = path_list + return current_list + else + return root + +/datum/lua_editor/ui_act(action, list/params) + . = ..() + if(.) + return + if(!check_rights_for(usr.client, R_DEBUG)) + return + switch(action) + if("newState") + var/state_name = params["name"] + var/datum/lua_state/new_state = new(state_name) + SSlua.states += new_state + LAZYREMOVEASSOC(SSlua.editors, "\ref[current_state]", src) + current_state = new_state + LAZYADDASSOCLIST(SSlua.editors, "\ref[current_state]", src) + return TRUE + if("switchState") + var/state_index = params["index"] + LAZYREMOVEASSOC(SSlua.editors, "\ref[current_state]", src) + current_state = SSlua.states[state_index] + LAZYADDASSOCLIST(SSlua.editors, "\ref[current_state]", src) + return TRUE + if("runCode") + var/code = params["code"] + var/result = current_state.load_script(code) + current_state.log_result(result) + return TRUE + if("moveArgUp") + var/list/path = params["path"] + var/list/target_list = traverse_list(path, arguments, traversal_depth_offset = 1) + var/index = popleft(path)["index"] + target_list.Swap(index-1, index) + return TRUE + if("moveArgDown") + var/list/path = params["path"] + var/list/target_list = traverse_list(path, arguments, traversal_depth_offset = 1) + var/index = popleft(path)["index"] + target_list.Swap(index, index+1) + return TRUE + if("removeArg") + var/list/path = params["path"] + var/list/target_list = traverse_list(path, arguments, traversal_depth_offset = 1) + var/index = popleft(path)["index"] + target_list.Cut(index, index+1) + return TRUE + if("addArg") + var/list/path = params["path"] + var/list/target_list = traverse_list(path, arguments) + if(target_list != arguments) + usr?.client?.mod_list_add(target_list, null, "a lua editor", "arguments") + else + var/list/vv_val = usr?.client?.vv_get_value(restricted_classes = list(VV_RESTORE_DEFAULT)) + var/class = vv_val["class"] + if(!class) + return + LAZYADD(arguments, list(vv_val["value"])) + return TRUE + if("callFunction") + var/list/recursive_indices = params["indices"] + var/list/current_list = kvpify_list(current_state.globals) + var/function = list() + while(LAZYLEN(recursive_indices)) + var/index = popleft(recursive_indices) + var/list/element = current_list[index] + var/key = element["key"] + var/value = element["value"] + if(!(istext(key) || isnum(key))) + to_chat(usr, span_warning("invalid key \[[key]] for function call (expected text or num)")) + return + function += key + if(islist(value)) + current_list = value + else + var/regex/function_regex = regex("^function: 0x\[0-9a-fA-F]+$") + if(function_regex.Find(value)) + break + to_chat(usr, span_warning("invalid path element \[[value]] for function call (expected list or text matching [function_regex])")) + return + var/result = current_state.call_function(arglist(list(function) + arguments)) + current_state.log_result(result) + arguments.Cut() + return TRUE + if("resumeTask") + var/task_index = params["index"] + SSlua.queue_resume(current_state, task_index, arguments) + arguments.Cut() + return TRUE + if("killTask") + var/task_info = params["info"] + SSlua.kill_task(current_state, task_info) + return TRUE + if("vvReturnValue") + var/log_entry_index = params["entryIndex"] + var/list/log_entry = current_state.log[log_entry_index] + var/thing_to_debug = traverse_list(params["tableIndices"], log_entry["param"]) + INVOKE_ASYNC(usr.client, /client.proc/debug_variables, thing_to_debug) + return FALSE + if("vvGlobal") + var/thing_to_debug = traverse_list(params["indices"], current_state.globals) + INVOKE_ASYNC(usr.client, /client.proc/debug_variables, thing_to_debug) + return FALSE + if("clearArgs") + arguments.Cut() + return TRUE + +/datum/lua_editor/ui_close(mob/user) + . = ..() + qdel(src) + +/client/proc/open_lua_editor() + set name = "Open Lua Editor" + set category = "Debug" + if(!check_rights_for(src, R_DEBUG)) + return + if(SSlua.initialized != TRUE) + to_chat(usr, span_warning("SSlua is not initialized!")) + return + var/datum/lua_editor/editor = new() + editor.ui_interact(usr) diff --git a/code/modules/admin/verbs/lua/lua_state.dm b/code/modules/admin/verbs/lua/lua_state.dm new file mode 100644 index 00000000000..15c9ab9ad77 --- /dev/null +++ b/code/modules/admin/verbs/lua/lua_state.dm @@ -0,0 +1,137 @@ +#define MAX_LOG_REPEAT_LOOKBACK 5 + +GLOBAL_VAR_INIT(IsLuaCall, FALSE) +GLOBAL_PROTECT(IsLuaCall) + +GLOBAL_DATUM(lua_usr, /mob) +GLOBAL_PROTECT(lua_usr) + +/datum/lua_state + var/name + + /// The internal ID of the lua state stored in auxlua's global map + var/internal_id + + /// A log of every return, yield, and error for each chunk execution and function call + var/list/log = list() + + /// A list of all the variables in the state's environment + var/list/globals = list() + + /// A list in which to store datums and lists instantiated in lua, ensuring that they don't get garbage collected + var/list/references = list() + +/datum/lua_state/vv_edit_var(var_name, var_value) + . = ..() + if(var_name == NAMEOF(src, internal_id)) + return FALSE + +/datum/lua_state/New(_name) + if(SSlua.initialized != TRUE) + qdel(src) + return + name = _name + internal_id = __lua_new_state() + +/datum/lua_state/proc/check_if_slept(result) + if(result["status"] == "sleeping") + SSlua.sleeps += src + +/datum/lua_state/proc/log_result(result, verbose = TRUE) + if(!verbose && result["status"] != "errored" && result["status"] != "bad return" \ + && !(result["name"] == "input" && (result["status"] == "finished" || length(result["param"])))) + return + var/append_to_log = TRUE + if(log.len) + for(var/index in log.len to max(log.len - MAX_LOG_REPEAT_LOOKBACK, 1) step -1) + var/list/entry = log[index] + if(entry["status"] == result["status"] \ + && entry["chunk"] == result["chunk"] \ + && entry["name"] == result["name"] \ + && ((entry["param"] == result["param"]) || deep_compare_list(entry["param"], result["param"]))) + if(!entry["repeats"]) + entry["repeats"] = 0 + entry["repeats"]++ + append_to_log = FALSE + break + if(append_to_log) + log += list(result) + +/datum/lua_state/proc/load_script(script) + GLOB.IsLuaCall = TRUE + var/tmp_usr = GLOB.lua_usr + GLOB.lua_usr = usr + var/result = __lua_load(internal_id, script) + GLOB.IsLuaCall = FALSE + GLOB.lua_usr = tmp_usr + + // Internal errors unrelated to the code being executed are returned as text rather than lists + if(istext(result)) + result = list("status" = "errored", "param" = result, "name" = "input") + result["chunk"] = script + check_if_slept(result) + + message_admins("[key_name(usr)] executed [length(script)] bytes of lua code. [ADMIN_LUAVIEW_CHUNK(src, log.len)]") + log_lua("[key_name(usr)] executed the following lua code:\n[script]") + + return result + +/datum/lua_state/proc/call_function(function, ...) + var/call_args = length(args) > 1 ? args.Copy(2) : list() + var/msg = "[key_name(usr)] called the lua function \"[function]\" with arguments: [english_list(call_args)]" + log_lua(msg) + + var/tmp_usr = GLOB.lua_usr + GLOB.lua_usr = usr + GLOB.IsLuaCall = TRUE + var/result = __lua_call(internal_id, function, call_args) + GLOB.IsLuaCall = FALSE + GLOB.lua_usr = tmp_usr + + if(istext(result)) + result = list("status" = "errored", "param" = result, "name" = islist(function) ? jointext(function, ".") : function) + check_if_slept(result) + return result + +/datum/lua_state/proc/call_function_return_first(function, ...) + var/list/result = call_function(arglist(args)) + log_result(result, verbose = FALSE) + if(length(result)) + if(islist(result["param"]) && length(result["param"])) + return result["param"][1] + +/datum/lua_state/proc/awaken() + GLOB.IsLuaCall = TRUE + var/result = __lua_awaken(internal_id) + GLOB.IsLuaCall = FALSE + + if(istext(result)) + result = list("status" = "errored", "param" = result, "name" = "An attempted awaken") + check_if_slept(result) + return result + +/// Prefer calling SSlua.queue_resume over directly calling this +/datum/lua_state/proc/resume(index, ...) + var/call_args = length(args) > 1 ? args.Copy(2) : list() + var/msg = "[key_name(usr)] resumed a lua coroutine with arguments: [english_list(call_args)]" + log_lua(msg) + + GLOB.IsLuaCall = TRUE + var/result = __lua_resume(internal_id, index, call_args) + GLOB.IsLuaCall = FALSE + + if(istext(result)) + result = list("status" = "errored", "param" = result, "name" = "An attempted resume") + check_if_slept(result) + return result + +/datum/lua_state/proc/get_globals() + globals = __lua_get_globals(internal_id) + +/datum/lua_state/proc/get_tasks() + return __lua_get_tasks(internal_id) + +/datum/lua_state/proc/kill_task(task_info) + __lua_kill_task(internal_id, task_info) + +#undef MAX_LOG_REPEAT_LOOKBACK diff --git a/code/modules/admin/view_variables/modify_variables.dm b/code/modules/admin/view_variables/modify_variables.dm index 045fbfc4261..d8255f8b397 100644 --- a/code/modules/admin/view_variables/modify_variables.dm +++ b/code/modules/admin/view_variables/modify_variables.dm @@ -95,7 +95,7 @@ GLOBAL_PROTECT(VVpixelmovement) if (O) L = L.Copy() - L += var_value + L += list(var_value) //var_value could be a list switch(tgui_alert(usr,"Would you like to associate a value with the list entry?",,list("Yes","No"))) if("Yes") diff --git a/config/config.txt b/config/config.txt index 40ce9f086cb..342f6e712eb 100644 --- a/config/config.txt +++ b/config/config.txt @@ -6,6 +6,7 @@ $include comms.txt $include resources.txt $include skyrat/skyrat_config.txt $include interviews.txt +$include lua.txt # You can use the @ character at the beginning of a config option to lock it from being edited in-game # Example usage: diff --git a/config/lua.txt b/config/lua.txt new file mode 100644 index 00000000000..16d5b497f78 --- /dev/null +++ b/config/lua.txt @@ -0,0 +1,9 @@ +## This file allows server hosts to specify the paths that the lua function "require" searches for modules in. +## The character '?' will be replaced with the name of the module being searched. + +LUA_PATH ? +LUA_PATH ?.lua +LUA_PATH ?.luau +LUA_PATH lua/? +LUA_PATH lua/?.lua +LUA_PATH lua/?.luau diff --git a/dependencies.sh b/dependencies.sh index e154c99281c..95faf891f8b 100644 --- a/dependencies.sh +++ b/dependencies.sh @@ -19,3 +19,9 @@ export SPACEMAN_DMM_VERSION=suite-1.7.1 # Python version for mapmerge and other tools export PYTHON_VERSION=3.7.9 + +#auxlua repo +export AUXLUA_REPO=tgstation/auxlua + +#auxlua git tag +export AUXLUA_VERSION=0.2.0 diff --git a/lua/SS13.lua b/lua/SS13.lua new file mode 100644 index 00000000000..a2965a327da --- /dev/null +++ b/lua/SS13.lua @@ -0,0 +1,153 @@ +local SS13 = {} + +SS13.SSlua = dm.global_vars:get_var("SSlua") + +SS13.global_proc = "some_magic_bullshit" + +local states = SS13.SSlua:get_var("states"):to_table() +for _, state in states do + if state:get_var("internal_id") == dm.state_id then + SS13.state = state + break + end +end + +function SS13.istype(thing, type) + return dm.global_proc("_istype", thing, dm.global_proc("_text2path", type)) == 1 +end + +function SS13.new(type, ...) + local datum = dm.global_proc("_new", type, {...}) + local references = SS13.state:get_var("references") + references:add(datum) + return datum +end + +function SS13.await(thing_to_call, proc_to_call, ...) + if not SS13.istype(thing_to_call, "/datum") then + thing_to_call = SS13.global_proc + end + if thing_to_call == SS13.global_proc then + proc_to_call = "/proc/" .. proc_to_call + end + local promise = SS13.new("/datum/auxtools_promise", thing_to_call, proc_to_call, ...) + while promise:get_var("status") == 0 do + sleep() + end + return promise:get_var("return_value"), promise:get_var("runtime_message") +end + +function SS13.wait(time, timer) + local index = #__yield_table + 1 + local callback = SS13.new("/datum/callback", SS13.SSlua, "queue_resume", SS13.state, index) + local timedevent = dm.global_proc("_addtimer", callback, time*10, 8, timer, debug.info(1, "sl")) + coroutine.yield() + dm.global_proc("deltimer", timedevent, timer) +end + + +function SS13.register_signal(datum, signal, func, make_easy_clear_function) + if not SS13.signal_handlers then + SS13.signal_handlers = {} + end + if not SS13.istype(datum, "/datum") then return end + local ref = dm.global_proc("REF", datum) + if not SS13.signal_handlers[ref] then + SS13.signal_handlers[ref] = {} + end + if signal == "_cleanup" then return end + if not SS13.signal_handlers[ref][signal] then + SS13.signal_handlers[ref][signal] = {} + end + local callback = SS13.new("/datum/callback", SS13.state, "call_function_return_first") + callback:call_proc("RegisterSignal", datum, signal, "Invoke") + local callback_ref = dm.global_proc("REF", callback) + local path = {"SS13", "signal_handlers", ref, signal, callback_ref, "func"} + callback:set_var("arguments", {path}) + if not SS13.signal_handlers[ref]["_cleanup"] then + local cleanup_path = {"SS13", "signal_handlers", ref, "_cleanup"} + local cleanup_callback = SS13.new("/datum/callback", SS13.state, "call_function_return_first", cleanup_path) + cleanup_callback:call_proc("RegisterSignal", datum, "parent_qdeleting", "Invoke") + SS13.signal_handlers[ref]["_cleanup"] = function(datum) + SS13.signal_handler_cleanup(datum) + dm.global_proc("qdel", cleanup_callback) + end + end + SS13.signal_handlers[ref][signal][callback_ref] = {func=func, callback=callback} + if make_easy_clear_function then + local clear_function_name = "clear_signal_" .. ref .. "_" .. signal .. "_" .. callback_ref + SS13[clear_function_name] = function() + if callback then + SS13.unregister_signal(datum, signal, callback) + end + SS13[clear_function_name] = nil + end + end + return callback +end + + +function SS13.unregister_signal(datum, signal, callback) + local function clear_handler(handler_info) + if not handler_info then return end + if not handler_info.callback then return end + local handler_callback = handler_info.callback + handler_callback:call_proc("UnregisterSignal", datum, signal) + dm.global_proc("qdel", handler_callback) + end + + if not SS13.signal_handlers then return end + + local ref = dm.global_proc("REF", datum) + local function clear_easy_clear_function(callback_ref) + local clear_function_name = "clear_signal_" .. ref .. "_" .. signal .. "_" .. callback_ref + SS13[clear_function_name] = nil + end + + if not SS13.signal_handlers[ref] then return end + if signal == "_cleanup" then return end + if not SS13.signal_handlers[ref][signal] then return end + + if not callback then + for handler_key, handler_info in SS13.signal_handlers[ref][signal] do + clear_easy_clear_function(handler_key) + clear_handler(handler_info) + end + SS13.signal_handlers[ref][signal] = nil + else + if not SS13.istype(callback, "/datum/callback") then return end + local callback_ref = dm.global_proc("REF", callback) + clear_easy_clear_function(callback_ref) + clear_handler(SS13.signal_handlers[ref][signal][callback_ref]) + SS13.signal_handlers[ref][signal][callback_ref] = nil + end +end + +function SS13.signal_handler_cleanup(datum) + if not SS13.signal_handlers then return end + local ref = dm.global_proc("REF", datum) + if not SS13.signal_handlers[ref] then return end + + for signal, _ in SS13.signal_handlers[ref] do + SS13.unregister_signal(datum, signal) + end + + SS13.signal_handlers[ref] = nil +end + +function SS13.set_timeout(time, func) + if not SS13.timeouts then + SS13.timeouts = {} + end + local callback = SS13.new("/datum/callback", SS13.state, "call_function") + local callback_ref = dm.global_proc("REF", callback) + SS13.timeouts[callback_ref] = function() + SS13.timeouts[callback_ref] = nil + func() + end + local path = {"SS13", "timeouts", callback_ref} + callback:set_var("arguments", {path}) + dm.global_proc("_addtimer", callback, time*10, 8, nil, debug.info(1, "sl")) +end + +return SS13 diff --git a/tgstation.dme b/tgstation.dme index 6acfc911504..b39b778a7d8 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -336,6 +336,7 @@ #include "code\__DEFINES\~skyrat_defines\vox_defines.dm" #include "code\__DEFINES\~skyrat_defines\_globalvars\lists\mapping.dm" #include "code\__DEFINES\~skyrat_defines\_HELPERS\lighting.dm" +#include "code\__HELPERS\_auxtools_api.dm" #include "code\__HELPERS\_lists.dm" #include "code\__HELPERS\_string_lists.dm" #include "code\__HELPERS\admin.dm" @@ -343,6 +344,7 @@ #include "code\__HELPERS\areas.dm" #include "code\__HELPERS\atmospherics.dm" #include "code\__HELPERS\atoms.dm" +#include "code\__HELPERS\auxtools.dm" #include "code\__HELPERS\bitflag_lists.dm" #include "code\__HELPERS\bodyparts.dm" #include "code\__HELPERS\chat.dm" @@ -522,6 +524,7 @@ #include "code\controllers\configuration\entries\game_options.dm" #include "code\controllers\configuration\entries\general.dm" #include "code\controllers\configuration\entries\interview.dm" +#include "code\controllers\configuration\entries\lua.dm" #include "code\controllers\configuration\entries\resources.dm" #include "code\controllers\subsystem\achievements.dm" #include "code\controllers\subsystem\addiction.dm" @@ -559,6 +562,7 @@ #include "code\controllers\subsystem\language.dm" #include "code\controllers\subsystem\library.dm" #include "code\controllers\subsystem\lighting.dm" +#include "code\controllers\subsystem\lua.dm" #include "code\controllers\subsystem\machines.dm" #include "code\controllers\subsystem\mapping.dm" #include "code\controllers\subsystem\materials.dm" @@ -2128,6 +2132,11 @@ #include "code\modules\admin\verbs\server.dm" #include "code\modules\admin\verbs\shuttlepanel.dm" #include "code\modules\admin\verbs\spawnobjasmob.dm" +#include "code\modules\admin\verbs\lua\_hooks.dm" +#include "code\modules\admin\verbs\lua\_wrappers.dm" +#include "code\modules\admin\verbs\lua\helpers.dm" +#include "code\modules\admin\verbs\lua\lua_editor.dm" +#include "code\modules\admin\verbs\lua\lua_state.dm" #include "code\modules\admin\verbs\SDQL2\SDQL_2.dm" #include "code\modules\admin\verbs\SDQL2\SDQL_2_parser.dm" #include "code\modules\admin\verbs\SDQL2\SDQL_2_wrappers.dm" diff --git a/tgui/packages/tgui/components/Button.js b/tgui/packages/tgui/components/Button.js index 60ba8567ccf..ef434d04c7a 100644 --- a/tgui/packages/tgui/components/Button.js +++ b/tgui/packages/tgui/components/Button.js @@ -309,3 +309,55 @@ export class ButtonInput extends Component { } Button.Input = ButtonInput; + +export class ButtonFile extends Component { + constructor() { + super(); + this.inputRef = createRef(); + } + + async read(files) { + const promises = Array.from(files).map((file) => { + let reader = new FileReader(); + return new Promise((resolve) => { + reader.onload = () => resolve(reader.result); + reader.readAsText(file); + }); + }); + + return await Promise.all(promises); + } + + render() { + const { onSelectFiles, accept, multiple, ...rest } = this.props; + const filePicker = ( + { + const files = this.inputRef.current.files; + if (files.length) { + const readFiles = await this.read(files); + onSelectFiles(multiple ? readFiles : readFiles[0]); + } + }} + /> + ); + return ( + <> + + }> + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/LuaEditor/ChunkViewModal.js b/tgui/packages/tgui/interfaces/LuaEditor/ChunkViewModal.js new file mode 100644 index 00000000000..c6abb8a8cc2 --- /dev/null +++ b/tgui/packages/tgui/interfaces/LuaEditor/ChunkViewModal.js @@ -0,0 +1,38 @@ +import { useLocalState } from '../../backend'; +import { Button, Modal, Section, Box } from '../../components'; +import { sanitizeText } from '../../sanitize'; +import hljs from 'highlight.js/lib/core'; + +export const ChunkViewModal = (props, context) => { + const [, setModal] = useLocalState(context, 'modal'); + const [viewedChunk, setViewedChunk] = useLocalState(context, 'viewedChunk'); + return ( + +
{ + setModal(null); + setViewedChunk(null); + }}> + Close + + }> + +
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/LuaEditor/ListMapper.js b/tgui/packages/tgui/interfaces/LuaEditor/ListMapper.js new file mode 100644 index 00000000000..53ff0839d1a --- /dev/null +++ b/tgui/packages/tgui/interfaces/LuaEditor/ListMapper.js @@ -0,0 +1,163 @@ +import { useBackend, useLocalState } from '../../backend'; +import { Box, Button, Collapsible, LabeledList, Section } from '../../components'; + +const RefRegex = RegExp('^.+ \\[0x[0-9a-fA-F]+]$'); +const FunctionRegex = RegExp('^function: 0x[0-9a-fA-F]+$'); +const UnconvertibleLuaValueRegex = RegExp( + '^(table|function|thread): 0x[0-9a-fA-F]+$' +); + +export const ListMapper = (props, context) => { + const { act } = useBackend(context); + + const { + list, + path, + editable, + name, + callType, + vvAct, + skipNulls, + collapsible, + ...rest + } = props; + + const [, setToCall] = useLocalState(context, 'toCallTaskInfo'); + const [, setModal] = useLocalState(context, 'modal'); + + const ThingNode = (thing, path, overrideProps) => { + if (Array.isArray(thing)) { + return ( + + ); + } else if (typeof thing === 'string') { + if (FunctionRegex.test(thing) && callType) { + return ( + + ); + } else if (UnconvertibleLuaValueRegex.test(thing)) { + return {thing.charAt(0).toUpperCase() + thing.substring(1)}; + } else if (RefRegex.test(thing)) { + return ( + + ); + } else if (thing === null) { + return Nil; + } else { + return thing; + } + } else { + return {thing}; + } + }; + + const ListMapperInner = (element, i) => { + const { key, value } = element; + const basePath = path ? path : []; + let keyPath = [...basePath, { index: i + 1, type: 'key' }]; + let valuePath = [...basePath, { index: i + 1, type: 'value' }]; + let entryPath = [...basePath, { index: i + 1, type: 'entry' }]; + + if (key === null && skipNulls) { + return; + } + + /* + * Finding a function only accessible as a table's key is too awkward to + * deal with for now + */ + let keyNode = ThingNode(key, keyPath, { callType: null }); + + /* + * Likewise, since table, thread, and userdata equality is tested by + * reference rather than value, we can't find functions whose keys + * within the table are tables, threads, or userdata + */ + const uniquelyIndexable = + (typeof key === 'string' && + !(UnconvertibleLuaValueRegex.test(key) || RefRegex.test(key))) || + typeof key === 'number'; + let valueNode = ThingNode(value, valuePath, { + callType: uniquelyIndexable && callType, + }); + return ( + + + + ); + } + return ( + <> + {i > 0 && } + + {message} + + {repeats && ( + + x{repeats + 1} + + )} + + ); + }); +}; diff --git a/tgui/packages/tgui/interfaces/LuaEditor/StateSelectModal.js b/tgui/packages/tgui/interfaces/LuaEditor/StateSelectModal.js new file mode 100644 index 00000000000..918ec0d992a --- /dev/null +++ b/tgui/packages/tgui/interfaces/LuaEditor/StateSelectModal.js @@ -0,0 +1,58 @@ +import { useBackend, useLocalState } from '../../backend'; +import { Button, Modal, Input, Section, Stack } from '../../components'; + +export const StateSelectModal = (props, context) => { + const { act, data } = useBackend(context); + const [, setModal] = useLocalState(context, 'modal', 'states'); + const [input, setInput] = useLocalState(context, 'newStateName', ''); + const { states } = data; + return ( + +
{ + setModal(null); + }}> + Cancel + + }> + {states.map((value, i) => ( + + ))} + + + { + setInput(value); + }} + /> + + +
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/LuaEditor/TaskManager.js b/tgui/packages/tgui/interfaces/LuaEditor/TaskManager.js new file mode 100644 index 00000000000..19dbf6265ff --- /dev/null +++ b/tgui/packages/tgui/interfaces/LuaEditor/TaskManager.js @@ -0,0 +1,68 @@ +import { useBackend, useLocalState } from '../../backend'; +import { Button, LabeledList, Section, Stack } from '../../components'; + +export const TaskManager = (props, context) => { + const { act, data } = useBackend(context); + const [, setToCall] = useLocalState(context, 'toCallTaskInfo'); + const [, setModal] = useLocalState(context, 'modal'); + let { tasks } = data; + tasks?.sort((a, b) => { + if (a.status < b.status) { + return -1; + } else if (a.status > b.status) { + return 1; + } else { + return 0; + } + }); + const sleeps = tasks.filter((info) => info.status === 'sleep'); + const yields = tasks.filter((info) => info.status === 'yield'); + return ( + + +
+ + {sleeps.map((info, i) => ( + + + + ))} + +
+
+ +
+ + {yields.map((info, i) => ( + + + + + ))} + +
+
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/LuaEditor/index.js b/tgui/packages/tgui/interfaces/LuaEditor/index.js new file mode 100644 index 00000000000..0a453466c36 --- /dev/null +++ b/tgui/packages/tgui/interfaces/LuaEditor/index.js @@ -0,0 +1,159 @@ +import { useBackend, useLocalState } from '../../backend'; +import { Box, Button, Flex, Section, Tabs, TextArea, Modal, Stack } from '../../components'; +import { Window } from '../../layouts'; +import { CallModal } from './CallModal'; +import { ChunkViewModal } from './ChunkViewModal'; +import { StateSelectModal } from './StateSelectModal'; +import { ListMapper } from './ListMapper'; +import { Log } from './Log'; +import { TaskManager } from './TaskManager'; +import { sanitizeText } from '../../sanitize'; +import { marked } from 'marked'; +import hljs from 'highlight.js/lib/core'; +import lua from 'highlight.js/lib/languages/lua'; +hljs.registerLanguage('lua', lua); + +export const LuaEditor = (props, context) => { + const { act, data } = useBackend(context); + const { noStateYet, globals, documentation } = data; + const [modal, setModal] = useLocalState( + context, + 'modal', + noStateYet ? 'states' : null + ); + const [activeTab, setActiveTab] = useLocalState( + context, + 'activeTab', + 'globals' + ); + const [input, setInput] = useLocalState(context, 'scriptInput', ''); + let tabContent; + switch (activeTab) { + case 'globals': { + tabContent = ( + act('vvGlobal', { indices: path })} + callType="callFunction" + /> + ); + break; + } + case 'tasks': { + tabContent = ; + break; + } + case 'log': { + tabContent = ; + break; + } + } + return ( + + + + {noStateYet ? ( + +

Please select or create a lua state to get started.

+
+ ) : ( + + +
+ setInput(file)} + accept=".lua,.luau"> + Import + + + + }> +