diff --git a/main.lua b/main.lua index 8d3a368c..bbe44f54 100644 --- a/main.lua +++ b/main.lua @@ -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) diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua new file mode 100644 index 00000000..c256475f --- /dev/null +++ b/src/debug/SwitchDiagnostics.lua @@ -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 "" end + end + return s +end + +local function sanitize(value, depth) + depth = depth or 0 + if depth > 4 then return "" 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] = "" + 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 diff --git a/tests/engine/switch_diagnostics_test.lua b/tests/engine/switch_diagnostics_test.lua new file mode 100644 index 00000000..3e83a2dc --- /dev/null +++ b/tests/engine/switch_diagnostics_test.lua @@ -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()