singe/assets/Tools.singe

810 lines
24 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The service tools: everything an operator or a developer needs to check a cabinet, reached by
-- pressing SERVICE in the menu. Each tool is a page of Singe/Menu.rml and an entry in TOOLS with
-- up to four functions: begin when it opens, input for a switch, update once a frame, and finish
-- when it closes. Anything a tool leaves out simply does not happen.
--
-- Only the RmlUi menu has these. Singe/MenuClassic.singe exists for a machine with no GPU device,
-- where there is no document to put a page in; it keeps its own audio delay screen and nothing
-- else.
local lfs = require("lfs")
TOOL_SELECTED = 1
TOOL_OPEN = nil -- The tool being used, or nil while the list itself is showing
TOOLS = {}
-- Writing into a page is the one thing every tool does, and the document and the GUI are the same
-- ones every time, so they are named here rather than in twenty-two places.
local function set(id, text)
guiSetValue(GUI, DOCUMENT, id, text)
end
-- ===== The list =====
function toolsBegin()
TOOL_OPEN = nil
toolsShowList()
toolsShowPage("tools")
end
function toolsEnd()
if TOOL_OPEN and TOOL_OPEN.finish then
TOOL_OPEN.finish()
end
TOOL_OPEN = nil
toolsShowPage(nil)
end
-- One page of the document at a time, and the game list when nothing is named.
function toolsShowPage(id)
menuElement("games"):SetClass("hidden", id ~= nil)
menuElement("tools"):SetClass("hidden", id ~= "tools")
for _, tool in ipairs(TOOLS) do
menuElement(tool.id):SetClass("hidden", id ~= tool.id)
end
end
function toolsShowList()
local rows = {}
for i, tool in ipairs(TOOLS) do
local class = (i == TOOL_SELECTED) and ' class="selected"' or ""
rows[#rows + 1] = string.format("<div%s>%s</div>", class, tool.name)
end
set("toolList", table.concat(rows))
set("toolHelp", TOOLS[TOOL_SELECTED].help or "")
end
function toolsOpen(index)
TOOL_OPEN = TOOLS[index]
if TOOL_OPEN.begin then
TOOL_OPEN.begin()
end
toolsShowPage(TOOL_OPEN.id)
end
function toolsClose()
if TOOL_OPEN and TOOL_OPEN.finish then
TOOL_OPEN.finish()
end
TOOL_OPEN = nil
toolsShowList()
toolsShowPage("tools")
end
-- Every switch while the tools are up. A tool that wants a switch for itself says so by returning
-- true from its input function; everything else falls through to the rules here, so Button 2 always
-- backs out and SERVICE always leaves.
function toolsInput(what)
if TOOL_OPEN then
if TOOL_OPEN.input and TOOL_OPEN.input(what) then
return
end
if what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then
toolsClose()
end
return
end
if what == SWITCH_UP then
TOOL_SELECTED = (TOOL_SELECTED == 1) and #TOOLS or (TOOL_SELECTED - 1)
toolsShowList()
elseif what == SWITCH_DOWN then
TOOL_SELECTED = (TOOL_SELECTED == #TOOLS) and 1 or (TOOL_SELECTED + 1)
toolsShowList()
elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 or what == SWITCH_START2 then
toolsOpen(TOOL_SELECTED)
elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then
toolsEnd()
end
end
function toolsUpdate()
if TOOL_OPEN and TOOL_OPEN.update then
TOOL_OPEN.update()
end
end
-- Whether the tools have the screen, which is what the menu asks before doing anything of its own.
function toolsActive()
return TOOL_OPEN ~= nil or not menuElement("tools"):IsClassSet("hidden")
end
-- ===== 1. Audio delay =====
-- A click every second and a flash scheduled the engine's measured device queue plus the candidate
-- value after it. The click and the disc audio share one mixer device, so when the player sees and
-- hears them as one event the candidate is this machine's delay.
local CAL_PERIOD = 1000
local CAL_STEP_COARSE = 10
local CAL_STEP_FINE = 1
local CAL_LIMIT = 1000
local calValue, calBeat, calClickDone, calFlashDone
local function audioShow()
set("calValue", tostring(calValue))
end
TOOLS[#TOOLS + 1] = {
id = "toolAudio",
name = "Audio Delay",
help = "Line the click up with the flash so sound and picture agree.",
begin = function()
calValue = singeGetAudioCalibration()
calBeat = singeGetTicks() - CAL_PERIOD
calClickDone = true
calFlashDone = true
set("calLatency", tostring(singeGetAudioLatency()))
audioShow()
end,
input = function(what)
local delta = 0
if what == SWITCH_LEFT then
delta = -CAL_STEP_COARSE
elseif what == SWITCH_RIGHT then
delta = CAL_STEP_COARSE
elseif what == SWITCH_UP then
delta = CAL_STEP_FINE
elseif what == SWITCH_DOWN then
delta = -CAL_STEP_FINE
elseif what == SWITCH_START1 or what == SWITCH_START2 then
calValue = 0
audioShow()
return true
elseif what == SWITCH_BUTTON1 then
singeSetAudioCalibration(calValue)
toolsClose()
return true
end
if delta ~= 0 then
calValue = math.max(-CAL_LIMIT, math.min(CAL_LIMIT, calValue + delta))
audioShow()
return true
end
return false
end,
update = function()
local now = singeGetTicks()
if now - calBeat >= CAL_PERIOD then
calBeat = now
calClickDone = false
calFlashDone = false
end
-- The click goes first; the flash waits out the device queue and the candidate delay, so
-- the two land together only when the candidate matches the machine.
if not calClickDone then
calClickDone = true
soundPlay(SND_CLICK)
end
if not calFlashDone and (now - calBeat >= singeGetAudioLatency() + calValue) then
calFlashDone = true
menuElement("toolAudio"):SetClass("flash", true)
elseif calFlashDone then
menuElement("toolAudio"):SetClass("flash", false)
end
end,
finish = function()
menuElement("toolAudio"):SetClass("flash", false)
end,
}
-- ===== 2. Input test =====
-- Every device the engine can see and what it is doing right now. Singe opens every controller as
-- a gamepad, an unrecognised one through a mapping it writes itself, so there is one family to
-- report rather than two. The switch line shows what the game would actually receive, which is the
-- question being asked when a button "does nothing".
local inputLast = "none yet"
local inputCount = 0
-- The engine sets every switch as a SWITCH_ global, so the names come from there rather than from a
-- second list here that could fall behind it.
local SWITCH_NAMES = {}
for name, value in pairs(_G) do
if type(name) == "string" and type(value) == "number" and name:sub(1, 7) == "SWITCH_" then
SWITCH_NAMES[value] = name
end
end
TOOLS[#TOOLS + 1] = {
id = "toolInput",
name = "Input Test",
help = "Controllers, keyboard, mice and light guns, live.",
begin = function()
inputLast = "none yet"
inputCount = 0
end,
input = function(what)
inputCount = inputCount + 1
inputLast = string.format("%s (%d)", SWITCH_NAMES[what] or "unnamed", what)
-- Button 2 is how every other tool backs out, so it is reported and then allowed through.
return false
end,
update = function()
local rows = {}
rows[#rows + 1] = string.format("<div class='row'>Last switch: <b>%s</b> &nbsp; count: %d</div>", inputLast, inputCount)
rows[#rows + 1] = string.format("<div class='row'>Gamepads: %d &nbsp; Mice: %d &nbsp; Stick drives mouse: %s</div>",
controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled()))
for slot = 0, SINGE_MAX_CONTROLLERS - 1 do
if controllerIsValid(slot) then
local axes = {}
for a = 0, SINGE_CONTROLLER_AXES - 1 do
axes[#axes + 1] = string.format("%d", controllerGetAxis(slot, a))
end
local buttons = {}
-- controllerGetButton wants the framework's own code for the button, not SDL's
-- index, so it is built here the way Framework.singe builds GAMEPAD_0.
for b = 0, SINGE_CONTROLLER_BUTTONS - 1 do
if controllerGetButton(slot, SINGE_GAMEPAD_BASE + slot * SINGE_GAMEPAD_STRIDE + SINGE_GAMEPAD_BUTTON_OFFSET + b) then
buttons[#buttons + 1] = tostring(b)
end
end
rows[#rows + 1] = string.format("<div class='row'>%d: <b>%s</b></div>", slot, controllerGetName(slot))
rows[#rows + 1] = string.format("<div class='sub'>axes %s &nbsp; buttons %s</div>",
table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-")
end
end
for m = 0, mouseHowMany() - 1 do
local x, y = mouseGetPosition(m)
rows[#rows + 1] = string.format("<div class='sub'>mouse %d: %s at %d, %d</div>", m, mouseGetName(m), x, y)
end
set("inputRows", table.concat(rows))
end,
}
-- ===== 3. System information =====
-- Everything the trace header knows, on one screen. A photograph of this answers most of a bug
-- report without anyone having to find trace.txt, which is the point of it.
TOOLS[#TOOLS + 1] = {
id = "toolSystem",
name = "System Information",
help = "What this machine is and what the engine chose to run on.",
begin = function()
local i = singeGetSystemInfo()
local rows = {}
local function line(label, value)
rows[#rows + 1] = string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>", label, tostring(value))
end
line("Singe", i.version)
line("System", i.os)
line("Processor", i.cpu)
line("Renderer", i.renderer)
line("3D device", i.gpu)
line("Video", i.decoder)
line("Audio", i.audio)
line("SoundFont", i.soundFont)
line("MIDI", i.midi)
line("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d",
i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight()))
line("Data", i.dataPath)
set("systemRows", table.concat(rows))
end,
}
-- ===== 4. Light gun =====
-- Where the gun is pointing, against the picture it is pointing at. A cabinet needs this every
-- time the gun or the monitor moves, and nothing else in the engine shows the mapping from the
-- gun's own coordinates to the canvas.
-- The targets: one in the middle and one in each corner, set in far enough that a gun that is
-- slightly out still puts its shot on the screen beside them.
local GUN_SHOTS_KEPT = 12
local GUN_INSET = 24
local GUN_RING = 14
local GUN_CENTRE = 2
local GUN_CROSS = 8
local gunShots = {}
local gunLast = "none yet"
TOOLS[#TOOLS + 1] = {
id = "toolGun",
name = "Light Gun",
help = "Aim and fire: see where the shot actually lands.",
begin = function()
gunShots = {}
gunLast = "none yet"
end,
input = function(what)
if what == SWITCH_BUTTON1 then
local x, y = mouseGetPosition(0)
gunShots[#gunShots + 1] = { x = x, y = y }
while #gunShots > GUN_SHOTS_KEPT do
table.remove(gunShots, 1)
end
gunLast = string.format("%d, %d", x, y)
return true
elseif what == SWITCH_START1 or what == SWITCH_START2 then
gunShots = {}
return true
end
return false
end,
update = function()
local w, h = overlayGetWidth(), overlayGetHeight()
-- A target in the middle and one in each corner: a gun that is square on the picture puts
-- its shots on them, and one that is not shows which way it is out.
overlayClear()
colorForeground(90, 90, 90)
overlayBox(1, 1, w - 2, h - 2)
overlayLine(w / 2, 0, w / 2, h)
overlayLine(0, h / 2, w, h / 2)
colorForeground(255, 255, 0)
for _, p in ipairs({ { w / 2, h / 2 }, { GUN_INSET, GUN_INSET }, { w - GUN_INSET, GUN_INSET },
{ GUN_INSET, h - GUN_INSET }, { w - GUN_INSET, h - GUN_INSET } }) do
overlayCircle(p[1], p[2], GUN_RING)
overlayCircle(p[1], p[2], GUN_CENTRE)
end
colorForeground(255, 60, 60)
for _, shot in ipairs(gunShots) do
overlayLine(shot.x - GUN_CROSS, shot.y, shot.x + GUN_CROSS, shot.y)
overlayLine(shot.x, shot.y - GUN_CROSS, shot.x, shot.y + GUN_CROSS)
end
set("gunLast", gunLast)
set("gunCount", tostring(#gunShots))
end,
finish = function()
overlayClear()
end,
}
-- ===== 5. Sound test =====
-- Which speaker is which, and how loud each kind of sound is. A cabinet with its wires crossed
-- sounds perfectly fine until something is meant to come from one side.
local soundStep = 0
local soundNames = { "left speaker", "right speaker", "both speakers" }
local soundPans = { -1.0, 1.0, 0.0 }
TOOLS[#TOOLS + 1] = {
id = "toolSound",
name = "Sound Test",
help = "Left, right, both, and the disc's own audio tracks.",
begin = function()
soundStep = 0
set("soundWhich", "press Button 1")
local tracks = {}
for t = 0, discGetAudioTracks() - 1 do
local code = discGetLanguage(t)
tracks[#tracks + 1] = string.format("%d:%s", t, (code ~= "" and code ~= "unk") and code or "unnamed")
end
set("soundTracks", (#tracks > 0) and table.concat(tracks, " &nbsp; ") or "the disc has none")
end,
input = function(what)
if what == SWITCH_BUTTON1 then
soundStep = (soundStep % #soundNames) + 1
soundSetPan(SND_CLICK, soundPans[soundStep])
soundPlay(SND_CLICK)
set("soundWhich", soundNames[soundStep])
return true
elseif what == SWITCH_LEFT or what == SWITCH_RIGHT then
-- Walk the disc's audio tracks, which is how a multi language release is checked.
local count = discGetAudioTracks()
if count > 1 then
local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count
discSetAudioTrack(next)
set("soundWhich", string.format("disc track %d (%s)", next, discGetLanguage(next)))
end
return true
end
return false
end,
finish = function()
soundSetPan(SND_CLICK, 0.0)
end,
}
-- ===== 6. Display =====
-- The monitor, and the picture's place on it. Scale, shift and rotation are live, so the operator
-- sees what they are doing, and singeSaveGeometry keeps them in machine.cfg beside the audio delay
-- -- once for the cabinet rather than once for every game.
--
-- The test pattern: a 16 by 12 grid, which is square on a 4:3 monitor and on a 16:9 one when the
-- picture is letterboxed into it, and an eight step greyscale wedge a thirty-second of the width
-- per step.
local FULL_TURN = 360
local DISP_COLUMNS = 16
local DISP_ROWS = 12
local DISP_STEPS = 8
local DISP_STEP_WIDE = 32
local DISP_WEDGE = 16
local function displayShow()
local x, y = vldpGetShift()
set("dispValues", string.format("scale %d%% &nbsp; shift %d, %d &nbsp; rotation %d",
vldpGetScale(), x, y, vldpGetRotate()))
end
TOOLS[#TOOLS + 1] = {
id = "toolDisplay",
name = "Display",
help = "Size, position and rotation of the picture, and a test pattern.",
begin = function()
displayShow()
set("dispSaved", "")
end,
input = function(what)
local x, y = vldpGetShift()
if what == SWITCH_LEFT then
vldpSetShift(math.max(SINGE_SHIFT_MIN, x - 1), y)
elseif what == SWITCH_RIGHT then
vldpSetShift(math.min(SINGE_SHIFT_MAX, x + 1), y)
elseif what == SWITCH_UP then
vldpSetShift(x, math.max(SINGE_SHIFT_MIN, y - 1))
elseif what == SWITCH_DOWN then
vldpSetShift(x, math.min(SINGE_SHIFT_MAX, y + 1))
elseif what == SWITCH_BUTTON3 then
vldpSetScale(math.max(SINGE_SCALE_MIN, vldpGetScale() - 1))
elseif what == SWITCH_BUTTON4 then
vldpSetScale(math.min(SINGE_SCALE_MAX, vldpGetScale() + 1))
elseif what == SWITCH_COIN1 then
vldpSetRotate((vldpGetRotate() + SINGE_ROTATE_STEP) % FULL_TURN)
elseif what == SWITCH_START1 or what == SWITCH_START2 then
vldpSetScale(SINGE_SCALE_MAX)
vldpSetShift(0, 0)
vldpSetRotate(0)
elseif what == SWITCH_BUTTON1 then
singeSaveGeometry()
set("dispSaved", "kept for this machine")
return true
else
return false
end
displayShow()
set("dispSaved", "")
return true
end,
update = function()
-- A grid to square the picture up with, a bar of greys to set brightness by, and a frame
-- one pixel inside the edge: if any of the frame is missing, the monitor is overscanning.
local w, h = overlayGetWidth(), overlayGetHeight()
overlayClear()
colorForeground(60, 60, 60)
for gx = 0, w, math.floor(w / DISP_COLUMNS) do
overlayLine(gx, 0, gx, h)
end
for gy = 0, h, math.floor(h / DISP_ROWS) do
overlayLine(0, gy, w, gy)
end
-- The greyscale wedge. overlayBox draws an outline, so each step is filled by its own
-- columns; a monitor with its brightness wrong loses the dark end or the light one.
local step = math.floor(w / DISP_STEP_WIDE)
local first = math.floor((w - DISP_STEPS * step) / 2)
local top = math.floor(h / 2) - DISP_WEDGE
local bottom = math.floor(h / 2) + DISP_WEDGE
for i = 0, DISP_STEPS - 1 do
local level = math.floor(i * 255 / (DISP_STEPS - 1))
local left = first + i * step
colorForeground(level, level, level)
for x = left, left + step - 1 do
overlayLine(x, top, x, bottom)
end
end
-- Two frames, one inside the other: a monitor that overscans eats the outer one first.
colorForeground(255, 255, 255)
overlayBox(0, 0, w - 1, h - 1)
overlayBox(1, 1, w - 2, h - 2)
end,
finish = function()
overlayClear()
end,
}
-- ===== 7. Disc test =====
-- How long the disc takes to find a frame. A rip with its keyframes far apart seeks slowly, and
-- the only symptom in a game is that everything feels heavy; this measures it directly.
-- Twelve seeks spread over the disc, and a seek that has not landed in three seconds has failed
-- rather than been slow.
local DISC_SEEKS = 12
local DISC_SEEK_LIMIT = 3000
local discResults = {}
local discPending = nil
local discWorst = 0
TOOLS[#TOOLS + 1] = {
id = "toolDisc",
name = "Disc Test",
help = "Seek timing: how quickly the disc finds a frame.",
begin = function()
discResults = {}
discPending = nil
discWorst = 0
set("discRows", "<div class='row'>Button 1 runs the test.</div>")
end,
input = function(what)
if what == SWITCH_BUTTON1 and not discPending then
discResults = {}
discWorst = 0
discPending = { step = 0, started = 0 }
return true
end
return false
end,
update = function()
if not discPending then
return
end
if discPending.started == 0 then
-- Spread the targets over the whole disc rather than clustering, so a framefile's
-- segment changes are included in what is measured.
local total = discGetFrameCount()
discPending.step = discPending.step + 1
discPending.target = math.floor((discPending.step / DISC_SEEKS) * math.max(1, total - 1))
discPending.started = singeGetTicks()
discSearch(discPending.target)
return
end
if discGetFrame() == discPending.target or singeGetTicks() - discPending.started > DISC_SEEK_LIMIT then
local took = singeGetTicks() - discPending.started
discResults[#discResults + 1] = string.format("<div class='sub'>frame %d took %d ms</div>", discPending.target, took)
discWorst = math.max(discWorst, took)
discPending.started = 0
if discPending.step >= DISC_SEEKS then
discResults[#discResults + 1] = string.format("<div class='row'>worst <b>%d ms</b> over %d seeks</div>", discWorst, discPending.step)
discPending = nil
end
set("discRows", table.concat(discResults))
end
end,
finish = function()
discPending = nil
end,
}
-- ===== 8. Saved data =====
-- What each game has kept, and a way to throw it away. High scores are the usual reason: an
-- operator wants the board cleared without deleting the game. Each game saves into its own data
-- directory, so they are found by looking rather than by being told.
local saveDirs = {}
local saveSelected = 1
local function savedShow()
local rows = {}
if #saveDirs == 0 then
rows[#rows + 1] = "<div class='row'>No game has saved anything yet.</div>"
end
for i, entry in ipairs(saveDirs) do
local class = (i == saveSelected) and " class='selected'" or ""
rows[#rows + 1] = string.format("<div%s>%s &nbsp; <span class='sub'>%d bytes</span></div>", class, entry.name, entry.size)
end
set("savedRows", table.concat(rows))
end
TOOLS[#TOOLS + 1] = {
id = "toolSaved",
name = "Saved Data",
help = "What each game has kept, and clearing it.",
begin = function()
saveDirs = {}
saveSelected = 1
-- Every game's data directory is one below the root the engine was given.
-- lfs.dir hands back its iterator and the directory it walks; the iterator is useless
-- without it, so both are kept. pcall because the root need not exist yet.
local root = singeGetSystemInfo().dataRoot
local ok, iter, state = pcall(lfs.dir, root)
if ok then
for name in iter, state do
if name ~= "." and name ~= ".." then
local file = root .. name .. "/save.json"
local attr = lfs.attributes(file)
if attr and attr.mode == "file" then
saveDirs[#saveDirs + 1] = { name = name, path = file, size = attr.size }
end
end
end
end
table.sort(saveDirs, function(a, b) return a.name < b.name end)
savedShow()
end,
input = function(what)
if #saveDirs == 0 then
return false
end
if what == SWITCH_UP then
saveSelected = (saveSelected == 1) and #saveDirs or (saveSelected - 1)
savedShow()
return true
elseif what == SWITCH_DOWN then
saveSelected = (saveSelected == #saveDirs) and 1 or (saveSelected + 1)
savedShow()
return true
elseif what == SWITCH_BUTTON3 then
-- Button 3 rather than Button 1: Button 1 is "choose" everywhere else, and this
-- throws away somebody's high scores.
os.remove(saveDirs[saveSelected].path)
table.remove(saveDirs, saveSelected)
saveSelected = math.max(1, math.min(saveSelected, #saveDirs))
savedShow()
return true
end
return false
end,
}
-- ===== 9. MIDI =====
-- The ports this machine has, and whether anything is coming in or going out. Nothing is opened
-- until this tool asks, so a cabinet that never touches MIDI never pays for it.
-- Middle C on channel 1 at a plain mezzo-forte: loud enough to hear on any instrument, and on
-- the channel a synthesiser is most likely to have something assigned to.
local MIDI_TEST_CHANNEL = 1
local MIDI_TEST_NOTE = 60
local MIDI_TEST_VELOCITY = 100
local MIDI_SEEN_MAX = 8
local midiIn, midiOut = -1, -1
local midiSeen = {}
local midiDirty = false
local function midiShow()
local rows = {}
rows[#rows + 1] = string.format("<div class='row'>%d in, %d out</div>", midiInputCount(), midiOutputCount())
for i = 0, midiOutputCount() - 1 do
rows[#rows + 1] = string.format("<div class='sub'>out %d%s %s</div>", i, (i == midiOut) and " *" or "", midiOutputName(i))
end
for i = 0, midiInputCount() - 1 do
rows[#rows + 1] = string.format("<div class='sub'>in &nbsp;%d%s %s</div>", i, (i == midiIn) and " *" or "", midiInputName(i))
end
for _, m in ipairs(midiSeen) do
rows[#rows + 1] = string.format("<div class='sub'>received %s</div>", m)
end
set("midiRows", table.concat(rows))
end
function onMidiMessage(status, data1, data2, bytes)
midiSeen[#midiSeen + 1] = string.format("%02X %02X %02X", status, data1, data2)
while #midiSeen > MIDI_SEEN_MAX do
table.remove(midiSeen, 1)
end
midiDirty = true
end
TOOLS[#TOOLS + 1] = {
id = "toolMidi",
name = "MIDI Ports",
help = "List the ports, send a note, watch what arrives.",
begin = function()
midiSeen = {}
midiDirty = false
if midiOutputCount() > 0 and not midiIsOutputOpen() then
midiOut = midiOpenOutput(0) and 0 or -1
end
if midiInputCount() > 0 and not midiIsInputOpen() then
midiIn = midiOpenInput(0) and 0 or -1
end
midiShow()
end,
input = function(what)
if what == SWITCH_BUTTON1 and midiIsOutputOpen() then
midiNoteOn(MIDI_TEST_CHANNEL, MIDI_TEST_NOTE, MIDI_TEST_VELOCITY)
midiNoteOff(MIDI_TEST_CHANNEL, MIDI_TEST_NOTE)
return true
elseif what == SWITCH_BUTTON3 then
midiRescan()
midiShow()
return true
end
return false
end,
update = function()
if midiDirty then
midiDirty = false
midiShow()
end
end,
finish = function()
midiCloseInput()
midiCloseOutput()
midiIn, midiOut = -1, -1
end,
}
-- ===== 10. Frame statistics =====
TOOLS[#TOOLS + 1] = {
id = "toolStats",
name = "Frame Statistics",
help = "The developer's overlay: frame time, what is alive, what the disc is doing.",
begin = function()
set("statsState", statsIsEnabled() and "on" or "off")
end,
input = function(what)
if what == SWITCH_BUTTON1 then
statsEnable(not statsIsEnabled())
set("statsState", statsIsEnabled() and "on" or "off")
return true
end
return false
end,
}