fix(switch): restore Save Editor pad and touch input on NX

Route gamepad/touch into the editor instead of dropping them in editorMode,
and hit-test clicks at event coords so a finger tap is not lost under the
Joy-Con virtual cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-02 23:04:37 -03:00
parent f0829ed54f
commit 8bf99c3318
8 changed files with 479 additions and 9 deletions
+1
View File
@@ -47,6 +47,7 @@ the transfer runbook).
- VoxelMod OPTIONS + Switch performance tips documented (WATER / 3D-BTL / extras)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
### Known gaps / welcome contributions
+62 -8
View File
@@ -369,49 +369,91 @@ end
function love.gamepadpressed(joystick, button)
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode or TouchEditor then return end
if editorMode then
if EditorApp and EditorApp.gamepadpressed then
return EditorApp.gamepadpressed(joystick, button)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.gamepadreleased then
return EditorApp.gamepadreleased(joystick, button)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.gamepadaxis then
return EditorApp.gamepadaxis(joystick, axis, value)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.joystickpressed then
return EditorApp.joystickpressed(joystick, button)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.joystickreleased then
return EditorApp.joystickreleased(joystick, button)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.joystickaxis then
return EditorApp.joystickaxis(joystick, axis, value)
end
return
end
if 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 editorMode then
if EditorApp and EditorApp.joystickhat then
return EditorApp.joystickhat(joystick, hat, direction)
end
return
end
if TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
@@ -459,7 +501,16 @@ function love.lowmemory()
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
-- would double-fire. Android / NX need the explicit touch → click path
-- (love-nx does not synthesize mouse for the editor the way desktop does).
if love.system.getOS() == "iOS" then return end
if EditorApp and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, 1)
end
return
end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
@@ -537,6 +588,9 @@ function love.mousepressed(x, y, button, istouch)
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
-- Same Android double-fire guard: touchpressed already clicked for the
-- save editor; a synthesized mouse press must not fire again.
if istouch and love.system.getOS() == "Android" then return end
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
+1
View File
@@ -62,6 +62,7 @@ if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LIST
fi
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/PadInput.lua \
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json; do
+1
View File
@@ -125,6 +125,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
+106
View File
@@ -0,0 +1,106 @@
-- Save editor pad / Joy-Con input (NX soft-lock fix).
-- PadInput mirrors the launcher cursor; main.lua must forward editorMode
-- gamepad/touch instead of discarding them.
-- luajit tests/save_editor_pad_input_test.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love or love_stub
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GamepadMap = require("src.core.GamepadMap")
local PadInput = require("PadInput")
PadInput.reset()
-- Stick deflection activates the cursor and moves it.
PadInput.gamepadaxis(nil, "leftx", 1)
PadInput.update(0.05)
check(PadInput.isActive(), "left stick activates pad cursor")
local x0 = select(1, PadInput.pointer())
PadInput.update(0.05)
local x1 = select(1, PadInput.pointer())
check(x1 > x0, "left stick moves cursor right")
-- A / B via GamepadMap (desktop labels = GB a/b).
eq(PadInput.gamepadpressed(nil, "a"), "a", "gamepad a → click action")
eq(PadInput.gamepadpressed(nil, "b"), "b", "gamepad b → close action")
eq(PadInput.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L cycles tab prev")
eq(PadInput.gamepadpressed(nil, "rightshoulder"), "tab_next", "R cycles tab next")
-- NX face swap: SDL south "a" is Nintendo B → GB b (close).
GamepadMap._setForceNXForTests(true)
eq(PadInput.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)")
eq(PadInput.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)")
GamepadMap._setForceNXForTests(false)
-- Dual-path gate: gamepad sticks must not also fire from raw joystick (#620).
local gamepadJoy = {
isGamepad = function() return true end,
}
eq(PadInput.joystickpressed(gamepadJoy, 1), nil,
"ignoreRaw skips second fire on isGamepad sticks")
-- Raw (non-gamepad) stick still maps through GamepadMap.
local rawJoy = {
isGamepad = function() return false end,
}
eq(PadInput.joystickpressed(rawJoy, 1), "a",
"raw #1 clicks via shared map")
-- Right stick accumulates wheel notches.
PadInput.reset()
PadInput.gamepadaxis(nil, "righty", -1) -- stick up
PadInput.update(1.0)
local notches = PadInput.takeWheel()
check(notches >= 1, "right stick up yields positive wheel notches")
PadInput.reset()
check(not PadInput.isActive(), "reset clears active cursor")
-- Touch / mouse must yield the pad so a tap is not hit-tested at the Joy-Con
-- pointer (the NX "cursor shows but touch does nothing" bug).
PadInput.gamepadaxis(nil, "leftx", 1)
PadInput.update(0.05)
check(PadInput.isActive(), "stick activates before yield")
PadInput.yieldToPointer()
check(not PadInput.isActive(), "yieldToPointer drops the virtual cursor")
-- Source seams: main.lua must forward editorMode gamepad/touch; App must
-- own the pad handlers, prefer event click coords over the pad pointer, and
-- feed pad coords into Kit only when active and no pending click.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local mainSrc = read("main.lua")
check(mainSrc:find("if editorMode then", 1, true) ~= nil
and mainSrc:find("EditorApp.gamepadpressed", 1, true) ~= nil,
"main.lua forwards gamepadpressed to EditorApp in editorMode")
check(mainSrc:find("EditorApp.mousepressed(x, y, 1)", 1, true) ~= nil,
"main.lua touchpressed clicks the save editor (non-iOS)")
check(mainSrc:find('istouch and love.system.getOS() == "Android"', 1, true) ~= nil,
"main.lua guards Android double-fire for editor mousepressed")
local appSrc = read("tools/save-editor/App.lua")
check(appSrc:find('require("PadInput")', 1, true) ~= nil,
"App.lua loads PadInput")
check(appSrc:find("function App.gamepadpressed", 1, true) ~= nil,
"App.lua exposes gamepadpressed")
check(appSrc:find("PadInput.reset()", 1, true) ~= nil,
"App.unload resets PadInput")
check(appSrc:find("PadInput.pointer()", 1, true) ~= nil,
"App.draw uses pad pointer when active")
check(appSrc:find("PadInput.yieldToPointer()", 1, true) ~= nil,
"App.mousepressed yields the pad so touch uses event coords")
check(appSrc:find("mouseClicked and clickX", 1, true) ~= nil,
"App.draw prefers click event coords over pad / mouse")
T.finish("save_editor_pad_input")
+82 -1
View File
@@ -24,6 +24,7 @@ local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local PadInput = require("PadInput")
local PAL = Theme.PAL
local Party = require("Party")
@@ -41,6 +42,10 @@ local S
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
-- Click position from the press event. Kit samples the pointer in draw, so a
-- touch / mouse / pad-A click must use the event coords -- not love.mouse
-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
local clickX, clickY
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
-- notch is always spent by the frame that follows it (#595).
@@ -233,6 +238,34 @@ function App.unload()
-- deaf to every click (#541).
Kit.blur()
Kit.blockClicks = false
PadInput.reset()
end
local function cycleTab(delta)
if not S then return end
local idx = 1
for i, t in ipairs(TABS) do
if t.id == S.tab then idx = i; break end
end
idx = ((idx - 1 + delta) % #TABS) + 1
S.tab = TABS[idx].id
Ops.say(S, "Tab: " .. TABS[idx].label)
end
-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
-- NX physical A confirms and B closes).
local function handlePadAction(action)
if not action or not S then return end
if action == "a" then
local mx, my = PadInput.pointer()
App.mousepressed(mx, my, 1)
elseif action == "b" then
App.close()
elseif action == "tab_prev" then
cycleTab(-1)
elseif action == "tab_next" then
cycleTab(1)
end
end
function App.save()
@@ -277,6 +310,7 @@ end
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
if not S then return false end
if S.dirty and not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, Save first or click Close again to discard"
@@ -301,16 +335,55 @@ function App.update(dt)
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
-- flowers) still needs ticking so the Map tab isn't static.
TileRenderer.tick()
PadInput.update(dt)
local notches = PadInput.takeWheel()
if notches ~= 0 then
App.wheelmoved(0, notches)
end
end
function App.mousepressed(x, y, button)
if button == 1 then mouseClicked = true end
if button == 1 then
mouseClicked = true
clickX, clickY = x, y
-- A finger / mouse tap yields the virtual cursor so the click lands where
-- the event said, not under the Joy-Con pointer (NX touch soft-miss).
PadInput.yieldToPointer()
end
end
function App.textinput(text)
Kit.textinput(text)
end
function App.gamepadpressed(joystick, button)
handlePadAction(PadInput.gamepadpressed(joystick, button))
end
function App.gamepadreleased(joystick, button)
PadInput.gamepadreleased(joystick, button)
end
function App.gamepadaxis(joystick, axis, value)
PadInput.gamepadaxis(joystick, axis, value)
end
function App.joystickpressed(joystick, button)
handlePadAction(PadInput.joystickpressed(joystick, button))
end
function App.joystickreleased(joystick, button)
PadInput.joystickreleased(joystick, button)
end
function App.joystickaxis(joystick, axis, value)
PadInput.joystickaxis(joystick, axis, value)
end
function App.joystickhat(joystick, hat, direction)
PadInput.joystickhat(joystick, hat, direction)
end
-- ------------------------------------------------------------------ chrome
-- The file chip: the single source of truth for "which file am I editing".
-- The path truncates from the LEFT so the filename is always readable, and
@@ -624,8 +697,15 @@ function App.draw()
local s = Kit.scale
local mx, my = love.mouse.getPosition()
local padX, padY, padOn = PadInput.pointer()
if mouseClicked and clickX ~= nil then
mx, my = clickX, clickY
elseif padOn then
mx, my = padX, padY
end
Kit.beginFrame(mx, my, mouseClicked, wheelY)
mouseClicked = false
clickX, clickY = nil, nil
wheelY = 0
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
@@ -656,6 +736,7 @@ function App.draw()
Kit.blockClicks = false
SpeciesPicker.draw(S, Kit, width, height)
Kit.endFrame()
PadInput.draw()
-- Only now, with the whole frame painted, is it safe to drop the editor.
if S._closeRequested then finishClose() end
+215
View File
@@ -0,0 +1,215 @@
-- Virtual pointer for the save editor on Switch / handhelds / any gamepad.
-- Mirrors the launcher's RomImporter pad cursor (speeds, deadzone, dual-path
-- raw gate) without sharing that module -- keeps RomImporter risk-free.
--
-- Stick / D-pad move; real mouse motion yields so desktop stays normal.
-- Callers (App.lua) map A → click, B → close, shoulders → tabs, right stick
-- → wheel notches.
local SafeArea = require("src.core.SafeArea")
local GamepadMap = require("src.core.GamepadMap")
local PAD_DEAD = 0.28
local PAD_SPEED = 560
local PAD_DPAD_SPEED = 420
-- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists
-- scroll at a usable pace without flooding one frame.
local PAD_WHEEL_RATE = 2.0
local PadInput = {}
local cursor = { x = 0, y = 0 }
local active = false
local inited = false
local axis = { leftx = 0, lefty = 0, righty = 0 }
local dir = {}
local rawHatDirs = {}
local lastMouseX, lastMouseY
local wheelAcc = 0
local function activate()
if active then return end
local ox, oy, w, h = SafeArea.rect()
if not inited then
cursor.x = ox + w * 0.5
cursor.y = oy + h * 0.45
inited = true
end
active = true
end
function PadInput.reset()
cursor.x, cursor.y = 0, 0
active = false
inited = false
axis.leftx, axis.lefty, axis.righty = 0, 0, 0
for k in pairs(dir) do dir[k] = nil end
for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end
lastMouseX, lastMouseY = nil, nil
wheelAcc = 0
end
-- Touch / mouse press: drop the virtual cursor for this interaction so a tap
-- is not swallowed by the Joy-Con pointer sitting elsewhere on screen.
function PadInput.yieldToPointer()
active = false
end
-- Returns mx, my, isActive. When inactive the caller should use the system
-- mouse; when active these coords feed Kit.beginFrame.
function PadInput.pointer()
return cursor.x, cursor.y, active
end
function PadInput.isActive()
return active
end
-- Consume accumulated right-stick scroll as integer wheel notches (same
-- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame.
function PadInput.takeWheel()
local notches = 0
if wheelAcc >= 1 or wheelAcc <= -1 then
notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc)
wheelAcc = wheelAcc - notches
end
return notches
end
function PadInput.update(dt)
if not (love and love.mouse and love.mouse.getPosition) then return end
local mx, my = love.mouse.getPosition()
if lastMouseX and active then
if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then
active = false
end
end
lastMouseX, lastMouseY = mx, my
local ax = axis.leftx or 0
local ay = axis.lefty or 0
local dx, dy = 0, 0
if math.abs(ax) > PAD_DEAD then dx = dx + ax end
if math.abs(ay) > PAD_DEAD then dy = dy + ay end
if dir.dpleft then dx = dx - 1 end
if dir.dpright then dx = dx + 1 end
if dir.dpup then dy = dy - 1 end
if dir.dpdown then dy = dy + 1 end
if dx ~= 0 or dy ~= 0 then
activate()
local mag = math.sqrt(dx * dx + dy * dy)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local ox, oy, w, h = SafeArea.rect()
local nx = cursor.x + dx * speed * dt
local ny = cursor.y + dy * speed * dt
cursor.x = math.max(ox, math.min(ox + w, nx))
cursor.y = math.max(oy, math.min(oy + h, ny))
end
local ry = axis.righty or 0
if math.abs(ry) > PAD_DEAD then
activate()
-- Negative righty (stick up) scrolls lists up = positive wheel notches.
wheelAcc = wheelAcc + (-ry) * PAD_WHEEL_RATE * dt
end
end
-- Returns a string action the App layer handles:
-- "a" | "b" | "tab_prev" | "tab_next" | nil
function PadInput.gamepadpressed(_, button)
activate()
local action = GamepadMap.mapGamepadButton(button)
if action == "a" or action == "b" then
return action
elseif button == "leftshoulder" then
return "tab_prev"
elseif button == "rightshoulder" then
return "tab_next"
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
dir[button] = true
end
return nil
end
function PadInput.gamepadreleased(_, button)
if button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
dir[button] = nil
end
end
function PadInput.gamepadaxis(_, axisName, value)
if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then
axis[axisName] = value
if math.abs(value) > PAD_DEAD then activate() end
end
end
function PadInput.joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then return PadInput.gamepadpressed(joystick, padButton) end
return nil
end
function PadInput.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then PadInput.gamepadreleased(joystick, padButton) end
end
function PadInput.joystickaxis(joystick, axisIndex, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axisIndex == 1 then
PadInput.gamepadaxis(joystick, "leftx", value)
elseif axisIndex == 2 then
PadInput.gamepadaxis(joystick, "lefty", value)
end
end
function PadInput.joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
for _, d in ipairs(rawHatDirs[hat] or {}) do
dir[d] = nil
end
local dirs = ({
u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" },
lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" },
ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" },
})[direction] or {}
for _, d in ipairs(dirs) do dir[d] = true end
rawHatDirs[hat] = dirs
if #dirs > 0 then activate() end
end
function PadInput.draw()
if not active then return end
if not (love and love.graphics) then return end
local x, y = cursor.x, cursor.y
love.graphics.push("all")
if love.graphics.origin then love.graphics.origin() end
if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end
love.graphics.setColor(0, 0, 0, 0.45)
if love.graphics.polygon then
love.graphics.polygon("fill",
x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26,
x + 18, y + 24, x + 11, y + 14, x + 20, y + 14)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.polygon("fill",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
love.graphics.setColor(0.05, 0.07, 0.12, 1)
love.graphics.polygon("line",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
else
love.graphics.rectangle("fill", x, y, 12, 18)
end
love.graphics.pop()
end
return PadInput
+11
View File
@@ -37,6 +37,7 @@ If the file isn't there (or you want another copy), use **Open...**, drop a
| --- | --- |
| `Theme.lua` | the launcher's palette + drawing primitives (cards, glow, dashed outlines, letterspaced captions) |
| `Kit.lua` | immediate-mode widgets built on Theme: buttons, rows, meters, chips, checkboxes, a real text field, pagers |
| `PadInput.lua` | virtual cursor for Switch / gamepads (stick move, A click, B close, shoulders cycle tabs) |
| `Ops.lua` | **every mutation**, behind one funnel that sets dirty + status together |
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
| `panels/` | one file per tab; pure layout that dispatches into Ops |
@@ -46,6 +47,15 @@ the modal species search the inspector opens, drawn by `App.draw` after the
panel rather than routed through the tab table. Kit has no z-order, so while
it is up `Kit.blockClicks` shields every widget underneath it.
### Switch / gamepad
On Nintendo Switch (and any gamepad without a mouse), the editor uses the same
virtual-cursor idea as the launcher: left stick / D-pad moves a pointer, **A**
clicks, **B** closes (with the usual unsaved confirm), L/R cycle tabs, and the
right stick scrolls lists. Touch taps forward as clicks. Without that path the
editor soft-locked until HOME — `main.lua` used to drop all pad/touch events
while `editorMode` was set.
The design reference is the `SaveEditor.dc.html` mockup that this port
transcribes; its measurements are in the same pixel space `App.lua` draws in.
@@ -70,6 +80,7 @@ luajit tests/save_editor_task6_tests.lua # Boxes + Items rules
luajit tests/save_editor_task7_tests.lua # Events + Dex rules
luajit tests/save_editor_task8_tests.lua # map browser + spawn points
luajit tests/save_editor_mod_tests.lua # modded species/items stay editable
luajit tests/save_editor_pad_input_test.lua # pad cursor / NX input routing
```
They drive `Ops.lua` rather than clicking pixel coordinates. The panels are