mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-04 08:21:40 -04:00
Merge pull request #2509 from Nexhas28/crash-screen-engine
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user