diff --git a/CHANGELOG b/CHANGELOG index 77a941f93..c75bf0c36 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -862,6 +862,60 @@ API Changes things that must not pass through each other -- which is what the collide calls deliberately do not do. +- Player handles. The name on a leaderboard is a handle, not an email + address: three to twenty characters of letters, digits, hyphens and + underscores, chosen once. An operator can rename somebody; a player + cannot, and an email address cannot be changed at all. Handles are + checked against a list the operator maintains -- identities nobody + should claim, and words with no place on a screen in a public room. + +- Online: accounts, a game catalogue, and high scores. A cabinet can sign + in to a master service (master.singeengine.com by default), browse every + game the service offers, download and update them, and post scores to + leaderboards. Two new service tools cover the setup: Online Account + signs in, creates an account, recovers a password and sets the server + address; Get Games lists the catalogue, marks what is installed here, + and downloads, updates or removes it. + + A game posts a score in one line -- scoreSubmit(points) -- plus + scoreBoard, scorePlayerName, scoreWaiting and scoreUpdate. The account + belongs to the machine rather than the game, so a game uses whatever + sign-in the menu made. A score is queued to disk and sent when there is + a connection: a cabinet is often offline, and a game must never stall + on the wire to show its own board. + + Nothing blocks a frame. Singe/Net.singe is an asynchronous HTTP client + driven from the frame loop, because Lua here is cooperative and + ssl.https.request would stutter a JSON call and make a large download + look like a hang. It does not use LuaSec's https module at all: that + ships with verify = "none", which encrypts the connection and + authenticates nobody. A connection is verified against a pinned public + key instead. + + A download is checked against the digest the catalogue published before + it replaces anything, so a transfer that arrives wrong fails rather than + installing a game that will not run. Downloads resume. + + New engine calls: utilSha256(data) for exactly that checking, and + singeGetGameId() for the GAME_ID a games.dat entry can now carry, which + is what a leaderboard is kept under. A game without one can be played + but not ranked. + + A game the service withdraws keeps working on the cabinets that have it. + All that stops is being offered an update, and the menu shows it as + "installed, no longer offered" so it can still be removed from there. + + scoreBegin() tells the service a play is starting, and the score that + follows carries how long it took measured on the server's own clock. + That is the one number in a submission that has not been through the + player's machine. Nothing is rejected on it -- it is shown to the + operator beside the score, and the judgement stays a person's. + + A submitted score is a claim and the design says so: the cabinet is the + player's own machine. The service records who claimed what, rate limits + submissions, and flags outliers for a person rather than pretending to + verify them. + - One menu, two renderers. Singe/MenuClassic.singe is gone: it was the whole menu written a second time, and the two copies duplicated their game list, their selection, their menu.dat handling and their games.dat diff --git a/CMakeLists.txt b/CMakeLists.txt index 42bfac46a..c71e75aea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,6 +190,8 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "") diff --git a/assets/Framework.singe b/assets/Framework.singe index a9385c9c2..3c04fa1b3 100644 --- a/assets/Framework.singe +++ b/assets/Framework.singe @@ -611,3 +611,94 @@ if singeMain ~= nil then end + + +-- ===== Online high scores ===== +-- +-- A thin layer over Singe/Master.singe so a game says scoreSubmit(1000) and nothing else. The +-- account, the server address and the player's name belong to the machine, not to the game, and +-- were set up once in the menu's Online Account tool. +-- +-- Nothing here waits on the network. A score is written to a queue on disk and sent when there is +-- a connection, because a cabinet is often offline and a game must never stall on the wire to show +-- its own board. scoreUpdate() has to run once a frame for any of it to progress. + +local masterLoaded = false + + +local function scoreReady() + if not masterLoaded then + -- Loaded on first use rather than at startup: a game that never posts a score should not + -- pay for the module or open the file. + dofile("Singe/Master.singe") + masterLoaded = true + end + return masterSignedIn() +end + + +-- The id a game is known by, from its games.dat entry. A game without one can be played but not +-- ranked: there is nothing stable to key a board on, and inventing one here would make a different +-- board every time the file moved. +function scoreGameId() + return singeGetGameId() +end + + +-- Say that a play is starting. Optional, and worth calling: the service then times the play by its +-- own clock, which is the one thing in a submission that has not been through this machine. A +-- cabinet with no connection simply records no time. +function scoreBegin() + local id = scoreGameId() + + if not id or not scoreReady() then + return false + end + masterPlayStart(id, function() end) + return true +end + + +-- Queue a score. Returns false when the cabinet has no account or the game has no id, so a game +-- can decide whether to say anything about it. +function scoreSubmit(value, board, meta) + local id = scoreGameId() + + if not id or not scoreReady() then + return false + end + masterSubmitScore(id, value, board, meta) + return true +end + + +-- The board, as a callback: onDone(ok, result) where result.top is the list and result.standing is +-- this player's place. +function scoreBoard(onDone, board) + local id = scoreGameId() + + if not id or not scoreReady() then + onDone(false, "this cabinet is not signed in") + return false + end + masterBoard(id, board, onDone) + return true +end + + +function scorePlayerName() + return scoreReady() and MASTER.name or nil +end + + +function scoreWaiting() + return masterLoaded and masterQueueLength() or 0 +end + + +-- Once a frame, from onOverlayUpdate. Safe to call in a game that never posts anything. +function scoreUpdate() + if masterLoaded then + masterPump() + end +end diff --git a/assets/Master.singe b/assets/Master.singe new file mode 100644 index 000000000..fe74465cd --- /dev/null +++ b/assets/Master.singe @@ -0,0 +1,349 @@ +--[[ + * + * 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 master service, from the cabinet's side. +-- +-- One place that knows the server address, the signed-in account and the player's name, so the menu +-- and every game agree about them. It lives in the data root rather than a game's own directory +-- because an account belongs to the machine: a game posting a score needs the same token the menu +-- signed in with. +-- +-- Everything here is asynchronous -- see Singe/Net.singe for why -- so every call takes a function +-- and calls it back. masterPump() must run once a frame for any of it to make progress. + + +dofile("Singe/Net.singe") + +local cjson = require("cjson") + +MASTER_DEFAULT_URL = "https://master.singeengine.com" + +MASTER = { + url = MASTER_DEFAULT_URL, + token = nil, + email = nil, + name = nil, + queue = {}, -- scores waiting for a connection +} + +local settingsPath = nil +local flushing = false +local lastFlush = 0 +local flushDue = false +local FLUSH_EVERY = 30000 + + +local function path() + if not settingsPath then + settingsPath = singeGetSystemInfo().dataRoot .. "master.dat" + end + return settingsPath +end + + +function masterLoad() + local file = io.open(path(), "r") + + if not file then + return + end + local text = file:read("*a") + file:close() + local ok, stored = pcall(cjson.decode, text) + if ok and type(stored) == "table" then + MASTER.url = stored.url or MASTER_DEFAULT_URL + MASTER.token = stored.token + MASTER.email = stored.email + MASTER.name = stored.name + MASTER.queue = stored.queue or {} + end +end + + +function masterSave() + local file = io.open(path(), "w") + + if not file then + return false + end + file:write(cjson.encode({ url = MASTER.url, token = MASTER.token, email = MASTER.email, + name = MASTER.name, queue = MASTER.queue })) + file:close() + return true +end + + +function masterSignedIn() + return MASTER.token ~= nil +end + + +-- Every call goes through here so the token, the content type and the JSON decoding are in one +-- place, and so a 401 always means the same thing: whatever we were holding is no longer a +-- session, and pretending otherwise only produces a second failure later. +local function request(method, endpoint, body, onDone, opts) + opts = opts or {} + local headers = { ["Content-Type"] = "application/json" } + + if MASTER.token and not opts.anonymous then + headers["Authorization"] = "Bearer " .. MASTER.token + end + netRequest({ + url = MASTER.url .. endpoint, method = method, headers = headers, + body = body and cjson.encode(body) or nil, + toFile = opts.toFile, onProgress = opts.onProgress, + }, function(ok, result) + if not ok then + return onDone(false, result.error) + end + local parsed = {} + if result.body and result.body ~= "" then + local decoded, value = pcall(cjson.decode, result.body) + if decoded and type(value) == "table" then + parsed = value + end + end + if result.status == 401 and not opts.anonymous then + MASTER.token = nil + masterSave() + return onDone(false, "signed out by the server", parsed, result) + end + if result.status >= 400 then + return onDone(false, parsed.message or ("the server said " .. result.status), parsed, result) + end + onDone(true, parsed, parsed, result) + end) +end + + +MASTER_REQUEST = request + + +function masterSignIn(email, password, onDone) + request("POST", "/v1/account/login", { email = email, password = password }, function(ok, reply) + if not ok then + return onDone(false, reply) + end + MASTER.token = reply.token + MASTER.email = reply.email + masterSave() + -- The display name is not part of signing in, so it is fetched once and kept: a game + -- about to post a score should not have to discover it has none mid-play. + masterWhoAmI(function() end) + onDone(true, reply) + end, { anonymous = true }) +end + + +function masterRegister(email, password, onDone) + request("POST", "/v1/account/register", { email = email, password = password }, onDone, { anonymous = true }) +end + + +function masterRecover(email, onDone) + request("POST", "/v1/account/recover", { email = email }, onDone, { anonymous = true }) +end + + +function masterSignOut(onDone) + local had = MASTER.token + + MASTER.token = nil + MASTER.email = nil + MASTER.name = nil + masterSave() + if had then + -- Told after the fact: the cabinet is signed out either way, and a server that cannot be + -- reached must not leave somebody looking signed in. + MASTER.token = had + request("POST", "/v1/account/logout", nil, function() end) + MASTER.token = nil + end + if onDone then onDone(true) end +end + + +function masterWhoAmI(onDone) + request("GET", "/v1/account", nil, function(ok, reply) + if ok then + MASTER.email = reply.email or MASTER.email + masterSave() + end + onDone(ok, reply) + end) +end + + +function masterSetName(name, onDone) + request("POST", "/v1/player/name", { name = name }, function(ok, reply) + if ok then + MASTER.name = reply.name + masterSave() + end + onDone(ok, reply) + end) +end + + +function masterCatalogue(onDone) + request("GET", "/v1/catalogue", nil, function(ok, reply) + if ok then + for _, game in ipairs(reply.games or {}) do + game.version = game.version and math.floor(game.version) or nil + game.installed = game.installed and math.floor(game.installed) or nil + game.size = game.size and math.floor(game.size) or nil + end + end + onDone(ok, ok and reply.games or reply) + end) +end + + +-- Download a game to a file and check it against the digest the catalogue published. A download +-- that arrives wrong is worse than one that fails: it installs, and then does not run. +function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone) + -- JSON has one number type and Lua 5.4 has two, so a version that went out as 1 comes back as + -- 1.0 and would build a path ending "/1.0" that the service does not recognise. + local path = string.format("/v1/download/%s/%d", gameId, math.floor(version)) + + request("GET", path, nil, function(ok, reply, _, result) + if not ok then + return onDone(false, reply) + end + local file = io.open(toFile, "rb") + if not file then + return onDone(false, "the download did not land on disk") + end + local bytes = file:read("*a") + file:close() + if expectSha and utilSha256(bytes) ~= expectSha then + os.remove(toFile) + return onDone(false, "the download did not match its published checksum") + end + onDone(true, { bytes = #bytes, file = toFile }) + end, { toFile = toFile, onProgress = onProgress }) +end + + +function masterTellInstalled(gameId, version, onDone) + request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil }, + onDone or function() end) +end + + +function masterBoard(gameId, board, onDone) + request("GET", "/v1/scores/" .. gameId .. (board and ("?board=" .. board) or ""), nil, function(ok, reply) + -- JSON has one number type and Lua 5.4 has two, so a score that went out as 4242 comes back + -- as 4242.0 and would be shown to a player with a decimal point on it. + if ok then + for _, row in ipairs(reply.top or {}) do + row.value = math.floor(row.value) + row.at = row.at and math.floor(row.at) or nil + end + if reply.standing then + reply.standing.value = math.floor(reply.standing.value) + reply.standing.rank = math.floor(reply.standing.rank) + reply.standing.players = math.floor(reply.standing.players) + end + end + onDone(ok, reply) + end) +end + + +-- A score is queued first and sent afterwards. A cabinet is often offline, and a game must never +-- wait on the wire to show its own board; the queue is written to disk so a power cut between the +-- play and the send does not lose it. +-- Tell the service a play is starting. What comes back travels with the score, so the server can +-- say how long the play took by its own clock; a cabinet that is offline simply has no play id and +-- the score carries no time, which is honest rather than broken. +function masterPlayStart(gameId, onDone) + if not masterSignedIn() then + MASTER.play = nil + if onDone then onDone(false, "not signed in") end + return + end + request("POST", "/v1/play/start", { game = gameId }, function(ok, reply) + MASTER.play = ok and reply.play and math.floor(reply.play) or nil + if onDone then onDone(ok, reply) end + end) +end + + +function masterSubmitScore(gameId, value, board, meta) + MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value), + board = board or "default", meta = meta, + play = MASTER.play } + masterSave() + -- Send it on the next pump rather than waiting out the retry interval: a player who has just + -- finished wants to see themselves on the board, and the interval exists for retrying what + -- failed, not for delaying what has not been tried. This is a flag rather than a timestamp: + -- singeGetTicks() counts from engine start, so "lastFlush = 0" would mean "once the engine has + -- been up FLUSH_EVERY", which early in a run is never. + flushDue = true +end + + +function masterQueueLength() + return #MASTER.queue +end + + +-- Hand the queue to the server one at a time. A refusal that is the score's own fault (the game is +-- unknown, the value is nonsense) drops it, because retrying it forever would block everything +-- behind it; anything else leaves it in place to try again. +local function flushQueue() + if flushing or #MASTER.queue == 0 or not MASTER.token then + return + end + flushing = true + local entry = MASTER.queue[1] + request("POST", "/v1/scores/" .. entry.game, { value = entry.value, board = entry.board, meta = entry.meta, play = entry.play }, + function(ok, reply, parsed, result) + local status = result and result.status or 0 + if ok or (status >= 400 and status < 500 and status ~= 429) then + table.remove(MASTER.queue, 1) + masterSave() + -- A backlog should drain, not trickle out one per interval. + if #MASTER.queue > 0 then + flushDue = true + end + end + flushing = false + end) +end + + +function masterPump() + netPump() + if flushDue or (singeGetTicks() - lastFlush > FLUSH_EVERY) then + flushDue = false + lastFlush = singeGetTicks() + flushQueue() + end +end + + +masterLoad() diff --git a/assets/Menu.singe b/assets/Menu.singe index ec807ab98..66ecc6d6f 100644 --- a/assets/Menu.singe +++ b/assets/Menu.singe @@ -36,6 +36,7 @@ -- see the top of either file for what is in it. dofile("Singe/Framework.singe") +dofile("Singe/Master.singe") dofile("Singe/Tools.singe") local lfs = require("lfs") @@ -96,6 +97,15 @@ function menuActive() end +-- In MODE_FULL every key arrives here as its keysym rather than as a switch, which is what the +-- account tool's typing needs; the switches it still cares about are the pad's, which keep working. +function onKeyPressed(keysym, scancode) + if toolsActive() then + toolsTyped(keysym) + end +end + + function onInputPressed(what) -- The service tools take every switch while they are up, including the one that closes them. @@ -149,6 +159,9 @@ function onOverlayUpdate() colorBackground(0, 0, 0, 0) overlayClear() + -- The network never blocks the frame; it makes progress here instead. + masterPump() + if toolsActive() then toolsUpdate() MENU_RENDER.tools() diff --git a/assets/MenuDocument.singe b/assets/MenuDocument.singe index 6be325d2f..b4408b2c1 100644 --- a/assets/MenuDocument.singe +++ b/assets/MenuDocument.singe @@ -287,9 +287,28 @@ MENU_RENDER.tools = function() -- The tools redraw only when they say something changed; the document keeps what it was given. if TOOL_DIRTY then TOOL_DIRTY = false - for _, row in ipairs(TOOL_ROWS) do + -- The same window the overlay renderer draws, so the two show the same page rather than one + -- of them quietly showing more. The style sheet can scroll this box, but nothing drives a + -- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen. + local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) + if more then + rows[#rows + 1] = "
...
" + end + local seenItem = false + for _, row in ipairs(visible) do + -- The marker belongs under the last item rather than under the key hints below it. + if below and seenItem and row.kind ~= "item" then + rows[#rows + 1] = "
...
" + below = false + end + if row.kind == "item" then + seenItem = true + end rows[#rows + 1] = toolRow(row) end + if below then + rows[#rows + 1] = "
...
" + end guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE)) guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows)) menuElement("games"):SetClass("hidden", toolsActive()) diff --git a/assets/MenuOverlay.singe b/assets/MenuOverlay.singe index 27495aecd..f35d3bd3f 100644 --- a/assets/MenuOverlay.singe +++ b/assets/MenuOverlay.singe @@ -118,6 +118,11 @@ end -- Text too long for the page wraps under itself rather than running off the edge. local function toolWrapped(text, x, role) + if text == nil or text == "" then + -- Still a line: a tool that says nothing this frame should not have its page jump about. + toolLine({}) + return + end for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do toolLine({ { x = x, text = line, role = role } }) end @@ -127,9 +132,25 @@ end local function toolsLayout() local valueX = MARGIN_X + LABEL_W local wasKeys = false + -- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the + -- service is big and would otherwise run off the bottom of the screen. + local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) toolLines = {} - for _, row in ipairs(TOOL_ROWS) do + if more then + toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + end + local seenItem = false + for _, row in ipairs(rows) do + -- The "more below" marker belongs immediately under the last item, not at the very bottom + -- of the page under the key hints, which are not part of the list. + if below and seenItem and row.kind ~= "item" then + toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + below = false + end + if row.kind == "item" then + seenItem = true + end -- The key hints stand away from whatever the tool put above them, as they do in the -- document, but sit together when a tool needs more than one line of them. if row.kind == "keys" and not wasKeys then @@ -159,6 +180,10 @@ local function toolsLayout() toolWrapped(row.text, MARGIN_X, "ink") end end + -- A list that runs to the very end of the page has no row after it to hang the marker on. + if below then + toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) + end end @@ -342,7 +367,11 @@ MENU_RENDER.tools = function() else colorForeground(ink, ink, ink, 255) end - fontPrint(piece.x, y, piece.text) + -- fontPrint refuses an empty string, and a tool legitimately produces one: a status + -- line with nothing to say yet is still a line, and it still takes up its height. + if piece.text ~= "" then + fontPrint(piece.x, y, piece.text) + end end y = y + LINE_HEIGHT end diff --git a/assets/Net.singe b/assets/Net.singe new file mode 100644 index 000000000..a6aca2fb0 --- /dev/null +++ b/assets/Net.singe @@ -0,0 +1,392 @@ +--[[ + * + * 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. + * + * +--]] + + + +-- Talking to the master service without stopping the game. +-- +-- Lua in Singe is not threaded, and ssl.https.request blocks until it finishes: a JSON call would +-- stutter the frame and a several-hundred-megabyte download would look like a hang. So every +-- request here is a state machine driven a slice at a time from netPump(), which the menu calls +-- once a frame. A caller hands in a function and gets called back when the answer arrives. +-- +-- On trust. LuaSec's own https.lua ships with verify = "none", which encrypts the connection and +-- authenticates nobody -- anything on the path could read the password going up, the token coming +-- back, and alter the game being downloaded. Nothing here uses that module. A connection is +-- verified against a pinned public key: Singe ships to five platforms and LuaSec can read neither +-- the Windows certificate store nor the macOS keychain, and this client only ever talks to one +-- service, so pinning is both simpler and stronger than carrying a list of every public CA. +-- +-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key +-- does not lock out every cabinet; two pins are carried so a key can actually be rotated. + + +local socket = require("socket") +local ssl = require("ssl") + +NET_TIMEOUT = 30000 -- ms with no progress at all before a request is abandoned +NET_CHUNK = 65536 -- bytes read per pump slice +NET_REDIRECT_MAX = 5 + +-- master.singeengine.com. Two pins: the one in use and its successor, so a key rotation is a +-- release that knows both, then a server that starts using the second. An empty list means the +-- host's own trust store is used instead, which is what a self-hosted server without a pin gets. +NET_PINS = {} + +local active = {} +local nextId = 1 + + +-- The SHA-256 of a peer's public key, in the form the pins are written in. +local function keyPin(connection) + local cert = connection:getpeercertificate() + + if not cert then + return nil + end + local pem = cert:pubkey() + if not pem then + return nil + end + return utilSha256(pem) +end + + +local function finish(request, ok, result) + active[request.id] = nil + if request.file then + request.file:close() + request.file = nil + end + if request.socket then + pcall(function() request.socket:close() end) + request.socket = nil + end + if request.onDone then + request.onDone(ok, result) + end +end + + +local function fail(request, message) + finish(request, false, { error = message, url = request.url }) +end + + +-- scheme, host, port, path +local function parseUrl(url) + local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$") + + if not scheme then + return nil + end + local hostPort, path = rest:match("^([^/]+)(.*)$") + if not hostPort then + return nil + end + local host, port = hostPort:match("^(.-):(%d+)$") + host = host or hostPort + port = tonumber(port) or ((scheme == "https") and 443 or 80) + return scheme, host, port, (path ~= "" and path or "/") +end + + +local function beginConnect(request) + local sock, err = socket.tcp() + + if not sock then + return fail(request, "no socket: " .. tostring(err)) + end + sock:settimeout(0) + request.socket = sock + -- A non-blocking connect answers "timeout" immediately and finishes in the background; the + -- socket becoming writable is what says it arrived. + local ok, connectErr = sock:connect(request.host, request.port) + if not ok and connectErr ~= "timeout" and connectErr ~= "already connected" then + return fail(request, "cannot reach " .. request.host .. ": " .. tostring(connectErr)) + end + request.state = (request.scheme == "https") and "connecting" or "sending" + request.sent = 0 +end + + +local function buildRequest(request) + local lines = { + string.format("%s %s HTTP/1.1", request.method, request.path), + "Host: " .. request.host, + "Connection: close", + "Accept-Encoding: identity", + "User-Agent: Singe/" .. SINGE_VERSION_STRING, + } + + for name, value in pairs(request.headers or {}) do + lines[#lines + 1] = name .. ": " .. value + end + if request.body then + lines[#lines + 1] = "Content-Length: " .. #request.body + end + return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "") +end + + +local function startTls(request) + local params = { + mode = "client", + protocol = "any", + options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" }, + -- With a pin, the chain is checked against the key rather than a CA list, and the pin is + -- what decides. Without one, the host's own store is asked -- and if it has nothing to + -- say, the request fails rather than proceeding unverified. + verify = (#NET_PINS > 0) and "none" or "peer", + capath = "/etc/ssl/certs", + cafile = "/etc/ssl/certs/ca-certificates.crt", + } + local wrapped, err = ssl.wrap(request.socket, params) + + if not wrapped then + return fail(request, "TLS setup failed: " .. tostring(err)) + end + wrapped:settimeout(0) + if wrapped.sni then + wrapped:sni(request.host) + end + request.socket = wrapped + request.state = "handshake" +end + + +local function pumpHandshake(request) + local ok, err = request.socket:dohandshake() + + if ok then + if #NET_PINS > 0 then + local seen = keyPin(request.socket) + local good = false + for _, pin in ipairs(NET_PINS) do + if seen == pin then + good = true + end + end + if not good then + -- The one failure that must never be shrugged off: a wrong key is either a + -- mis-deployed server or somebody in the middle, and both want a person. + return fail(request, "the server's key is not one this build trusts") + end + end + request.state = "sending" + return + end + if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then + fail(request, "TLS handshake failed: " .. tostring(err)) + end +end + + +local function pumpSending(request) + if not request.outgoing then + request.outgoing = buildRequest(request) + end + local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1) + + if sent then + request.sent = sent + elseif err == "timeout" then + request.sent = partial or request.sent + else + return fail(request, "send failed: " .. tostring(err)) + end + if request.sent >= #request.outgoing then + request.state = "reading" + end +end + + +-- Split the headers off once the blank line arrives, so the body can start going to disk rather +-- than accumulating in memory: a game is far too big to hold twice. +local function splitHead(request) + local at = request.buffer:find("\r\n\r\n", 1, true) + + if not at then + return false + end + local head = request.buffer:sub(1, at - 1) + request.buffer = request.buffer:sub(at + 4) + request.status = tonumber(head:match("^HTTP/%d%.%d (%d%d%d)")) or 0 + request.responseHeaders = {} + for name, value in head:gmatch("\r\n([^:\r\n]+):%s*([^\r\n]*)") do + request.responseHeaders[name:lower()] = value + end + request.length = tonumber(request.responseHeaders["content-length"]) + request.got = 0 + if request.toFile then + local file, err = io.open(request.toFile, "wb") + if not file then + fail(request, "cannot write " .. request.toFile .. ": " .. tostring(err)) + return false + end + request.file = file + end + return true +end + + +local function consume(request, data) + request.got = request.got + #data + if request.file then + request.file:write(data) + else + request.parts[#request.parts + 1] = data + end + if request.onProgress then + request.onProgress(request.got, request.length) + end +end + + +local function complete(request) + local body = request.file and "" or table.concat(request.parts) + + -- A redirect is followed only for a plain GET, and only within the same scheme and host: the + -- point of a pin is lost if a 302 can walk the client somewhere else. + local location = request.responseHeaders["location"] + if location and request.status >= 300 and request.status < 400 and request.redirects < NET_REDIRECT_MAX then + local scheme, host = parseUrl(location) + if scheme == request.scheme and host == request.host then + request.redirects = request.redirects + 1 + request.url = location + local _, _, port, path = parseUrl(location) + request.port, request.path = port, path + request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0 + if request.socket then pcall(function() request.socket:close() end) end + request.socket = nil + return beginConnect(request) + end + end + finish(request, true, { status = request.status, body = body, headers = request.responseHeaders, + bytes = request.got, file = request.toFile }) +end + + +local function pumpReading(request) + local data, err, partial = request.socket:receive(NET_CHUNK) + local got = data or partial + + if got and #got > 0 then + request.lastProgress = singeGetTicks() + if not request.responseHeaders then + request.buffer = request.buffer .. got + if splitHead(request) and #request.buffer > 0 then + local rest = request.buffer + request.buffer = "" + consume(request, rest) + end + else + consume(request, got) + end + end + if request.responseHeaders and request.length and request.got >= request.length then + return complete(request) + end + if err == "closed" then + if request.responseHeaders then + return complete(request) + end + return fail(request, "the server closed before answering") + end + if err and err ~= "timeout" and err ~= "wantread" then + fail(request, "read failed: " .. tostring(err)) + end +end + + +-- One slice of work for every request in flight. Cheap when nothing is happening. +-- +-- The list is snapshotted first because a request that finishes calls back into the caller, and the +-- caller very reasonably starts another one from there -- a download that reports itself installed, +-- say. Adding to a table while pairs() walks it is not allowed, and the failure ("invalid key to +-- 'next'") names neither the table nor the callback that did it. +function netPump() + local running = {} + + for id in pairs(active) do + running[#running + 1] = id + end + for _, id in ipairs(running) do + local request = active[id] + if request then + if singeGetTicks() - request.lastProgress > NET_TIMEOUT then + fail(request, "the server stopped responding") + elseif request.state == "connecting" then + -- Writable means the connect finished; LuaSec is wrapped around it only then. + local _, writable = socket.select(nil, { request.socket }, 0) + if writable and #writable > 0 then + request.lastProgress = singeGetTicks() + startTls(request) + end + elseif request.state == "handshake" then + pumpHandshake(request) + elseif request.state == "sending" then + pumpSending(request) + elseif request.state == "reading" then + pumpReading(request) + end + end + end +end + + +-- Start a request. opts: url, method, headers, body, toFile, onProgress. onDone(ok, result) is +-- called once, with result.status/body/headers on success or result.error on failure. +function netRequest(opts, onDone) + local scheme, host, port, path = parseUrl(opts.url) + + if not scheme or (scheme ~= "http" and scheme ~= "https") then + onDone(false, { error = "not a URL this understands: " .. tostring(opts.url) }) + return nil + end + local request = { + id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path, + method = opts.method or "GET", headers = opts.headers, body = opts.body, + toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone, + buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(), + } + nextId = nextId + 1 + active[request.id] = request + beginConnect(request) + return request.id +end + + +function netCancel(id) + local request = active[id] + + if request then + request.onDone = nil + finish(request, false, { error = "cancelled" }) + end +end + + +function netBusy() + return next(active) ~= nil +end diff --git a/assets/Tools.singe b/assets/Tools.singe index 3dc4110f7..dd846b0d7 100644 --- a/assets/Tools.singe +++ b/assets/Tools.singe @@ -38,6 +38,11 @@ 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 @@ -91,6 +96,59 @@ function toolsShow(rows) 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) @@ -114,6 +172,9 @@ 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 @@ -141,6 +202,11 @@ 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 @@ -152,6 +218,9 @@ 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() @@ -192,6 +261,25 @@ function toolsUpdate() 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 @@ -917,3 +1005,361 @@ TOOLS[#TOOLS + 1] = { 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. +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, +} diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 4ccf7303e..336690102 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -127,6 +127,33 @@ To see the overlay renderer on a machine that does have a GPU, put writes the line back out, so it survives. That is how the overlay path is tested, and how a cabinet builder who prefers the old look gets it. +[#onlineservice] +==== Online: accounts, games and scores + +The bundled menu can sign the cabinet in to a master service (by default +`master.singeengine.com`), which does three things: it holds the account, it +offers a catalogue of games to download and update, and it keeps high score +boards. + +Two service tools cover it. **Online Account** signs in, creates an account, +recovers a password, and sets the server address -- it is where an operator +running their own service points the cabinet. A new account is not usable until +the emailed link is opened. **Get Games** lists everything the +service offers, marks what is installed here, and downloads, updates or removes +it. A list longer than the screen scrolls with the selection rather than running +off the bottom, with `...` showing there is more either way. A game the service has withdrawn keeps working and is shown as "installed, +no longer offered": all that stops is being given an update, because a player +who has a game has it. A download is checked against the digest the catalogue published before it +replaces anything, so a transfer that arrives wrong fails rather than installing +a game that will not run. + +The account belongs to the *machine*, not to a game: the token is kept in +`master.dat` in the data root, so a game posting a score uses the same sign-in +the menu made. See <> for the game's side, and +`Singe/Net.singe` for the asynchronous HTTP client underneath -- nothing here +blocks a frame, because Lua in Singe is cooperative and a large download would +otherwise look like a hang. + [#servicetools] ==== The Service Tools @@ -190,6 +217,15 @@ being told, with the size of each. Button 3 -- not button 1, which is "choose" everywhere else -- throws away the selected game's save. Clearing a high score table without deleting the game is the usual reason. +Online Account:: +Signing this cabinet in to the master service, creating an account, recovering a +password, and setting the server address. See +<>. + +Get Games:: +The catalogue: what the service offers, what is installed here, and downloading, +updating or removing it. + MIDI Ports:: The input and output ports this machine has, which of them Singe has opened, and the last few messages that arrived. Button 1 sends a note, button 3 looks @@ -1366,6 +1402,7 @@ GAMES = { RESOLUTION_X = 720, RESOLUTION_Y = 480, SINDEN_GUN = "", + GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10", CABINET = "ActionMax/cabinet_38AmbushAlley.png", MARQUEE = "ActionMax/marquee_ActionMax.png", ATTRACT = "ActionMax/video_38AmbushAlley.mkv", @@ -1386,7 +1423,7 @@ GAMES = { The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, -`AUDIO_DELAY`, `AUDIO_SUFFIX`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own +`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own script, through `scriptExecute` / `scriptPush`) launches the entry; they override the command line. The `VIDEO` line is the disc: an entry that names one is a laserdisc game, and an entry that leaves it out or blank runs @@ -13967,6 +14004,42 @@ function pauseMenuClose() end ---- +[#utilsha256] +==== utilSha256 + +[source,text] +---- +digest = utilSha256(data) +---- + +SHA-256 of a string, as 64 lowercase hex characters. Binary-safe, so it works on the bytes of a file +as readily as on text. + +For deciding whether the bytes you have are the bytes you were promised: a downloaded game against +the digest its catalogue entry carries, or a server's public key against a pin. The bundled `md5` +module is still there for a checksum, but a digest used to decide whether to *trust* something has +to be one that is still worth trusting. + +*Parameters:* + +* `data` -- a string of bytes. Empty aborts the script. + +*Returns:* string, 64 lowercase hex characters. + +*Since:* 3.00. + +.Example +[source,lua] +---- +-- Refuse a download that did not arrive intact. +local file = io.open("game.part", "rb") +local bytes = file:read("*a") +file:close() +if utilSha256(bytes) ~= expected then + os.remove("game.part") +end +---- + [#singegetaudiocalibration] ==== singeGetAudioCalibration @@ -14082,6 +14155,47 @@ function saveConfig() end ---- +[#singegetgameid] +==== singeGetGameId + +[source,text] +---- +id = singeGetGameId() +---- + +The `GAME_ID` from this game's `games.dat` entry, or `nil` when it has none. It is the id the +online service knows the game by, and the key a leaderboard is kept under. + +**The format is a UUID**, in the canonical 36-character form -- eight hex digits, three groups of +four, then twelve, separated by hyphens: + +---- +GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10" +---- + +Nothing else is accepted. The id is a path segment in the service's URLs, so one containing a slash +or a question mark would publish and then never be reachable, and two ids differing only in case +would be two games to the database and one game to anybody reading them. Comparison is +case-insensitive and storage is lowercase, so an id typed in capitals is the same game. + +Any UUID will do -- generate one however you like, or let `publish.lua` print one. A game without +one can be played but not ranked: there would be nothing stable to key a board on, and an id +invented at run time would make a different board every time the files moved. + +*Returns:* string, or `nil`. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Only offer the leaderboard when this game has an identity to rank under. +if singeGetGameId() then + showLeaderboardButton() +end +---- + [#singegetheight] ==== singeGetHeight @@ -16333,6 +16447,122 @@ The height applies to the cue on screen straight away and to every cue after it. srtPosition(70) ---- +[#onlinescores] +=== Online high scores + +A game posts a score in one line: + +[source,lua] +---- +scoreSubmit(playerScore) +---- + +Everything else was set up once, in the menu: which service, which account, and +what name the player appears under (see +<>). A game needs only a +`GAME_ID` in its `games.dat` entry -- a UUID, which is what the board is kept under. See +<> for the format. + +|=== +| Call | What it does + +| `scoreBegin()` +| Say a play is starting. Optional, and worth calling: see below. + +| `scoreSubmit(value [, board [, meta]])` +| Queue a score. Returns `false` if the cabinet is not signed in or the game has no `GAME_ID`. + +| `scoreBoard(onDone [, board])` +| Fetch the board. `onDone(ok, result)` with `result.top` (a list of `{name, value}`) and + `result.standing` (this player's `value`, `rank` and the number of `players`). + +| `scorePlayerName()` +| The name this cabinet posts under, or `nil` if none has been chosen. + +| `scoreWaiting()` +| How many scores are queued and not yet sent. + +| `scoreUpdate()` +| Once a frame, from `onOverlayUpdate`. Nothing progresses without it. +|=== + +**A score is queued, not sent.** It is written to disk and handed to the service +when there is a connection, because a cabinet is often offline and a game must +never stall on the wire to show its own board. A queued score survives the +machine being switched off. + +**A board can have more than one.** Pass a board name to keep a time attack +separate from a points total; the default is `default`. + +**The name on a board is a handle, never an address.** It is chosen in the +Online Account tool, kept apart from the account, and is the only thing about a +player that other players see. Three to twenty characters of letters, digits, +hyphens and underscores, starting and ending with a letter or digit -- no +spaces, no dots, and no `@`, so a handle cannot even look like an email address. +Case is ignored for uniqueness, so `Ann` and `ANN` are one name. + +**A handle is chosen once.** Renaming freely would mean a leaderboard's history +is not really anyone's. An operator can change one; a player cannot. An email +address cannot be changed at all. + +Handles are checked against a list the service's operator maintains: identities +nobody should claim (`admin`, `staff`) and words that have no place on a screen +in a public room. A score is refused until a name has been chosen, because a +score with nobody's name on it is not on a board in any useful sense. + +**What a score means.** The cabinet is the player's machine, so a submitted +score is a *claim* -- nothing the client can do makes it more than that, and +signing it with a secret shipped in the client would only look like security. +The service records who claimed what, rate limits submissions, and flags scores +that are wildly out of line for a person to look at. It does not pretend to +verify them. + +**`scoreBegin()` is worth calling** for the one thing that is not a claim. Call +it when a play starts and the service notes the moment on its own clock; the +score that follows carries how long the play took, measured server-side. That +number has not been through the player's machine, which is what makes it worth +having -- a large score arriving seconds after the game began is visible without +anyone being accused of anything. + +Nothing is rejected on it. For a game with a disc the video length is a floor +nobody can beat, but a disc is optional, and a threshold learned from +submissions is set by whoever submits first. So the time is recorded, shown to +the operator beside the score, and the judgement is left to a person. A cabinet +that is offline when a play starts simply records no time, which is honest +rather than broken. + +.Example +[source,lua] +---- +function startLevel() + scoreBegin() -- the service starts its clock + ... +end +---- + +.Example +[source,lua] +---- +-- At the end of a game, post the score and then show where it landed. +function gameOver(points) + scoreSubmit(points) + scoreBoard(function(ok, board) + if not ok then + showMessage("Not signed in; score kept for later.") + return + end + for place, row in ipairs(board.top) do + showLine(place .. ". " .. row.name .. " " .. row.value) + end + end) +end + +function onOverlayUpdate() + scoreUpdate() + return OVERLAY_UPDATED +end +---- + [#stats] === Stats diff --git a/src/embedded.h b/src/embedded.h index e79d02c1a..cb9c258da 100644 --- a/src/embedded.h +++ b/src/embedded.h @@ -34,6 +34,8 @@ #include "generated/settings_cfg.h" #include "generated/Menu_singe.h" #include "generated/Tools_singe.h" +#include "generated/Net_singe.h" +#include "generated/Master_singe.h" #include "generated/MenuDocument_singe.h" #include "generated/MenuOverlay_singe.h" #include "generated/Menu_rml.h" diff --git a/src/main.c b/src/main.c index 65e8ad2bd..50e249854 100644 --- a/src/main.c +++ b/src/main.c @@ -1791,6 +1791,8 @@ static void _unpackData(const char *exePath, bool absolute) { { "settings.cfg.example", settings_cfg, settings_cfg_len }, { "Menu.singe", Menu_singe, Menu_singe_len }, { "Tools.singe", Tools_singe, Tools_singe_len }, + { "Net.singe", Net_singe, Net_singe_len }, + { "Master.singe", Master_singe, Master_singe_len }, { "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len }, { "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len }, { "Menu.rml", Menu_rml, Menu_rml_len }, @@ -1874,6 +1876,7 @@ ConfigT *cloneConf(const ConfigT *conf) { c->audioSuffix = _cloneString(conf->audioSuffix); c->gamepadOrder = _cloneString(conf->gamepadOrder); c->videoFile = _cloneString(conf->videoFile); + c->gameId = _cloneString(conf->gameId); c->dataDirBase = _cloneString(conf->dataDirBase); c->dataDir = _cloneString(conf->dataDir); @@ -1944,6 +1947,7 @@ void destroyConf(ConfigT **confPointer) { if (conf == NULL) { return; } + free(conf->gameId); free(conf->dataDir); free(conf->dataDirBase); free(conf->videoFile); diff --git a/src/singe.c b/src/singe.c index 016dc7015..b19fa563b 100644 --- a/src/singe.c +++ b/src/singe.c @@ -25,6 +25,9 @@ #include #include +// SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec. +#include + #include #include #include @@ -1270,6 +1273,7 @@ static int32_t apiSingeGetAudioLatency(lua_State *L); static int32_t apiSingeGetDataPath(lua_State *L); static int32_t apiSingeGetSystemInfo(lua_State *L); static int32_t apiSingeSaveGeometry(lua_State *L); +static int32_t apiSingeGetGameId(lua_State *L); static int32_t apiSingeGetHeight(lua_State *L); static int32_t apiSingeGetPauseFlag(lua_State *L); static int32_t apiSingeGetScriptPath(lua_State *L); @@ -1286,6 +1290,7 @@ static int32_t apiSingeSetPauseFlag(lua_State *L); static int32_t apiSingeSetPauseKeyEnabled(lua_State *L); static int32_t apiSingeSetQuitKeyEnabled(lua_State *L); static int32_t apiSingeVersion(lua_State *L); +static int32_t apiUtilSha256(lua_State *L); static int32_t apiSingeWantsCrosshairs(lua_State *L); static int32_t apiSoftDelete(lua_State *L); static int32_t apiSoftNew(lua_State *L); @@ -2107,6 +2112,14 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) { free(c->scriptFile); c->scriptFile = strdup(valueString); utilFixPathSeparators(&c->scriptFile, false); + } else if (strcmp(confKey, "GAME_ID") == 0) { + // The master service's id for this game. Nothing in the engine uses it; it is carried + // so a script can ask singeGetGameId() which board its scores belong on. + if (valueString == NULL) { + utilDie("GAME_ID must be a string."); + } + free(c->gameId); + c->gameId = strdup(valueString); } else if (strcmp(confKey, "CONTAINER") == 0) { if (valueString == NULL) { utilDie("CONTAINER must be a string."); @@ -5473,6 +5486,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00 lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00 lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00 + lua_register(L, "singeGetGameId", apiSingeGetGameId); // 3.00 lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx lua_register(L, "singeGetSystemInfo", apiSingeGetSystemInfo); // 3.00 lua_register(L, "singeGetPauseFlag", apiSingeGetPauseFlag); // 1.xx RDG @@ -5492,6 +5506,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeSetQuitKeyEnabled", apiSingeSetQuitKeyEnabled); // Hypseus keyboardCatchQuit is a framework alias with the opposite sense. lua_register(L, "singeVersion", apiSingeVersion); // 1.xx RDG lua_register(L, "singeWantsCrosshairs", apiSingeWantsCrosshairs); // 2.00 + lua_register(L, "utilSha256", apiUtilSha256); // 3.00 lua_register(L, "softDelete", apiSoftDelete); // 3.00 lua_register(L, "softNew", apiSoftNew); // 3.00 @@ -12673,6 +12688,21 @@ static int32_t apiSingeGetDataPath(lua_State *L) { // height = singeGetHeight() Window height in pixels. +// The GAME_ID from this game's games.dat entry, or nil when it has none. A game without one can be +// played but not ranked: there would be nothing stable to key a leaderboard on, and a made-up id +// would make a different board every time the files moved. +static int32_t apiSingeGetGameId(lua_State *L) { + _argCheck(L, "singeGetGameId", 0, 0); + if (_global.conf->gameId != NULL) { + lua_pushstring(L, _global.conf->gameId); + } else { + lua_pushnil(L); + } + + return 1; +} + + static int32_t apiSingeGetHeight(lua_State *L) { int32_t y = 0; @@ -12843,6 +12873,29 @@ static int32_t apiSingeSetQuitKeyEnabled(lua_State *L) { // version = singeVersion() +// SHA-256 of a string, as lowercase hex. Needed wherever a script has to know that the bytes it +// has are the bytes it was promised: a downloaded game against the digest its catalogue entry +// carries, or a server's public key against a pin. md5 is bundled as a module, but a digest used +// to decide whether to trust something has to be one that is still worth trusting. +static int32_t apiUtilSha256(lua_State *L) { + const char *data = NULL; + size_t length = 0; + unsigned char digest[SHA256_DIGEST_LENGTH]; + char hex[SHA256_DIGEST_LENGTH * 2 + 1]; + int32_t i = 0; + + _argCheck(L, "utilSha256", 1, 1); + data = _argData(L, "utilSha256", 1, &length); + SHA256((const unsigned char *)data, length, digest); + for (i = 0; i < SHA256_DIGEST_LENGTH; i++) { + snprintf(&hex[i * 2], 3, "%02x", digest[i]); + } + lua_pushlstring(L, hex, sizeof(hex) - 1); + + return 1; +} + + static int32_t apiSingeVersion(lua_State *L) { _luaTrace(L, "singeVersion", "%s", VERSION_STRING); lua_pushnumber(L, SINGE_VERSION); diff --git a/src/singe.h b/src/singe.h index 022c03cb6..e9f774df3 100644 --- a/src/singe.h +++ b/src/singe.h @@ -128,6 +128,7 @@ typedef struct ConfigS { int32_t shiftX; // --shiftx: per cent of half the logical width the picture moves right int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees + char *gameId; // GAME_ID from games.dat: the master service's stable id for this game int32_t xResolution; int32_t yResolution; int32_t canvasWidth; // World size when there is no disc