feat(debug): add opt-in Switch input diagnostics

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 03:53:20 -03:00
parent 83cf1fdb00
commit 361d4b81df
3 changed files with 207 additions and 0 deletions
+11
View File
@@ -10,6 +10,8 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local Game, EditorApp, Importer, TouchEditor local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
@@ -268,6 +270,7 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
SwitchDiagnostics.maybeFlush(false)
if editorMode then return EditorApp.update(dt) end if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end if Importer then return Importer:update(dt) end
@@ -342,48 +345,56 @@ function love.keyreleased(key)
end end
function love.gamepadpressed(joystick, button) function love.gamepadpressed(joystick, button)
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadpressed(joystick, button) end if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button) Game:gamepadpressed(joystick, button)
end end
function love.gamepadreleased(joystick, button) function love.gamepadreleased(joystick, button)
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadreleased(joystick, button) end if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button) Game:gamepadreleased(joystick, button)
end end
function love.gamepadaxis(joystick, axis, value) function love.gamepadaxis(joystick, axis, value)
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end end
function love.joystickpressed(joystick, button) function love.joystickpressed(joystick, button)
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:joystickpressed(joystick, button) end if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button) Game:joystickpressed(joystick, button)
end end
function love.joystickreleased(joystick, button) function love.joystickreleased(joystick, button)
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button) Game:joystickreleased(joystick, button)
end end
function love.joystickaxis(joystick, axis, value) function love.joystickaxis(joystick, axis, value)
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value) Game:joystickaxis(joystick, axis, value)
end end
function love.joystickhat(joystick, hat, direction) function love.joystickhat(joystick, hat, direction)
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction)
end end
function love.joystickremoved(joystick) function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return end if Importer then return end
Game:joystickremoved(joystick) Game:joystickremoved(joystick)
+143
View File
@@ -0,0 +1,143 @@
-- Opt-in Switch diagnostics: ring buffer + ≤1 Hz flush when switch-debug.txt exists.
-- Never logs ROM/save bytes — see spec SWNX-13/28.
local SwitchDiagnostics = {}
local MARKER = "switch-debug.txt"
local LOG_FILE = "switch.log"
local FLUSH_INTERVAL = 1.0
local RING_SIZE = 64
local enabled = nil
local buffer = {}
local bufCount = 0
local lastFlushAt = -math.huge
local identityLine = nil
local function fs()
return love and love.filesystem
end
local function redactString(s)
if type(s) ~= "string" then return s end
for i = 1, #s do
local b = s:byte(i)
if b < 32 or b > 126 then return "<redacted>" end
end
return s
end
local function sanitize(value, depth)
depth = depth or 0
if depth > 4 then return "<deep>" end
local t = type(value)
if t == "string" then return redactString(value) end
if t == "number" or t == "boolean" or value == nil then return value end
if t == "table" then
local out = {}
for k, v in pairs(value) do
local key = type(k) == "string" and k or tostring(k)
if key:lower():find("rom") or key:lower():find("save") then
out[key] = "<redacted>"
else
out[key] = sanitize(v, depth + 1)
end
end
return out
end
return tostring(value)
end
local function encodePayload(payload)
if payload == nil then return "" end
if type(payload) == "string" then return redactString(payload) end
local parts = {}
for k, v in pairs(sanitize(payload)) do
parts[#parts + 1] = tostring(k) .. "=" .. tostring(v)
end
table.sort(parts)
return table.concat(parts, " ")
end
function SwitchDiagnostics._resetForTests()
enabled = nil
buffer = {}
bufCount = 0
lastFlushAt = -math.huge
identityLine = nil
end
function SwitchDiagnostics.isEnabled()
if enabled ~= nil then return enabled end
local filesystem = fs()
if not filesystem then
enabled = false
return false
end
enabled = filesystem.getInfo(MARKER) ~= nil
return enabled
end
function SwitchDiagnostics.identityOverlay()
if identityLine then return identityLine end
local gitCommit = os.getenv("POKEPORT_GIT_COMMIT") or "unknown"
local loveNxTag = "11.5-nx1"
local buildVersion = "dev"
local filesystem = fs()
if filesystem then
local raw = filesystem.read("build-info.json")
if raw and raw ~= "" then
local ver = raw:match('"version"%s*:%s*"([^"]+)"')
if ver then buildVersion = ver end
local tag = raw:match('"loveNxTag"%s*:%s*"([^"]+)"')
if tag then loveNxTag = tag end
local commit = raw:match('"gitCommit"%s*:%s*"([^"]+)"')
if commit then gitCommit = commit end
end
end
identityLine = ("gitCommit=%s loveNxTag=%s buildVersion=%s os=%s"):format(
gitCommit, loveNxTag, buildVersion,
love and love.system and love.system.getOS() or "unknown")
return identityLine
end
function SwitchDiagnostics.onEvent(kind, payload)
if not SwitchDiagnostics.isEnabled() then return end
bufCount = bufCount + 1
local slot = ((bufCount - 1) % RING_SIZE) + 1
buffer[slot] = ("%s %s"):format(tostring(kind), encodePayload(payload))
end
function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra)
if not SwitchDiagnostics.isEnabled() then return end
local payload = { button = button }
if joystick then
if joystick.getGUID then payload.guid = joystick:getGUID() end
if joystick.isGamepad then payload.isGamepad = joystick:isGamepad() end
if joystick.getName then payload.name = joystick:getName() end
end
if extra then
for k, v in pairs(extra) do payload[k] = v end
end
SwitchDiagnostics.onEvent(kind, payload)
end
function SwitchDiagnostics.maybeFlush(force, now)
if not SwitchDiagnostics.isEnabled() then return end
now = now or (love and love.timer and love.timer.getTime() or 0)
if not force and (now - lastFlushAt) < FLUSH_INTERVAL then return end
lastFlushAt = now
local filesystem = fs()
if not filesystem then return end
local lines = { SwitchDiagnostics.identityOverlay(), "---" }
local start = math.max(1, bufCount - RING_SIZE + 1)
for i = start, bufCount do
local slot = ((i - 1) % RING_SIZE) + 1
if buffer[slot] then lines[#lines + 1] = buffer[slot] end
end
filesystem.write(LOG_FILE, table.concat(lines, "\n") .. "\n")
end
return SwitchDiagnostics
+53
View File
@@ -0,0 +1,53 @@
-- Opt-in Switch input diagnostics (SWNX-13/28): marker file, ring buffer, flush cap.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local function reset()
SwitchDiagnostics._resetForTests()
love.filesystem.remove("switch-debug.txt")
love.filesystem.remove("switch.log")
end
reset()
check(not SwitchDiagnostics.isEnabled(), "disabled without marker file")
love.filesystem.write("switch-debug.txt", "")
reset()
love.filesystem.write("switch-debug.txt", "")
check(SwitchDiagnostics.isEnabled(), "enabled when marker exists")
SwitchDiagnostics.onEvent("probe", { kind = "gamepadpressed", button = "a" })
SwitchDiagnostics.maybeFlush(true, 0)
local log = love.filesystem.read("switch.log") or ""
check(log:find("gamepadpressed", 1, true) ~= nil, "flush writes buffered events")
check(log:find("gitCommit=", 1, true) ~= nil, "identity includes gitCommit field")
check(log:find("loveNxTag=", 1, true) ~= nil, "identity includes loveNxTag field")
-- ROM-like byte sequences must never appear in diagnostics output.
local romSnippet = string.char(0xEA, 0x9B, 0xCA, 0xE6)
SwitchDiagnostics.onEvent("probe", { sample = romSnippet, note = "redacted" })
SwitchDiagnostics.maybeFlush(true, 1)
log = love.filesystem.read("switch.log") or ""
check(not log:find(romSnippet, 1, true),
"ROM bytes are stripped from diagnostic payloads")
-- Flush rate capped at 1 Hz unless forced.
reset()
love.filesystem.write("switch-debug.txt", "")
SwitchDiagnostics.onEvent("tick", { n = 1 })
SwitchDiagnostics.maybeFlush(true, 0.0)
SwitchDiagnostics.onEvent("tick", { n = 2 })
SwitchDiagnostics.maybeFlush(false, 0.5)
local logMid = love.filesystem.read("switch.log") or ""
SwitchDiagnostics.maybeFlush(false, 1.0)
local logLate = love.filesystem.read("switch.log") or ""
check(not logMid:find("n=2", 1, true), "flush waits until 1s elapsed")
check(logLate:find("n=2", 1, true) ~= nil, "flush includes events after 1s")
T.finish()