Working on integrating with the master server.

This commit is contained in:
Scott Duensing 2026-09-12 01:22:02 -05:00
parent f9f67201e3
commit d73966c1e6
14 changed files with 1689 additions and 4 deletions

View file

@ -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

View file

@ -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 "")

View file

@ -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

349
assets/Master.singe Normal file
View file

@ -0,0 +1,349 @@
--[[
*
* 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 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()

View file

@ -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()

View file

@ -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] = "<div class='sub'>...</div>"
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] = "<div class='sub'>...</div>"
below = false
end
if row.kind == "item" then
seenItem = true
end
rows[#rows + 1] = toolRow(row)
end
if below then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
menuElement("games"):SetClass("hidden", toolsActive())

View file

@ -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,8 +367,12 @@ MENU_RENDER.tools = function()
else
colorForeground(ink, ink, ink, 255)
end
-- 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
end

392
assets/Net.singe Normal file
View file

@ -0,0 +1,392 @@
--[[
*
* 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.
*
*
--]]
-- 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

View file

@ -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,
}

View file

@ -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 <<onlinescores,Online high scores>> 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
<<onlineservice,Online: accounts, games and scores>>.
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:* <<onlinescores,Online high scores>>, <<gamesdat,games.dat>>
.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
<<onlineservice,Online: accounts, games and scores>>). A game needs only a
`GAME_ID` in its `games.dat` entry -- a UUID, which is what the board is kept under. See
<<singegetgameid,singeGetGameId>> 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

View file

@ -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"

View file

@ -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);

View file

@ -25,6 +25,9 @@
#include <math.h>
#include <errno.h>
// SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec.
#include <openssl/sha.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_mixer/SDL_mixer.h>
@ -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);

View file

@ -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