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 SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
@@ -268,6 +270,7 @@ function love.load(args)
end
function love.update(dt)
SwitchDiagnostics.maybeFlush(false)
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
@@ -342,48 +345,56 @@ function love.keyreleased(key)
end
function love.gamepadpressed(joystick, button)
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
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()