diff --git a/aurorastation.dme b/aurorastation.dme
index cbf30a086e9..95f2e734dd2 100644
--- a/aurorastation.dme
+++ b/aurorastation.dme
@@ -2140,6 +2140,8 @@
#include "code\modules\emotes\definitions\slime.dm"
#include "code\modules\emotes\definitions\synthetics.dm"
#include "code\modules\emotes\definitions\visible.dm"
+#include "code\modules\error_handler\error_handler.dm"
+#include "code\modules\error_handler\error_viewer.dm"
#include "code\modules\events\apc_damage.dm"
#include "code\modules\events\blob.dm"
#include "code\modules\events\brand_intelligence.dm"
diff --git a/code/__DEFINES/admin.dm b/code/__DEFINES/admin.dm
index ceedca41140..54eda35f475 100644
--- a/code/__DEFINES/admin.dm
+++ b/code/__DEFINES/admin.dm
@@ -58,3 +58,4 @@
#define ADMIN_JMP(src) "(JMP)"
#define COORD(src) "[src ? src.Admin_Coordinates_Readable() : "nonexistent location"]"
+#define AREACOORD(src) "[src ? src.Admin_Coordinates_Readable(TRUE) : "nonexistent location"]"
diff --git a/code/__DEFINES/stack_trace.dm b/code/__DEFINES/stack_trace.dm
index 969eaecc707..4911b4a0d57 100644
--- a/code/__DEFINES/stack_trace.dm
+++ b/code/__DEFINES/stack_trace.dm
@@ -1,2 +1,4 @@
/// gives us the stack trace from CRASH() without ending the current proc.
#define stack_trace(message) _stack_trace(message, __FILE__, __LINE__)
+
+#define WORKAROUND_IDENTIFIER "%//%"
diff --git a/code/__HELPERS/logging/_logging.dm b/code/__HELPERS/logging/_logging.dm
index 0b946ba1b25..0b3fdcbdfd1 100644
--- a/code/__HELPERS/logging/_logging.dm
+++ b/code/__HELPERS/logging/_logging.dm
@@ -237,3 +237,16 @@ GLOBAL_LIST_INIT(testing_global_profiler, list("_PROFILE_NAME" = "Global"))
if(message)
entry += "\n[message]"
WRITE_LOG(GLOB.config.logfiles["world_subsystems_tgui"], "TGUI: [entry]")
+
+/proc/loc_name(atom/A)
+ if(!istype(A))
+ return "(INVALID LOCATION)"
+
+ var/turf/T = A
+ if (!istype(T))
+ T = get_turf(A)
+
+ if(istype(T))
+ return "([AREACOORD(T)])"
+ else if(A.loc)
+ return "(UNKNOWN (?, ?, ?))"
diff --git a/code/__HELPERS/stack_trace.dm b/code/__HELPERS/stack_trace.dm
index 6dc9bbd5598..453ebd7266c 100644
--- a/code/__HELPERS/stack_trace.dm
+++ b/code/__HELPERS/stack_trace.dm
@@ -2,4 +2,4 @@
/// Do not call directly, use the [stack_trace] macro instead.
/// May also be used by other tooling like from rust.
/proc/_stack_trace(message, file, line)
- CRASH("[message] ([file]:[line])")
+ CRASH("[message][WORKAROUND_IDENTIFIER][json_encode(list(file, line))][WORKAROUND_IDENTIFIER]")
diff --git a/code/game/world.dm b/code/game/world.dm
index 7fa1ef74e12..2fcc0eecc7c 100644
--- a/code/game/world.dm
+++ b/code/game/world.dm
@@ -268,52 +268,6 @@ var/list/world_api_rate_limit = list()
shutdown_logging()
..(reason)
-/world/Error(var/exception/e)
- var/static/inerror = 0
-
- //runtime while processing runtimes
- if (inerror)
- inerror = 0
- return ..(e)
-
- inerror = 1
-
-// A horrible hack for unit tests but fuck runtiming timers.
-// They don't provide any useful information, and as such, are being suppressed.
-#ifdef UNIT_TEST
-
- if (findtextEx(e.name, "Invalid timer:") || findtextEx(e.desc, "Invalid timer:"))
- inerror = 0
- return
-
-#endif // UNIT_TEST
-
- e.time_stamp()
- log_exception(e)
-
- inerror = 0
- return ..(e)
-
-// We need this elsewhere!
-/exception/var/time_stamped = 0
-
-/exception/proc/time_stamp()
- if (time_stamped)
- return
-
- //newline at start is because of the "runtime error" byond prints that can't be timestamped.
- name = "\n\[[time2text(world.timeofday,"hh:mm:ss")]\][name]"
-
- //this is done this way rather then replace text to pave the way for processing the runtime reports more thoroughly
- // (and because runtimes end with a newline, and we don't want to basically print an empty time stamp)
- var/list/split = splittext(desc, "\n")
- for (var/i in 1 to split.len)
- if (split[i] != "")
- split[i] = "\[[time2text(world.timeofday,"hh:mm:ss")]\][split[i]]"
- desc = jointext(split, "\n")
-
- time_stamped = 1
-
/proc/load_configuration()
GLOB.config = new()
GLOB.config.load("config/config.txt")
diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm
index 63cdec64a8e..081a00c6ecf 100644
--- a/code/modules/admin/topic.dm
+++ b/code/modules/admin/topic.dm
@@ -1561,6 +1561,17 @@
access_control_topic(href_list["access_control"])
return
+ else if(href_list["viewruntime"])
+ var/datum/error_viewer/error_viewer = locate(href_list["viewruntime"])
+ if(!istype(error_viewer))
+ to_chat(usr, SPAN_WARNING("That runtime viewer no longer exists."), confidential = TRUE)
+ return
+
+ if(href_list["viewruntime_backto"])
+ error_viewer.show_to(owner, locate(href_list["viewruntime_backto"]), href_list["viewruntime_linear"])
+ else
+ error_viewer.show_to(owner, null, href_list["viewruntime_linear"])
+
/mob/living/proc/can_centcom_reply()
return 0
diff --git a/code/modules/error_handler/error_handler.dm b/code/modules/error_handler/error_handler.dm
new file mode 100644
index 00000000000..7041695fdd5
--- /dev/null
+++ b/code/modules/error_handler/error_handler.dm
@@ -0,0 +1,143 @@
+GLOBAL_VAR_INIT(total_runtimes, GLOB.total_runtimes || 0)
+GLOBAL_VAR_INIT(total_runtimes_skipped, 0)
+
+#ifdef USE_CUSTOM_ERROR_HANDLER
+#define ERROR_USEFUL_LEN 2
+
+/world/Error(exception/E, datum/e_src)
+ GLOB.total_runtimes++
+
+ if(!istype(E)) //Something threw an unusual exception
+ log_world("uncaught runtime error: [E]")
+ return ..()
+
+ //this is snowflake because of a byond bug (ID:2306577), do not attempt to call non-builtin procs in this if
+ if(copytext(E.name, 1, 32) == "Maximum recursion level reached")//32 == length() of that string + 1
+ //log to world while intentionally triggering the byond bug.
+ log_world("runtime error: [E.name]\n[E.desc]")
+ //if we got to here without silently ending, the byond bug has been fixed.
+ log_world("The bug with recursion runtimes has been fixed. Please remove the snowflake check from world/Error in [__FILE__]:[__LINE__]")
+ return //this will never happen.
+
+ else if(copytext(E.name, 1, 18) == "Out of resources!")//18 == length() of that string + 1
+ log_world("BYOND out of memory. Restarting ([E?.file]:[E?.line])")
+ TgsEndProcess()
+ . = ..()
+ Reboot(reason = 1)
+ return
+
+ var/static/regex/stack_workaround = regex("[WORKAROUND_IDENTIFIER](.+?)[WORKAROUND_IDENTIFIER]")
+ var/static/list/error_last_seen = list()
+ var/static/list/error_cooldown = list() /* Error_cooldown items will either be positive(cooldown time) or negative(silenced error)
+ If negative, starts at -1, and goes down by 1 each time that error gets skipped*/
+
+ if(!error_last_seen) // A runtime is occurring too early in start-up initialization
+ return ..()
+
+ if(stack_workaround.Find(E.name))
+ var/list/data = json_decode(stack_workaround.group[1])
+ E.file = data[1]
+ E.line = data[2]
+ E.name = stack_workaround.Replace(E.name, "")
+
+ var/erroruid = "[E.file][E.line]"
+ var/last_seen = error_last_seen[erroruid]
+ var/cooldown = error_cooldown[erroruid] || 0
+
+ if(last_seen == null)
+ error_last_seen[erroruid] = world.time
+ last_seen = world.time
+
+ if(cooldown < 0)
+ error_cooldown[erroruid]-- //Used to keep track of skip count for this error
+ GLOB.total_runtimes_skipped++
+ return //Error is currently silenced, skip handling it
+ //Handle cooldowns and silencing spammy errors
+ var/silencing = FALSE
+
+ // We can runtime before config is initialized because BYOND initialize objs/map before a bunch of other stuff happens.
+ // This is a bunch of workaround code for that. Hooray!
+ var/configured_error_cooldown = 600 //CHANGED FROM TG, THAT DO NOT SET THE VARS HERE, SEE BELOW
+ var/configured_error_limit = 50 //CHANGED FROM TG, THAT DO NOT SET THE VARS HERE, SEE BELOW
+ var/configured_error_silence_time = 6000 //CHANGED FROM TG, THAT DO NOT SET THE VARS HERE, SEE BELOW
+ //THIS WILL MATTER WHEN WE PORT THE NEW CONFIG SYSTEM
+ // if(config?.entries)
+ // configured_error_cooldown = CONFIG_GET(number/error_cooldown)
+ // configured_error_limit = CONFIG_GET(number/error_limit)
+ // configured_error_silence_time = CONFIG_GET(number/error_silence_time)
+ // else
+ // var/datum/config_entry/CE = /datum/config_entry/number/error_cooldown
+ // configured_error_cooldown = initial(CE.default)
+ // CE = /datum/config_entry/number/error_limit
+ // configured_error_limit = initial(CE.default)
+ // CE = /datum/config_entry/number/error_silence_time
+ // configured_error_silence_time = initial(CE.default)
+
+
+ //Each occurence of a unique error adds to its cooldown time...
+ cooldown = max(0, cooldown - (world.time - last_seen)) + configured_error_cooldown
+ // ... which is used to silence an error if it occurs too often, too fast
+ if(cooldown > configured_error_cooldown * configured_error_limit)
+ cooldown = -1
+ silencing = TRUE
+ spawn(0)
+ usr = null
+ sleep(configured_error_silence_time)
+ var/skipcount = abs(error_cooldown[erroruid]) - 1
+ error_cooldown[erroruid] = 0
+ if(skipcount > 0)
+ SEND_TEXT(world.log, "\[[time_stamp()]] Skipped [skipcount] runtimes in [E.file],[E.line].")
+ GLOB.error_cache.log_error(E, skip_count = skipcount)
+
+ error_last_seen[erroruid] = world.time
+ error_cooldown[erroruid] = cooldown
+
+ var/list/usrinfo = null
+ var/locinfo
+ if(istype(usr))
+ usrinfo = list(" usr: [key_name(usr)]")
+ locinfo = loc_name(usr)
+ if(locinfo)
+ usrinfo += " usr.loc: [locinfo]"
+ // The proceeding mess will almost definitely break if error messages are ever changed
+ var/list/splitlines = splittext(E.desc, "\n")
+ var/list/desclines = list()
+ if(LAZYLEN(splitlines) > ERROR_USEFUL_LEN) // If there aren't at least three lines, there's no info
+ for(var/line in splitlines)
+ if(LAZYLEN(line) < 3 || findtext(line, "source file:") || findtext(line, "usr.loc:"))
+ continue
+ if(findtext(line, "usr:"))
+ if(usrinfo)
+ desclines.Add(usrinfo)
+ usrinfo = null
+ continue // Our usr info is better, replace it
+ if(copytext(line, 1, 3) != " ")//3 == length(" ") + 1
+ desclines += (" " + line) // Pad any unpadded lines, so they look pretty
+ else
+ desclines += line
+ if(usrinfo) //If this info isn't null, it hasn't been added yet
+ desclines.Add(usrinfo)
+ if(silencing)
+ desclines += " (This error will now be silenced for [DisplayTimeText(configured_error_silence_time)])"
+ if(GLOB.error_cache)
+ GLOB.error_cache.log_error(E, desclines)
+
+ var/main_line = "\[[time_stamp()]] Runtime in [E.file],[E.line]: [E]"
+ SEND_TEXT(world.log, main_line)
+ for(var/line in desclines)
+ SEND_TEXT(world.log, line)
+
+// We might want to use this one day, but until it is that day, in `code\unit_tests\ss_test.dm` there is a shadowing of the /Error proc
+// #ifdef UNIT_TESTS
+// if(GLOB.current_test)
+// //good day, sir
+// GLOB.current_test.Fail("[main_line]\n[desclines.Join("\n")]", file = E.file, line = E.line)
+// #endif
+
+
+ // This writes the regular format (unwrapping newlines and inserting timestamps as needed).
+ log_runtime("runtime error: [E.name]\n[E.desc]")
+#undef ERROR_USEFUL_LEN
+
+
+#endif
diff --git a/code/modules/error_handler/error_viewer.dm b/code/modules/error_handler/error_viewer.dm
new file mode 100644
index 00000000000..f4a6fb0ef3f
--- /dev/null
+++ b/code/modules/error_handler/error_viewer.dm
@@ -0,0 +1,196 @@
+// Error viewing datums, responsible for storing error info, notifying admins
+// when errors occur, and showing them to admins on demand.
+
+// There are 3 different types used here:
+//
+// - error_cache keeps track of all error sources, as well as all individually
+// logged errors. Only one instance of this datum should ever exist, and it's
+// right here:
+
+#ifdef USE_CUSTOM_ERROR_HANDLER
+GLOBAL_DATUM_INIT(error_cache, /datum/error_viewer/error_cache, new)
+#else
+// If debugging is disabled, there's nothing useful to log, so don't bother.
+GLOBAL_DATUM(error_cache, /datum/error_viewer/error_cache)
+#endif
+
+// - error_source datums exist for each line (of code) that generates an error,
+// and keep track of all errors generated by that line.
+//
+// - error_entry datums exist for each logged error, and keep track of all
+// relevant info about that error.
+
+// Common vars and procs are kept at the error_viewer level
+/datum/error_viewer
+ var/name = ""
+
+/datum/error_viewer/proc/browse_to(client/user, html)
+ var/datum/browser/browser = new(user.mob, "error_viewer", null, 600, 400)
+ browser.set_content(html)
+ browser.add_head_content({"
+
+ "})
+ browser.open()
+
+/datum/error_viewer/proc/build_header(datum/error_viewer/back_to, linear)
+ // Common starter HTML for show_to
+
+ . = ""
+
+ if (istype(back_to))
+ . += back_to.make_link("<<<", null, linear)
+
+ . += "[make_link("Refresh")]
"
+
+/datum/error_viewer/proc/show_to(user, datum/error_viewer/back_to, linear)
+ // Specific to each child type
+ return
+
+/datum/error_viewer/proc/make_link(linktext, datum/error_viewer/back_to, linear)
+ var/back_to_param = ""
+ if (!linktext)
+ linktext = name
+
+ if (istype(back_to))
+ back_to_param = ";viewruntime_backto=[REF(back_to)]"
+
+ if (linear)
+ back_to_param += ";viewruntime_linear=1"
+
+ return "[linktext]"
+
+/datum/error_viewer/error_cache
+ var/list/errors = list()
+ var/list/error_sources = list()
+ var/list/errors_silenced = list()
+
+/datum/error_viewer/error_cache/show_to(user, datum/error_viewer/back_to, linear)
+ var/html = build_header()
+ html += "[GLOB.total_runtimes] runtimes, [GLOB.total_runtimes_skipped] skipped
"
+ if (!linear)
+ html += "organized | [make_link("linear", null, 1)]