--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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 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. -- -- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub, -- toolsKeys, toolsItem and toolsBlank constructors and hands it to toolsShow; the menu that loaded -- this file draws it whichever way it draws everything else. That is what lets the same ten tools -- run over the RmlUi document and over the plain overlay on a machine with no GPU. local lfs = require("lfs") TOOLS = {} TOOL_SELECTED = 1 -- The overlay renderer's line height, which the capacity above is measured in; the document one -- lays out a little tighter, so using this for both means it never shows fewer rows than it has -- room for. TOOL_LINE_HEIGHT = 24 TOOL_CHROME_LINES = 6 TOOL_OPEN = nil -- The tool being used, or nil while the list itself is showing TOOL_SHOWING = false -- Whether the tools have the screen at all -- What the menu draws. It redraws when TOOL_DIRTY is set and clears it; a tool that changes -- nothing costs nothing. TOOL_TITLE = "" TOOL_ROWS = {} TOOL_FLASH = false -- One frame of white, for the audio delay tool TOOL_CLEAR = false -- This page lets the overlay through: the tool draws its own picture TOOL_DIRTY = false -- ===== Rows ===== -- A row is a table with a kind the menu knows how to draw. Tools never build one by hand. function toolsText(text) return { kind = "text", text = text } end function toolsPair(label, value) return { kind = "pair", label = label, value = tostring(value) } end function toolsSub(text) return { kind = "sub", text = text } end function toolsKeys(text) return { kind = "keys", text = text } end function toolsItem(text, selected) return { kind = "item", text = text, selected = selected } end function toolsBlank() return { kind = "blank" } end -- What the page says now. Called as often as a tool likes; only the last one before the menu -- draws is seen. function toolsShow(rows) TOOL_ROWS = rows TOOL_DIRTY = true end -- How many rows of a page fit on screen. -- -- Derived from the overlay, which is the size both renderers draw into, so the two show the same -- thing rather than one of them quietly showing more. The subtraction is the title and the couple -- of lines of key hints that live below every list. function toolsCapacity() return math.max(4, math.floor(overlayGetHeight() / TOOL_LINE_HEIGHT) - TOOL_CHROME_LINES) end -- The slice of a page that is actually drawn, with the chosen row kept in view. -- -- Without this a page simply ran off the bottom: the catalogue is as long as the service is big, -- and a saved-data list is as long as the cabinet's library. Returns the rows to draw and whether -- there are more in either direction, so a renderer can say so. -- -- Only rows a person moves between are windowed. The key hints and the status line under a list are -- part of the page rather than part of the list, so they stay put instead of scrolling away. function toolsVisible(rows, capacity) local items = {} local chosen = nil for index, row in ipairs(rows) do if row.kind == "item" then items[#items + 1] = index if row.selected then chosen = #items end end end -- Nothing to scroll: a page with no list, or one whose list already fits. if #items == 0 or #rows <= capacity then return rows, false, false end -- Keep the chosen row in the middle of the window where there is room either side of it, so -- moving down does not pin it to the last line. local room = math.max(1, capacity - (#rows - #items)) local first = math.max(1, math.min((chosen or 1) - math.floor(room / 2), #items - room + 1)) local last = math.min(#items, first + room - 1) local out = {} for index, row in ipairs(rows) do if row.kind ~= "item" then out[#out + 1] = row elseif index >= items[first] and index <= items[last] then out[#out + 1] = row end end return out, first > 1, last < #items end -- The one frame of white the audio delay tool flashes. A menu that cannot invert its page can -- ignore it, and the tool still works by ear. function toolsFlash(on) if TOOL_FLASH ~= on then TOOL_FLASH = on TOOL_DIRTY = true end end -- ===== The list ===== function toolsBegin() TOOL_OPEN = nil TOOL_SHOWING = true toolsShowList() end function toolsEnd() if TOOL_OPEN and TOOL_OPEN.finish then TOOL_OPEN.finish() end if TOOL_OPEN and TOOL_OPEN.typed then keyboardSetMode(MODE_NORMAL) end TOOL_OPEN = nil TOOL_SHOWING = false TOOL_CLEAR = false TOOL_FLASH = false TOOL_DIRTY = true end function toolsShowList() local rows = {} TOOL_TITLE = "Service Tools" TOOL_CLEAR = false for i, tool in ipairs(TOOLS) do rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED) end rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsText(TOOLS[TOOL_SELECTED].help or "") rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: open Service: back to the games") toolsShow(rows) end function toolsOpen(index) TOOL_OPEN = TOOLS[index] TOOL_TITLE = TOOL_OPEN.name TOOL_CLEAR = TOOL_OPEN.clear or false -- A tool that takes typed text needs every key, including the ones controls.cfg has claimed for -- switches; MODE_FULL is how the engine is asked for them, and toolsClose puts it back. if TOOL_OPEN.typed then keyboardSetMode(MODE_FULL) end if TOOL_OPEN.begin then TOOL_OPEN.begin() end TOOL_DIRTY = true end function toolsClose() if TOOL_OPEN and TOOL_OPEN.finish then TOOL_OPEN.finish() end if TOOL_OPEN and TOOL_OPEN.typed then keyboardSetMode(MODE_NORMAL) end TOOL_OPEN = nil TOOL_FLASH = false toolsShowList() 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 -- A tool that wants typed text says so with a typed function. While one is open the keyboard is put -- into full mode so every key arrives as a character, and put back afterwards -- otherwise the -- engine's own switch mappings would swallow half the alphabet. function toolsTyped(keysym) if TOOL_OPEN and TOOL_OPEN.typed and keysym and keysym > 0 then if keysym == 8 then TOOL_OPEN.typed("\b") elseif keysym >= 32 and keysym < 127 then TOOL_OPEN.typed(string.char(keysym)) end end end function toolsWantsKeys() return TOOL_OPEN ~= nil and TOOL_OPEN.typed ~= nil end -- Whether the tools have the screen, which is what the menu asks before doing anything of its own. function toolsActive() return TOOL_SHOWING 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() toolsShow({ toolsText("Adjust until the flash and the click happen together."), toolsBlank(), toolsPair("Delay", calValue .. " ms"), toolsPair("Device queue", singeGetAudioLatency() .. " ms"), toolsKeys("Left / Right: 10 ms Up / Down: 1 ms Start: reset to 0"), toolsKeys("Button 1: save Button 2: back"), }) end TOOLS[#TOOLS + 1] = { 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 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 toolsFlash(true) elseif calFlashDone then toolsFlash(false) end end, finish = function() toolsFlash(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] = { 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] = toolsPair("Last switch", string.format("%s count: %d", inputLast, inputCount)) rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s", 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] = toolsText(string.format("%d: %s", slot, controllerGetName(slot))) rows[#rows + 1] = toolsSub(string.format("axes %s buttons %s", 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] = toolsSub(string.format("mouse %d: %s at %d, %d", m, mouseGetName(m), x, y)) end rows[#rows + 1] = toolsKeys("Press anything. Button 2: back") toolsShow(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] = { name = "System Information", help = "What this machine is and what the engine chose to run on.", begin = function() local i = singeGetSystemInfo() toolsShow({ toolsPair("Singe", i.version), toolsPair("System", i.os), toolsPair("Processor", i.cpu), toolsPair("Renderer", i.renderer), toolsPair("3D device", i.gpu), toolsPair("Video", i.decoder), toolsPair("Audio", i.audio), toolsPair("SoundFont", i.soundFont), toolsPair("MIDI", i.midi), toolsPair("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d", i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight())), toolsPair("Data", i.dataPath), toolsKeys("Button 2: back"), }) 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" local function gunShow() toolsShow({ toolsPair("Last shot", string.format("%s shots shown: %d", gunLast, #gunShots)), toolsKeys("Button 1: fire Start: clear Button 2: back"), }) end TOOLS[#TOOLS + 1] = { name = "Light Gun", help = "Aim and fire: see where the shot actually lands.", clear = true, begin = function() gunShots = {} gunLast = "none yet" gunShow() 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) gunShow() return true elseif what == SWITCH_START1 or what == SWITCH_START2 then gunShots = {} gunShow() 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 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 } local soundWhich = "" local soundList = "" local function soundShow() toolsShow({ toolsPair("Playing", soundWhich), toolsPair("Disc tracks", soundList), toolsKeys("Button 1: left, right, both Left / Right: disc track Button 2: back"), }) end TOOLS[#TOOLS + 1] = { name = "Sound Test", help = "Left, right, both, and the disc's own audio tracks.", begin = function() local tracks = {} soundStep = 0 soundWhich = "press Button 1" 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 soundList = (#tracks > 0) and table.concat(tracks, " ") or "the disc has none" soundShow() end, input = function(what) if what == SWITCH_BUTTON1 then soundStep = (soundStep % #soundNames) + 1 soundSetPan(SND_CLICK, soundPans[soundStep]) soundPlay(SND_CLICK) soundWhich = soundNames[soundStep] soundShow() return true elseif what == SWITCH_LEFT or what == SWITCH_RIGHT then local count = discGetAudioTracks() if count > 1 then local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count discSetAudioTrack(next) soundWhich = string.format("disc track %d (%s)", next, discGetLanguage(next)) soundShow() 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 dispSaved = "" local function displayShow() local x, y = vldpGetShift() toolsShow({ toolsPair("Picture", string.format("scale %d%% shift %d, %d rotation %d", vldpGetScale(), x, y, vldpGetRotate())), toolsPair("Kept", dispSaved), toolsKeys("Arrows: move Button 3 / 4: size Coin 1: rotate Start: reset"), toolsKeys("Button 1: keep for this machine Button 2: back"), }) end TOOLS[#TOOLS + 1] = { name = "Display", help = "Size, position and rotation of the picture, and a test pattern.", clear = true, begin = function() dispSaved = "not yet" displayShow() 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() dispSaved = "kept for this machine" displayShow() return true else return false end dispSaved = "not yet" displayShow() 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 local function discShow(note) local rows = {} if note ~= nil then rows[#rows + 1] = toolsText(note) end for _, line in ipairs(discResults) do rows[#rows + 1] = line end rows[#rows + 1] = toolsKeys("Button 1: run Button 2: back") toolsShow(rows) end TOOLS[#TOOLS + 1] = { name = "Disc Test", help = "Seek timing: how quickly the disc finds a frame.", begin = function() discResults = {} discPending = nil discWorst = 0 discShow("Button 1 runs the test.") end, input = function(what) if what == SWITCH_BUTTON1 and not discPending then discResults = {} discWorst = 0 discPending = { step = 0, started = 0 } discShow("Running.") 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] = toolsSub(string.format("frame %d took %d ms", discPending.target, took)) discWorst = math.max(discWorst, took) discPending.started = 0 if discPending.step >= DISC_SEEKS then discResults[#discResults + 1] = toolsText(string.format("worst %d ms over %d seeks", discWorst, discPending.step)) discPending = nil discShow(nil) else discShow("Running.") end 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] = toolsText("No game has saved anything yet.") end for i, entry in ipairs(saveDirs) do rows[#rows + 1] = toolsItem(string.format("%s %d bytes", entry.name, entry.size), i == saveSelected) end rows[#rows + 1] = toolsKeys("Up / Down: choose Button 3: delete this game's save Button 2: back") toolsShow(rows) end TOOLS[#TOOLS + 1] = { name = "Saved Data", help = "What each game has kept, and clearing it.", begin = function() saveDirs = {} saveSelected = 1 -- 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] = toolsPair("Ports", string.format("%d in, %d out", midiInputCount(), midiOutputCount())) for i = 0, midiOutputCount() - 1 do rows[#rows + 1] = toolsSub(string.format("out %d%s %s", i, (i == midiOut) and " *" or "", midiOutputName(i))) end for i = 0, midiInputCount() - 1 do rows[#rows + 1] = toolsSub(string.format("in %d%s %s", i, (i == midiIn) and " *" or "", midiInputName(i))) end for _, m in ipairs(midiSeen) do rows[#rows + 1] = toolsSub("received " .. m) end rows[#rows + 1] = toolsKeys("Button 1: send a note Button 3: look again Button 2: back") toolsShow(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] = { 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 ===== local function statsShow() toolsShow({ toolsPair("Overlay", statsIsEnabled() and "on" or "off"), toolsText("It stays on while you play, in the top left corner."), toolsKeys("Button 1: turn it on or off Button 2: back"), }) end TOOLS[#TOOLS + 1] = { name = "Frame Statistics", help = "The developer's overlay: frame time, what is alive, what the disc is doing.", begin = statsShow, input = function(what) if what == SWITCH_BUTTON1 then statsEnable(not statsIsEnabled()) statsShow() return true end return false end, } -- ===== 11. Online account ===== -- Signing this cabinet in to the master service, and saying where that service is. It belongs -- behind SERVICE because it is set up once for the machine, not once per play: the token it keeps -- is what every game uses to post a score. -- -- Typing on a cabinet is miserable, so the field being edited takes whatever the keyboard sends and -- the arrows move between fields; a machine with only a joystick can still change the server -- address, which is the one thing an operator running their own server must be able to do. local ACCOUNT_FIELDS = { "email", "password", "server" } local accountField = 1 local accountValues = { email = "", password = "", server = "" } local accountSaying = "" local accountBusy = false local function accountShow() local rows = {} if masterSignedIn() then rows[#rows + 1] = toolsPair("Signed in", MASTER.email or "yes") rows[#rows + 1] = toolsPair("Player name", MASTER.name or "not set") rows[#rows + 1] = toolsPair("Server", MASTER.url) rows[#rows + 1] = toolsPair("Queued scores", masterQueueLength()) rows[#rows + 1] = toolsText(accountSaying) rows[#rows + 1] = toolsKeys("Button 3: sign out Button 2: back") else for index, name in ipairs(ACCOUNT_FIELDS) do local shown = accountValues[name] if name == "password" then shown = string.rep("*", #shown) end if name == "server" and shown == "" then shown = MASTER.url end rows[#rows + 1] = toolsItem(string.format("%-9s %s", name, shown), index == accountField) end rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsText(accountSaying) rows[#rows + 1] = toolsKeys("Up / Down: field type to edit Backspace: rub out") rows[#rows + 1] = toolsKeys("Button 1: sign in Button 3: create account Coin 1: forgot password") rows[#rows + 1] = toolsKeys("Button 2: back") end toolsShow(rows) end -- One reply handler for all four calls: they differ only in what they say afterwards. local function accountReply(success) return function(ok, reply) accountBusy = false accountSaying = ok and success or ("Sorry: " .. tostring(reply)) accountShow() end end local function accountApplyServer() local typed = accountValues.server if typed ~= "" then MASTER.url = (typed:find("://") and typed or ("https://" .. typed)):gsub("/+$", "") masterSave() end end TOOLS[#TOOLS + 1] = { name = "Online Account", help = "Sign this cabinet in to the master service.", begin = function() accountField = 1 accountSaying = masterSignedIn() and "" or "Not signed in." accountValues.password = "" accountValues.server = MASTER.url accountShow() end, input = function(what) if masterSignedIn() then if what == SWITCH_BUTTON3 then masterSignOut(function() end) accountSaying = "Signed out." accountShow() return true end return false end if accountBusy then return what ~= SWITCH_BUTTON2 end if what == SWITCH_UP then accountField = (accountField == 1) and #ACCOUNT_FIELDS or (accountField - 1) elseif what == SWITCH_DOWN then accountField = (accountField == #ACCOUNT_FIELDS) and 1 or (accountField + 1) elseif what == SWITCH_BUTTON1 then accountApplyServer() accountBusy = true accountSaying = "Signing in..." masterSignIn(accountValues.email, accountValues.password, accountReply("Signed in.")) elseif what == SWITCH_BUTTON3 then accountApplyServer() accountBusy = true accountSaying = "Creating..." masterRegister(accountValues.email, accountValues.password, accountReply("Check your email for the link, then sign in.")) elseif what == SWITCH_COIN1 then accountApplyServer() accountBusy = true accountSaying = "Asking..." masterRecover(accountValues.email, accountReply("If that address has an account, a link is on its way.")) else return false end accountShow() return true end, -- Typed characters are not switches, so they arrive here rather than through input. typed = function(text) if masterSignedIn() or accountBusy then return end local name = ACCOUNT_FIELDS[accountField] if text == "\b" then accountValues[name] = accountValues[name]:sub(1, -2) else accountValues[name] = accountValues[name] .. text end accountShow() end, update = function() -- The page shows a queue length and a busy state, both of which change without a keypress. if accountBusy or masterQueueLength() > 0 then accountShow() end end, } -- ===== 12. Get games ===== -- The catalogue: everything the service offers, what is installed here, and downloading, updating -- or removing it. This is the one page in the tools a *player* wants rather than an operator, but -- it lives here because it needs the account the tool beside it establishes, and because a cabinet -- that is mid-download should not also be trying to start a game. -- -- A download goes to a temporary name and is moved into place only once its checksum matches what -- the catalogue published. A file that arrives wrong is worse than one that fails: it installs, -- and then does not run. local shopGames = {} local shopSelected = 1 local shopSaying = "Press Button 3 to fetch the list." local shopBusy = false local shopProgress = nil local function shopLocalName(game) return game.slug .. ".game" end local function shopInstalledVersion(game) -- What is on this machine, which is not necessarily what the service was last told. if not lfs.attributes(shopLocalName(game)) then return nil end return game.installed or 0 end local function shopShow() local rows = {} if not masterSignedIn() then toolsShow({ toolsText("Sign in first, in the Online Account tool."), toolsKeys("Button 2: back") }) return end for index, game in ipairs(shopGames) do local held = shopInstalledVersion(game) local state = "not installed" if game.withdrawn then -- A game the service no longer offers. It keeps working: a player who has it has it, -- and all that stops is being offered an update. It stays on the list so it can still -- be removed from here rather than only by deleting a file by hand. state = "installed, no longer offered" elseif held ~= nil then state = (held >= game.version) and "installed" or ("update to " .. game.version) end rows[#rows + 1] = toolsItem(string.format("%-28s %s", game.title, state), index == shopSelected) end if #shopGames == 0 then rows[#rows + 1] = toolsText("Nothing listed yet.") end rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsText(shopProgress or shopSaying) rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: download or update Button 3: refresh") rows[#rows + 1] = toolsKeys("Button 4: remove Button 2: back") toolsShow(rows) end -- A .game installed here that the catalogue no longer lists. Without this it would vanish from -- the page the moment it was withdrawn, leaving a player with a game they can play, cannot update, -- and cannot remove from anywhere but a file manager. local function shopAddWithdrawn() local listed = {} for _, game in ipairs(shopGames) do listed[shopLocalName(game)] = true end for file in lfs.dir(".") do if file:sub(-5):lower() == ".game" and not listed[file] then shopGames[#shopGames + 1] = { id = nil, slug = file:sub(1, -6), title = file:sub(1, -6), withdrawn = true, } end end end local function shopRefresh() shopBusy = true shopSaying = "Asking the service..." shopShow() masterCatalogue(function(ok, result) shopBusy = false if ok then shopGames = result shopAddWithdrawn() shopSelected = math.min(shopSelected, math.max(#shopGames, 1)) shopSaying = #shopGames .. " games listed." else shopSaying = "Sorry: " .. tostring(result) end shopShow() end) end local function shopDownload() local game = shopGames[shopSelected] if not game then return end if game.withdrawn then shopSaying = game.title .. " is not offered by the service any more. It still works." shopShow() return end local target = shopLocalName(game) local partial = target .. ".part" shopBusy = true shopProgress = "Starting..." shopShow() masterDownload(game.id, game.version, partial, game.sha256, function(got, total) shopProgress = total and string.format("%s: %d%% (%d of %d bytes)", game.title, math.floor(got * 100 / total), got, total) or string.format("%s: %d bytes", game.title, got) end, function(ok, result) shopBusy = false shopProgress = nil if not ok then shopSaying = "Sorry: " .. tostring(result) else -- Only now does the old copy get replaced, so a failed download never destroys a -- working install. os.remove(target) if os.rename(partial, target) then game.installed = game.version shopSaying = game.title .. " is ready. " .. result.bytes .. " bytes." masterTellInstalled(game.id, game.version) else os.remove(partial) shopSaying = "Downloaded, but could not put it in place." end end shopShow() end) end local function shopRemove() local game = shopGames[shopSelected] if not game then return end if not lfs.attributes(shopLocalName(game)) then shopSaying = "That one is not installed here." else os.remove(shopLocalName(game)) game.installed = nil shopSaying = game.title .. " removed. Its saved data is kept." if game.id then masterTellInstalled(game.id, nil) end end shopShow() end TOOLS[#TOOLS + 1] = { name = "Get Games", help = "Everything the service offers: download, update, remove.", begin = function() shopProgress = nil if masterSignedIn() and #shopGames == 0 then shopRefresh() else shopSaying = masterSignedIn() and "Button 3 refreshes the list." or "" shopShow() end end, input = function(what) if not masterSignedIn() or shopBusy then -- A download in flight keeps the page; only backing out is allowed, and that leaves it -- running rather than corrupting a half-written file. return what ~= SWITCH_BUTTON2 end if what == SWITCH_UP then shopSelected = (shopSelected <= 1) and math.max(#shopGames, 1) or (shopSelected - 1) elseif what == SWITCH_DOWN then shopSelected = (shopSelected >= #shopGames) and 1 or (shopSelected + 1) elseif what == SWITCH_BUTTON1 then shopDownload() return true elseif what == SWITCH_BUTTON3 then shopRefresh() return true elseif what == SWITCH_BUTTON4 then shopRemove() return true else return false end shopShow() return true end, update = function() if shopBusy then shopShow() end end, }