Merge pull request #2509 from Nexhas28/crash-screen-engine

This commit is contained in:
bryanthaboi
2026-09-26 19:33:17 -04:00
committed by GitHub
6 changed files with 875 additions and 58 deletions
+117 -47
View File
@@ -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
+341
View File
@@ -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
+106 -5
View File
@@ -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)
@@ -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
+259
View File
@@ -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()
+48 -2
View File
@@ -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("<redacted>", 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
-- "<redacted>" (fused Play triage regression).
SwitchDiagnostics.logLuaError("missing module 'data/generated/maps.lua'.\nImport again.\n(detail)")