From 8c4acf669d1fc1f1dc954a00d1ce65a1618113a1 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 26 Sep 2026 22:35:10 +0100 Subject: [PATCH] feat: make crash reports readable and attributable --- main.lua | 164 ++++++--- src/debug/CrashScreen.lua | 341 ++++++++++++++++++ src/debug/SwitchDiagnostics.lua | 111 +++++- .../errorhandler_probe_bug1517_test.lua | 8 +- tests/engine/errorhandler_fallback_test.lua | 259 +++++++++++++ tests/engine/switch_diagnostics_test.lua | 50 ++- 6 files changed, 875 insertions(+), 58 deletions(-) create mode 100644 src/debug/CrashScreen.lua create mode 100644 tests/engine/errorhandler_fallback_test.lua diff --git a/main.lua b/main.lua index ca9aa79b..fe1626bd 100644 --- a/main.lua +++ b/main.lua @@ -8,30 +8,7 @@ -- opens the editor on that slot's file, and restores the launcher when -- the editor's Close button is pressed (openEditor / closeEditor below) -if POKEPORT_DISPLAY_COMPANION then - return require("src.render.DesktopCompanion").install( - POKEPORT_DISPLAY_COMPANION) -end - -local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true - local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") -local PadHints = require("src.core.PadHints") -local LaunchOptions = require("src.core.LaunchOptions") -local NxDisplay = require("src.core.NxDisplay") -local PlatformHooks = require("src.core.PlatformHooks") -local HostDisplay = require("src.core.HostDisplay") -local GameViewport = require("src.render.GameViewport") - -local function applySavedOrientation() - local ok, savedOptions = pcall(function() - return require("src.core.SaveData").loadOptions() - end) - if not ok or type(savedOptions) ~= "table" then savedOptions = {} end - pcall(function() - require("src.core.Orientation").applyOptions(savedOptions) - end) -end -- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE. local emergencyQuitTimer = 0 @@ -92,46 +69,139 @@ local function checkEmergencyQuit(dt) end end --- Lua errors: persist a redacted trace in the save dir and surface a hint. +-- Install before the rest of the engine loads so startup failures also get +-- the crash report when the game's regular renderer is unavailable. do local defaultErrorHandler = love.errorhandler or love.errhand function love.errorhandler(msg) - local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg) - if ok and hint and type(msg) == "string" then - msg = msg .. "\n\n" .. hint + local traceback = debug.traceback() + local ok, hint, source, report = pcall(SwitchDiagnostics.logLuaError, msg, traceback) + local nativeMsg = tostring(msg) + if ok then + if source then nativeMsg = source .. "\n\n" .. nativeMsg end + if hint then nativeMsg = nativeMsg .. "\n\n" .. hint end end - if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then - -- Ensure no Canvas is left bound (errors during Display.present). - pcall(function() love.graphics.setCanvas() end) - pcall(function() love.graphics.origin() end) - local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]" - return function() - love.event.pump() - for e, a in love.event.poll() do - if e == "quit" or (e == "keypressed" and a == "escape") then - return 1 - elseif e == "gamepadpressed" and (a == "start" or a == "back") then - return 1 + local okScreen, CrashScreen = pcall(require, "src.debug.CrashScreen") + if okScreen and report then + local summaryOk, summary = pcall(CrashScreen.fallbackText, report) + if summaryOk then nativeMsg = summary end + if love.window and love.graphics and love.event then + local function ready() + local openOk, open = pcall(love.window.isOpen) + local activeOk, active = pcall(love.graphics.isActive) + return openOk and open and activeOk and active + end + local variant + if ready() then + variant = "red" + elseif love.window.setMode then + -- Match LÖVE's native fallback: open a usable window after a + -- startup failure, using the same plain-language layout. + local modeOk, opened = pcall(love.window.setMode, 800, 600) + if modeOk and opened and ready() then variant = "blue" end + end + if variant then + local prepared, screen = pcall(CrashScreen.new, report, variant) + if prepared and screen then + local fallbackLoop + return function() + if fallbackLoop then return fallbackLoop() end + local drawn, result = pcall(function() + love.event.pump() + for e, a, b, c, d, touchMouse in love.event.poll() do + if e == "quit" or (e == "keypressed" and a == "escape") then + return 1 + elseif e == "gamepadpressed" and (b == "start" or b == "back") then + return 1 + elseif e == "mousepressed" and c == 1 and not d + and CrashScreen.hitClose(screen, a, b) then + return 1 + elseif e == "touchpressed" and CrashScreen.hitClose(screen, b, c) then + return 1 + elseif e == "mousepressed" and c == 1 and not d then + CrashScreen.pointerPressed(screen, "mouse", a, b) + elseif e == "mousereleased" and c == 1 and not d then + CrashScreen.pointerReleased(screen, "mouse") + elseif e == "mousemoved" and not touchMouse then + CrashScreen.pointerMoved(screen, "mouse", a, b) + elseif e == "touchpressed" then + CrashScreen.pointerPressed(screen, a, b, c) + elseif e == "touchmoved" then + CrashScreen.pointerMoved(screen, a, b, c) + elseif e == "touchreleased" then + CrashScreen.pointerReleased(screen, a) + elseif e == "wheelmoved" then + CrashScreen.scroll(screen, -b * 3 * (screen.lineHeight or 16)) + elseif e == "keypressed" and (a == "up" or a == "down") then + CrashScreen.scroll(screen, + (a == "down" and 1 or -1) * (screen.lineHeight or 16)) + elseif e == "keypressed" and (a == "pageup" or a == "pagedown") then + CrashScreen.scroll(screen, (a == "pagedown" and 1 or -1) + * ((screen.detailArea and screen.detailArea.h) or 64)) + elseif e == "keypressed" and (a == "home" or a == "end") then + CrashScreen.scrollTo(screen, a == "end") + elseif e == "gamepadpressed" and (b == "dpup" or b == "dpdown") then + CrashScreen.scroll(screen, + (b == "dpdown" and 1 or -1) * 3 * (screen.lineHeight or 16)) + elseif e == "keypressed" and a == "c" and screen.canCopy + and love.keyboard.isDown("lctrl", "rctrl") then + local copyOk, copied = pcall(love.system.setClipboardText, + report.logPath) + if copyOk and copied ~= false then screen.copied = true end + end + end + checkEmergencyQuit(0.016) + CrashScreen.draw(screen) + love.graphics.present() + if love.timer then love.timer.sleep(0.016) end + end) + if drawn then return result end + if defaultErrorHandler then + local fallbackOk, loop = pcall(defaultErrorHandler, nativeMsg) + if fallbackOk and type(loop) == "function" then + fallbackLoop = loop + return fallbackLoop() + end + end + return 1 + end end end - checkEmergencyQuit(0.016) - love.graphics.origin() - love.graphics.clear(0.10, 0.10, 0.12) - love.graphics.setColor(1, 0.4, 0.4, 1) - love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40) - love.graphics.present() - love.timer.sleep(0.016) end end if defaultErrorHandler then - return defaultErrorHandler(msg) + return defaultErrorHandler(nativeMsg) end end love.errhand = love.errorhandler end +if POKEPORT_DISPLAY_COMPANION then + return require("src.render.DesktopCompanion").install( + POKEPORT_DISPLAY_COMPANION) +end + +local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true + +local PadHints = require("src.core.PadHints") +local LaunchOptions = require("src.core.LaunchOptions") +local NxDisplay = require("src.core.NxDisplay") +local PlatformHooks = require("src.core.PlatformHooks") +local HostDisplay = require("src.core.HostDisplay") +local GameViewport = require("src.render.GameViewport") + +local function applySavedOrientation() + local ok, savedOptions = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + if not ok or type(savedOptions) ~= "table" then savedOptions = {} end + pcall(function() + require("src.core.Orientation").applyOptions(savedOptions) + end) +end + local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch local launcherSplash diff --git a/src/debug/CrashScreen.lua b/src/debug/CrashScreen.lua new file mode 100644 index 00000000..35980732 --- /dev/null +++ b/src/debug/CrashScreen.lua @@ -0,0 +1,341 @@ +-- A small, self-contained crash screen. No game assets are needed: this can +-- run while the launcher, a mod, or the normal renderer is failing. +local CrashScreen = {} + +local PALETTES = { + red = { + background = { 0.055, 0.065, 0.085 }, + card = { 0.105, 0.12, 0.15 }, + panel = { 0.075, 0.09, 0.115 }, + accent = { 1, 0.42, 0.43 }, + }, + blue = { + background = { 0.055, 0.105, 0.17 }, + card = { 0.09, 0.16, 0.245 }, + panel = { 0.065, 0.125, 0.20 }, + accent = { 0.43, 0.76, 1 }, + }, +} + +local WHITE = { 0.97, 0.98, 1 } +local MUTED = { 0.72, 0.78, 0.85 } +local BORDER = { 0.31, 0.39, 0.48 } + +local function color(g, value, alpha) + g.setColor(value[1], value[2], value[3], alpha or 1) +end + +local function textFor(report) + local owner = report.owner or {} + local isMod = owner.kind == "mod" + local source = isMod and ("Mod: " .. tostring(owner.name or owner.id or "Unknown mod")) + or "Game itself (gen1recomp)" + if isMod and owner.id and owner.name ~= owner.id then + source = source .. " (" .. tostring(owner.id) .. ")" + end + + local advice = isMod + and "Try disabling this mod, then restart the game." + or "Restart the game. If this happens again, share the error log." + if not report.saved then + advice = "The error log could not be saved. Take a photo of this screen." + end + + return { + source = source, + advice = advice, + logLabel = report.saved and "ERROR LOG" or "ERROR LOG NOT SAVED", + logPath = tostring(report.logPath or "Save directory unavailable"), + footer = report.saved and "Share this file when reporting the problem." + or "Keep this screen open if you need to note the location.", + } +end + +function CrashScreen.fallbackText(report) + local copy = textFor(report) + local sourceLine = report.source or ("Likely source: " .. copy.source) + if report.owner and report.owner.kind == "engine" then + sourceLine = "Likely source: " .. copy.source + end + return table.concat({ + "The game ran into a problem.", + sourceLine, + copy.advice, + (report.saved and "Error log: " or "Could not save error log: ") + .. copy.logPath, + }, "\n\n") +end + +local function makeFont(g, size) + local ok, font = pcall(g.newFont, math.max(10, math.floor(size + 0.5))) + if ok and font then return font end + return g.getFont and g.getFont() or nil +end + +function CrashScreen.new(report, variant) + local g = love and love.graphics + if not g or not g.getDimensions or not g.rectangle or not g.printf then + return nil + end + local w, h = g.getDimensions() + local scale = math.min(1.4, w / 900, h / 620) + if scale <= 0 then return nil end + if love.audio and love.audio.stop then pcall(love.audio.stop) end + if love.mouse and love.mouse.setVisible then pcall(love.mouse.setVisible, true) end + if g.setCanvas then pcall(g.setCanvas) end + if g.reset then pcall(g.reset) end + if g.origin then pcall(g.origin) end + local fonts = { + small = makeFont(g, 13 * scale), + title = makeFont(g, 31 * scale), + source = makeFont(g, 22 * scale), + body = makeFont(g, 16 * scale), + path = makeFont(g, 14 * scale), + } + return { + report = report, + copy = textFor(report), + palette = PALETTES[variant] or PALETTES.red, + fonts = fonts, + scale = scale, + scroll = 0, + canCopy = report.saved and love.system and love.system.setClipboardText + and love.keyboard and love.keyboard.isDown and true or false, + } +end + +local function write(g, font, value, x, y, width, ink, align) + if font and g.setFont then g.setFont(font) end + color(g, ink) + g.printf(value, x, y, width, align or "left") +end + +local function geometry(screen) + local g = love.graphics + local w, h = g.getDimensions() + local s = screen.scale + local cardW = math.min(w - 32 * s, 840 * s) + local cardH = math.min(h - 24 * s, 500 * s) + local x, y = (w - cardW) / 2, (h - cardH) / 2 + local left, usable = x + 34 * s, cardW - 68 * s + return { + w = w, h = h, s = s, x = x, y = y, + cardW = cardW, cardH = cardH, left = left, usable = usable, + buttonX = x + cardW - 184 * s, + buttonY = y + cardH - 65 * s, + buttonW = 150 * s, + buttonH = 43 * s, + } +end + +local function clamp(value, low, high) + return math.max(low, math.min(value, high)) +end + +local function wrappedLines(font, value, width) + local lines = {} + for raw in (tostring(value or "") .. "\n"):gmatch("(.-)\n") do + local ok, _, wrapped = false, nil, nil + if font and font.getWrap then + ok, _, wrapped = pcall(font.getWrap, font, raw, width) + end + if ok and type(wrapped) == "table" and #wrapped > 0 then + for _, line in ipairs(wrapped) do lines[#lines + 1] = line end + elseif font and font.getWidth then + local line = "" + for i = 1, #raw do + local nextLine = line .. raw:sub(i, i) + if line ~= "" and font:getWidth(nextLine) > width then + lines[#lines + 1] = line + line = raw:sub(i, i) + else + line = nextLine + end + end + lines[#lines + 1] = line + else + lines[#lines + 1] = raw + end + end + return lines +end + +local function point(box, x, y) + if type(x) ~= "number" or type(y) ~= "number" then return nil, nil end + -- Some touch hosts send normalized coordinates instead of pixels. + if x >= 0 and x <= 1 and y >= 0 and y <= 1 then + return x * box.w, y * box.h + end + return x, y +end + +function CrashScreen.scroll(screen, amount) + screen.scroll = clamp((screen.scroll or 0) + amount, 0, + screen.scrollMax or math.huge) +end + +function CrashScreen.scrollTo(screen, endOfLog) + screen.scroll = endOfLog and (screen.scrollMax or math.huge) or 0 +end + +function CrashScreen.pointerPressed(screen, id, x, y) + local area = screen.detailArea + if not area then return false end + x, y = point(geometry(screen), x, y) + if not x or x < area.x or x > area.x + area.w + or y < area.y or y > area.y + area.h then return false end + screen.drag = { id = id, y = y } + if screen.scrollMax and screen.scrollMax > 0 + and x >= area.barX - 6 * screen.scale then + screen.scroll = clamp(((y - area.y) / area.h) * screen.scrollMax, + 0, screen.scrollMax) + end + return true +end + +function CrashScreen.pointerMoved(screen, id, x, y) + if not screen.drag or screen.drag.id ~= id then return false end + local _, movedY = point(geometry(screen), x, y) + y = movedY + if not y then return false end + CrashScreen.scroll(screen, screen.drag.y - y) + screen.drag.y = y + return true +end + +function CrashScreen.pointerReleased(screen, id) + if screen.drag and screen.drag.id == id then screen.drag = nil end +end + +function CrashScreen.hitClose(screen, x, y) + local box = geometry(screen) + x, y = point(box, x, y) + if not x then return false end + return x >= box.buttonX and x <= box.buttonX + box.buttonW + and y >= box.buttonY and y <= box.buttonY + box.buttonH +end + +function CrashScreen.draw(screen) + local g = love.graphics + local box = geometry(screen) + local s, x, y = box.s, box.x, box.y + local cardW, cardH, left, usable = box.cardW, box.cardH, + box.left, box.usable + local palette, copy = screen.palette, screen.copy + + g.origin() + color(g, palette.background) + g.clear(palette.background[1], palette.background[2], palette.background[3]) + color(g, { 0, 0, 0 }, 0.22) + g.rectangle("fill", x + 5 * s, y + 9 * s, cardW, cardH, 14 * s, 14 * s) + color(g, palette.card) + g.rectangle("fill", x, y, cardW, cardH, 14 * s, 14 * s) + color(g, palette.accent) + g.rectangle("fill", x, y, cardW, 6 * s, 14 * s, 14 * s) + + write(g, screen.fonts.small, "GEN1RECOMP / GAME INTERRUPTED", + left, y + 27 * s, usable, palette.accent) + write(g, screen.fonts.title, "The game ran into a problem", + left, y + 57 * s, usable, WHITE) + color(g, BORDER, 0.55) + g.rectangle("fill", left, y + 108 * s, usable, 1 * s) + + write(g, screen.fonts.small, "Likely source", + left, y + 132 * s, usable, MUTED) + write(g, screen.fonts.source, copy.source, + left, y + 155 * s, usable, WHITE) + local sourceLines = wrappedLines(screen.fonts.source, copy.source, usable) + local sourceLineH = math.max(31 * s, + ((screen.fonts.source and screen.fonts.source.getHeight + and screen.fonts.source:getHeight()) or 22 * s) * 1.1) + local sourceHeight = #sourceLines * sourceLineH + local adviceY = y + 155 * s + sourceHeight + 10 * s + write(g, screen.fonts.body, copy.advice, + left, adviceY, usable, MUTED) + + local adviceLines = wrappedLines(screen.fonts.body, copy.advice, usable) + local adviceLineH = math.max(20 * s, + ((screen.fonts.body and screen.fonts.body.getHeight + and screen.fonts.body:getHeight()) or 16 * s) * 1.2) + local panelY = math.max(y + 236 * s, + adviceY + #adviceLines * adviceLineH + 18 * s) + local panelH = math.max(0, y + cardH - 83 * s - panelY) + color(g, palette.panel) + g.rectangle("fill", left, panelY, usable, panelH, 9 * s, 9 * s) + write(g, screen.fonts.small, copy.logLabel, + left + 18 * s, panelY + 15 * s, usable - 36 * s, palette.accent) + write(g, screen.fonts.small, "Scroll for details", + left + usable - 190 * s, panelY + 15 * s, 172 * s, MUTED, "right") + write(g, screen.fonts.path, copy.logPath, + left + 18 * s, panelY + 42 * s, usable - 36 * s, WHITE) + + local pathFont = screen.fonts.path + local pathLines = wrappedLines(pathFont, copy.logPath, usable - 36 * s) + local pathLineH = ((pathFont and pathFont.getHeight and pathFont:getHeight()) + or 12 * s) * 1.25 + local detailY = panelY + 42 * s + #pathLines * pathLineH + 7 * s + local area = { + x = left + 18 * s, y = detailY, + w = usable - 36 * s, + h = math.max(1, panelY + panelH - 8 * s - detailY), + barX = left + usable - 23 * s, + textW = usable - 59 * s, + } + screen.detailArea = area + if screen.wrapWidth ~= area.textW then + screen.detailLines = wrappedLines(screen.fonts.path, + screen.report.details or "No further details available.", area.textW) + screen.wrapWidth = area.textW + end + local font = screen.fonts.path + local lineH = ((font and font.getHeight and font:getHeight()) or 12 * s) * 1.25 + screen.lineHeight = lineH + screen.scrollMax = math.max(0, #screen.detailLines * lineH - area.h) + screen.scroll = clamp(screen.scroll or 0, 0, screen.scrollMax) + local clipX, clipY, clipW, clipH + if g.getScissor then clipX, clipY, clipW, clipH = g.getScissor() end + if g.setScissor then + g.setScissor(math.floor(area.x), math.floor(area.y), + math.ceil(area.textW), math.ceil(area.h)) + end + local first = math.floor(screen.scroll / lineH) + 1 + local last = math.min(#screen.detailLines, + math.ceil((screen.scroll + area.h) / lineH) + 1) + for i = first, last do + write(g, font, screen.detailLines[i], area.x, + area.y + (i - 1) * lineH - screen.scroll, area.textW, MUTED) + end + if g.setScissor then + if clipX then g.setScissor(clipX, clipY, clipW, clipH) + else g.setScissor() end + end + if screen.scrollMax > 0 then + color(g, BORDER, 0.7) + g.rectangle("fill", area.barX, area.y, 4 * s, area.h, 2 * s, 2 * s) + local thumbH = math.min(area.h, math.max(15 * s, + area.h * area.h / (area.h + screen.scrollMax))) + local thumbY = area.y + (area.h - thumbH) + * (screen.scroll / screen.scrollMax) + color(g, palette.accent) + g.rectangle("fill", area.barX, thumbY, 4 * s, thumbH, 2 * s, 2 * s) + end + write(g, screen.fonts.small, copy.footer, + left, y + cardH - 53 * s, usable - 174 * s, MUTED) + local controls = "Esc / Start / Back: Close" + if screen.copied then + controls = "Log path copied to clipboard." + elseif screen.canCopy then + controls = controls .. " / Ctrl+C: Copy path" + end + write(g, screen.fonts.small, controls, + left, y + cardH - 30 * s, usable - 174 * s, WHITE) + + color(g, palette.accent) + g.rectangle("fill", box.buttonX, box.buttonY, box.buttonW, box.buttonH, + 8 * s, 8 * s) + write(g, screen.fonts.body, "Close game", + box.buttonX + 15 * s, box.buttonY + 11 * s, + box.buttonW - 30 * s, palette.background) +end + +return CrashScreen diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 0e33244c..d4e7de7e 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -238,11 +238,104 @@ function SwitchDiagnostics.onFocus(f) SwitchDiagnostics.maybeFlush(true) end -function SwitchDiagnostics.logLuaError(msg) +local function safeLabel(value) + if type(value) ~= "string" or value == "" then return nil end + return value:gsub("[%c]", " "):sub(1, 100) +end + +local function modFolder(path) + if type(path) ~= "string" then return nil end + path = path:gsub("\\", "/"):gsub("^@", "") + -- src/mods is engine code, even when a traceback uses an absolute path. + if path:match("^src/mods/") then return nil end + local engineMods = path:find("/src/mods/", 1, true) + local userMods = path:find("/mods/", 1, true) + if engineMods and (not userMods or engineMods < userMods) then return nil end + local folder = path:match("^mods/([^/]+)/") or path:match("/mods/([^/]+)/") + if folder == "." or folder == ".." then return nil end + return folder +end + +local function manifestAt(folder) + local filesystem = fs() + if not filesystem or not filesystem.read then return nil end + local raw = filesystem.read("mods/" .. folder .. "/manifest.json") + if not raw then return nil end + local okJson, Json = pcall(require, "src.link.Json") + if not okJson then return nil end + local ok, manifest = pcall(Json.decode, raw) + if ok and type(manifest) == "table" then return manifest end +end + +local function modName(folder, id) + local filesystem = fs() + if not folder and filesystem and filesystem.getDirectoryItems then + for _, candidate in ipairs(filesystem.getDirectoryItems("mods") or {}) do + local manifest = manifestAt(candidate) + if manifest and manifest.id == id then + folder = candidate + break + end + end + end + local manifest = folder and manifestAt(folder) + id = safeLabel(manifest and manifest.id) or safeLabel(id) or safeLabel(folder) or "unknown" + local name = safeLabel(manifest and manifest.name) + if name and name ~= id then return ('mod "%s" (%s)'):format(name, id), name, id end + return ("mod %s"):format(id), id, id +end + +local function sourcePath(line) + return line:match("^%s*(.-%.lua):%d+:") +end + +local function modSource(folder, id) + local label, name, modId = modName(folder, id) + return "Likely source: " .. label, + { kind = "mod", name = name, id = modId } +end + +local function engineSource() + return "Likely source: gen1recomp", { kind = "engine", name = "gen1recomp" } +end + +function SwitchDiagnostics.errorSource(msg, traceback) + local firstLine = tostring(msg or ""):match("^[^\n]*") or "" + local path = sourcePath(firstLine) + local folder = modFolder(path) + if folder then return modSource(folder) end + if path then return engineSource() end + + -- Error strings without a file can still have a mod frame in the traceback. + -- Engine dispatch frames can appear before that frame, so scan the trace. + for line in tostring(traceback or ""):gmatch("[^\n]+") do + path = sourcePath(line) + if path then + folder = modFolder(path) + if folder then return modSource(folder) end + end + end + local runtime = package.loaded["src.mods.Runtime"] + local id = runtime and (runtime.currentMod or runtime.modRequire) + if type(id) == "string" then return modSource(nil, id) end + return engineSource() +end + +local function errorLogPath(filesystem) + local dir = filesystem.getSaveDirectory and filesystem.getSaveDirectory() + if type(dir) ~= "string" or dir == "" then return ERROR_LOG end + local last = dir:sub(-1) + local separator = dir:find("\\", 1, true) and "\\" or "/" + return dir .. ((last == "/" or last == "\\") and "" or separator) .. ERROR_LOG +end + +function SwitchDiagnostics.logLuaError(msg, traceback) local filesystem = fs() if not filesystem then return nil end - local text = redactString(tostring(msg or "unknown error")) + local source, owner = SwitchDiagnostics.errorSource(msg, traceback) + local message = redactString(tostring(msg or "unknown error")) + local trace = traceback and redactString(tostring(traceback)) or nil local existing = filesystem.read(ERROR_LOG) or "" if #existing > ERROR_LOG_MAX then filesystem.write(ERROR_LOG_ROTATED, existing) @@ -250,10 +343,18 @@ function SwitchDiagnostics.logLuaError(msg) end local stamp = os.date("!%Y-%m-%dT%H:%M:%SZ") - local line = ("[%s] %s\n"):format(stamp, text) - filesystem.write(ERROR_LOG, existing .. line .. SwitchDiagnostics.identityOverlay() .. "\n") + local line = ("[%s] %s\n%s\n"):format(stamp, source, message) + if trace then line = line .. trace .. "\n" end + local path = errorLogPath(filesystem) + local written = filesystem.write(ERROR_LOG, + existing .. line .. "Error log: " .. path .. "\n" + .. SwitchDiagnostics.identityOverlay() .. "\n") - return "Details saved to lua-error.log in the save directory." + local report = { owner = owner, source = source, logPath = path, + details = message .. (trace and ("\n" .. trace) or ""), + saved = written and true or false } + if written then return "Error log: " .. path, source, report end + return "Could not save error log: " .. path, source, report end function SwitchDiagnostics.maybeFlush(force, now) diff --git a/tests/drivers/errorhandler_probe_bug1517_test.lua b/tests/drivers/errorhandler_probe_bug1517_test.lua index 51b3bebd..ed8bf0fb 100644 --- a/tests/drivers/errorhandler_probe_bug1517_test.lua +++ b/tests/drivers/errorhandler_probe_bug1517_test.lua @@ -9,14 +9,14 @@ -- love.errorhandler type: nil / love.errhand type: function -- -- Nothing here can be a unit test: the deliverable is whether a human sees --- LOVE's blue error screen or an app that vanishes. +-- the crash screen or an app that vanishes. -- -- POKEPORT_TOUCH=0 \ -- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love . -- -- Pre-fix: the window closes on the first drawn frame after the probe arms, --- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler --- probe" plus the lua-error.log hint, and stays up until it is dismissed. +-- exit 1, no output. Post-fix: the crash screen identifies gen1recomp and +-- shows the lua-error.log path until it is dismissed. return function(game) local U = dofile("tests/drivers/util.lua") @@ -41,6 +41,6 @@ return function(game) end U.log("[1517] armed; the next drawn frame raises from love.draw") - U.log("[1517] you should now see LOVE's blue error screen, not a closed app") + U.log("[1517] you should now see the crash screen, not a closed app") while true do coroutine.yield() end end diff --git a/tests/engine/errorhandler_fallback_test.lua b/tests/engine/errorhandler_fallback_test.lua new file mode 100644 index 00000000..25dbfa70 --- /dev/null +++ b/tests/engine/errorhandler_fallback_test.lua @@ -0,0 +1,259 @@ +-- Startup errors still reach LÖVE's native handler if graphics cannot open. +package.path = "./?.lua;./?/init.lua;" .. package.path +_G.love = require("tests.love_stub") + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local shown +love.errhand = function(msg) + shown = msg + return "blue handler result" +end + +-- Fail the first engine require after main.lua installs the crash handler. +package.preload["src.core.LaunchOptions"] = function() + error("startup probe", 0) +end +package.loaded["src.core.LaunchOptions"] = nil +local loaded, startupError = pcall(dofile, "main.lua") +check(not loaded and tostring(startupError):find("startup probe", 1, true) ~= nil, + "startup probe fails after the crash handler is installed") +check(type(love.errorhandler) == "function", "startup crash handler is installed") + +love.filesystem.write("mods/sample_mod/manifest.json", + '{"id":"sample_mod","name":"Sample Mod"}') +eq(love.errorhandler("mods/sample_mod/main.lua:12: crash"), + "blue handler result", "inactive graphics delegates to LÖVE's blue handler") +check(shown:find('Likely source: mod "Sample Mod" (sample_mod)', 1, true) ~= nil, + "blue handler names the mod") +check(shown:find("Error log: /tmp/pokeport-stub-save/lua-error.log", 1, true) ~= nil, + "blue handler shows the exact log path") +check((love.filesystem.read("lua-error.log") or ""):find( + 'Likely source: mod "Sample Mod" (sample_mod)', 1, true) ~= nil, + "blue handler saves the attributed error") + +eq(love.errorhandler("src/core/Game2.lua:42: crash"), + "blue handler result", "engine errors also use the blue handler") +check(shown:find("Likely source: Game itself (gen1recomp)", 1, true) ~= nil, + "blue handler identifies engine errors") + +-- With an active window, the game's own screen keeps the useful summary and +-- log path visible, with the current traceback inside a scrollable panel. +local drawn = {} +local backgrounds = {} +love.graphics.printf = function(value) + drawn[#drawn + 1] = tostring(value) +end +love.graphics.clear = function(r, g, b) + backgrounds[#backgrounds + 1] = { r, g, b } +end +love.graphics.present = function() end +love.graphics.isActive = function() return true end +love.window.isOpen = function() return true end +love.event = { + pump = function() end, + poll = function() return function() return nil end end, +} +love.timer.sleep = function() end + +local nativeShown = shown +local drawLoop = love.errorhandler("mods/sample_mod/main.lua:12: raw Lua crash") +eq(type(drawLoop), "function", "active window gets the custom crash screen") +drawLoop() +eq(shown, nativeShown, "custom crash screen does not call the native handler") +local display = table.concat(drawn, "\n") +check(display:find("The game ran into a problem", 1, true) ~= nil, + "screen has a plain-language heading") +check(display:find("Mod: Sample Mod (sample_mod)", 1, true) ~= nil, + "screen names the mod and its id") +check(display:find("Try disabling this mod", 1, true) ~= nil, + "screen gives the player a next step") +check(display:find("/tmp/pokeport-stub-save/lua-error.log", 1, true) ~= nil, + "screen shows the exact log path") +check(display:find("raw Lua crash", 1, true) ~= nil, + "the terminal panel shows the current error details") +check((love.filesystem.read("lua-error.log") or ""):find("raw Lua crash", 1, true) ~= nil, + "technical details remain in the saved log") +local red = backgrounds[#backgrounds] + +-- If no window exists yet, open one and render the same layout in blue. +local opened = false +love.window.isOpen = function() return opened end +love.window.setMode = function(width, height) + eq(width, 800, "fallback opens an 800px window") + eq(height, 600, "fallback opens a 600px window") + opened = true + return true +end +drawn, backgrounds = {}, {} +local blueLoop = love.errorhandler("src/core/Game2.lua:42: startup crash") +eq(type(blueLoop), "function", "startup failure gets a custom crash screen") +blueLoop() +display = table.concat(drawn, "\n") +check(display:find("gen1recomp", 1, true) ~= nil, + "blue variant identifies the engine") +check(display:find("Restart the game", 1, true) ~= nil, + "blue variant gives a plain-language next step") +check(display:find("startup crash", 1, true) ~= nil, + "blue variant shows the same technical-details panel") +local blue = backgrounds[#backgrounds] +check(red and blue and red[3] ~= blue[3], + "active and startup crash screens use distinct palettes") + +local CrashScreen = require("src.debug.CrashScreen") +drawn = {} +local unsaved = CrashScreen.new({ + owner = { kind = "engine", name = "gen1recomp" }, + logPath = "/tmp/pokeport-stub-save/lua-error.log", + saved = false, +}, "blue") +CrashScreen.draw(unsaved) +display = table.concat(drawn, "\n") +check(display:find("ERROR LOG NOT SAVED", 1, true) ~= nil, + "screen clearly marks a failed log write") +check(display:find("Take a photo", 1, true) ~= nil, + "failed log write gives an alternative next step") +check(not display:find("Share this file", 1, true), + "failed log write does not tell the player to share a nonexistent file") +check(display:find("Close game", 1, true) ~= nil, + "crash screen has a visible close button") +check(CrashScreen.hitClose(unsaved, 515, 430), + "close button accepts a pointer press") +check(CrashScreen.hitClose(unsaved, 0.81, 0.75), + "close button accepts normalized touch coordinates") +check(not CrashScreen.hitClose(unsaved, 10, 10), + "outside presses do not close the game") + +local details = { "first detail line" } +for i = 1, 40 do details[#details + 1] = "trace frame " .. i end +details[#details + 1] = "last detail line" +local longScreen = CrashScreen.new({ + owner = { kind = "engine", name = "gen1recomp" }, + logPath = "/tmp/pokeport-stub-save/lua-error.log", + details = table.concat(details, "\n"), saved = true, +}, "red") +local clips = {} +local originalScissor = love.graphics.setScissor +love.graphics.setScissor = function(x, y, w, h) + clips[#clips + 1] = { x, y, w, h } +end +drawn = {} +CrashScreen.draw(longScreen) +love.graphics.setScissor = originalScissor +check(longScreen.scrollMax > 0, "long details have a scroll range") +check(#clips >= 2 and clips[1][3] > 0 and clips[1][4] > 0 + and clips[#clips][1] == nil, + "details are clipped to the panel and graphics clipping is restored") +check(table.concat(drawn, "\n"):find("first detail line", 1, true) ~= nil, + "the details panel starts at the first line") +CrashScreen.scrollTo(longScreen, true) +drawn = {} +CrashScreen.draw(longScreen) +display = table.concat(drawn, "\n") +check(display:find("last detail line", 1, true) ~= nil + and not display:find("first detail line", 1, true), + "scrolling reaches the final line without drawing hidden lines") +eq(longScreen.scroll, longScreen.scrollMax, "End clamps at the bottom") +CrashScreen.scroll(longScreen, -math.huge) +eq(longScreen.scroll, 0, "scrolling up clamps at the top") +local area = longScreen.detailArea +check(CrashScreen.pointerPressed(longScreen, "touch", area.x + 8, area.y + 25), + "touch can grab the details panel") +CrashScreen.pointerMoved(longScreen, "touch", area.x + 8, area.y + 5) +check(longScreen.scroll > 0, "touch drag scrolls the details") +CrashScreen.pointerReleased(longScreen, "touch") +check(longScreen.drag == nil, "releasing touch ends the drag") + +local copiedPath +love.system.setClipboardText = function(value) copiedPath = value end +love.keyboard = { isDown = function() return true end } +love.window.isOpen = function() return true end +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "keypressed", "c" + end + end +end +drawn = {} +local copyLoop = love.errorhandler("src/core/Game2.lua:42: crash") +copyLoop() +eq(copiedPath, "/tmp/pokeport-stub-save/lua-error.log", + "Ctrl+C copies the exact log path") +display = table.concat(drawn, "\n") +check(display:find("copied to clipboard", 1, true) ~= nil, + "screen confirms the copied path") + +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "mousepressed", 515, 430, 1 + end + end +end +local clickLoop = love.errorhandler("src/core/Game2.lua:42: crash") +eq(clickLoop(), 1, "clicking Close game exits the crash screen") + +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "gamepadpressed", {}, "start" + end + end +end +local padLoop = love.errorhandler("src/core/Game2.lua:42: crash") +eq(padLoop(), 1, "pressing Start exits the crash screen") + +local originalScroll = CrashScreen.scroll +local scrollCalls = 0 +CrashScreen.scroll = function(screen, amount) + scrollCalls = scrollCalls + 1 + originalScroll(screen, amount) +end +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "wheelmoved", 0, -1 + end + end +end +local wheelLoop = love.errorhandler("src/core/Game2.lua:42: crash") +wheelLoop() +CrashScreen.scroll = originalScroll +eq(scrollCalls, 1, "mouse wheel events reach the details scroller") + +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "mousepressed", 515, 430, 1, true + end + end +end +local syntheticMouseLoop = love.errorhandler("src/core/Game2.lua:42: crash") +eq(syntheticMouseLoop(), nil, + "synthetic mouse presses do not duplicate touch input") + +love.event.poll = function() + local sent = false + return function() + if not sent then + sent = true + return "touchpressed", "finger", 0.81, 0.75 + end + end +end +local touchLoop = love.errorhandler("src/core/Game2.lua:42: crash") +eq(touchLoop(), 1, "touching Close game exits the crash screen") + +T.finish() diff --git a/tests/engine/switch_diagnostics_test.lua b/tests/engine/switch_diagnostics_test.lua index 2be22cb2..5f8a5af3 100644 --- a/tests/engine/switch_diagnostics_test.lua +++ b/tests/engine/switch_diagnostics_test.lua @@ -53,14 +53,60 @@ check(logLate:find("n=2", 1, true) ~= nil, "flush includes events after 1s") -- Lua error log: redacted, no ROM bytes. local romErr = string.char(0xEA, 0x9B, 0xCA, 0xE6) local hint = SwitchDiagnostics.logLuaError("probe failure") -check(type(hint) == "string" and hint:find("lua-error.log", 1, true) ~= nil, - "error handler hint mentions lua-error.log") +eq(hint, "Error log: /tmp/pokeport-stub-save/lua-error.log", + "error handler gives the absolute error log path") SwitchDiagnostics.logLuaError(romErr) local errLog = love.filesystem.read("lua-error.log") or "" check(errLog:find("probe failure", 1, true) ~= nil, "lua-error.log records message") +check(errLog:find("Likely source: gen1recomp", 1, true) ~= nil, + "unattributed errors identify gen1recomp") check(errLog:find("", 1, true) ~= nil, "lua-error.log strips ROM bytes") check(not errLog:find(romErr, 1, true), "lua-error.log omits raw ROM bytes") +love.filesystem.write("mods/sample_mod/manifest.json", + '{"id":"sample_mod","name":"Sample Mod"}') +local modHint, modSource, modReport = SwitchDiagnostics.logLuaError( + "mods/sample_mod/scripts/main.lua:12: crash", + "stack traceback:\n\tmods/sample_mod/scripts/main.lua:12: in function 'draw'") +eq(modSource, 'Likely source: mod "Sample Mod" (sample_mod)', + "mod file errors name the owning mod") +eq(modHint, "Error log: /tmp/pokeport-stub-save/lua-error.log", + "mod errors show the same absolute log path") +eq(modReport.owner.kind, "mod", "crash screen receives mod attribution") +eq(modReport.owner.name, "Sample Mod", "crash screen receives the display name") +check(modReport.saved, "crash screen knows the log was saved") +check(modReport.details:find("mods/sample_mod/scripts/main.lua:12: crash", 1, true) + and modReport.details:find("stack traceback:", 1, true), + "crash screen receives the current message and traceback for scrolling") +errLog = love.filesystem.read("lua-error.log") or "" +check(errLog:find("mods/sample_mod/scripts/main.lua:12", 1, true) ~= nil, + "saved log includes the traceback") +eq(SwitchDiagnostics.errorSource("plain failure", + "stack traceback:\n\tsrc/mods/Sandbox.lua:225: in function 'require'" + .. "\n\tmods/sample_mod/scripts/main.lua:12: in function 'draw'"), + 'Likely source: mod "Sample Mod" (sample_mod)', + "traceback identifies a mod after engine dispatch frames") +eq(SwitchDiagnostics.errorSource("src/mods/Loader.lua:12: crash", ""), + "Likely source: gen1recomp", "engine mod loader is not a user mod") +eq(SwitchDiagnostics.errorSource( + "/game/mods/sample_mod/src/mods/helper.lua:12: crash", ""), + 'Likely source: mod "Sample Mod" (sample_mod)', + "a mod's own src/mods directory stays attributed to the mod") +eq(SwitchDiagnostics.errorSource("src/ui/gen2/WideBattle.lua:37: crash", ""), + "Likely source: gen1recomp", "engine crash is attributed to gen1recomp") +love.filesystem.remove("mods/sample_mod/manifest.json") + +local originalWrite = love.filesystem.write +love.filesystem.write = function(name, contents) + if name == "lua-error.log" then return false end + return originalWrite(name, contents) +end +local failedHint, _, failedReport = SwitchDiagnostics.logLuaError("write failed") +love.filesystem.write = originalWrite +check(failedHint:find("Could not save error log", 1, true) ~= nil, + "failed writes are reported honestly") +check(not failedReport.saved, "crash screen knows when the log was not saved") + -- Stack-trace style messages (newlines) must remain readable — not wholesale -- "" (fused Play triage regression). SwitchDiagnostics.logLuaError("missing module 'data/generated/maps.lua'.\nImport again.\n(detail)")