1365 lines
40 KiB
Text
1365 lines
40 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 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,
|
|
}
|