Lots of menu work related to auto updates. Engine work for auto updates. Forge work for sharing games with the server. LOTS of documentation work.

This commit is contained in:
Scott Duensing 2026-09-23 22:15:01 -05:00
parent 721a0d120a
commit 25aa909c3d
74 changed files with 21111 additions and 18789 deletions

147
CHANGELOG
View file

@ -9,6 +9,49 @@ SINGE 3.00
API Changes
-----------
- Singe can update itself. Once a day the menu asks the master service
whether a newer release exists for this machine's platform, and says so in
a line under the game's details; nothing is fetched until a person opens
the new Engine Update service tool and presses the button. The tool
downloads the release, checks it against the digest the service published,
puts it beside the running Singe under the release's own name, sets the old
one aside as old-<name>, and restarts into the new one with --refresh, so
the support folder is rewritten by the version that now owns it and the
folder it replaces is kept as old-Singe-<version>. no_update_check = true
in settings.cfg turns the daily look off; the tool still works by hand.
Behind it: singeGetSystemInfo() reports platform and executable,
SINGE_UPDATE_CHECK says whether the look is on, and singeRelaunch(path
[, argument]) starts another executable when this one ends. Neither the
question nor the download needs an account.
- Beta builds have a version of their own. CMake's project version has to be
numbers, so the four it takes are used as major.minor.beta.which: 3.00.0.0
is v3.00 and 3.00.1.2 is v3.00b2, which is what the binary is named, what
the engine reports, and what the books carry as their revision. A beta
ranks below the release of the same number, so a cabinet on v3.00b2 is
offered v3.00 the day it appears, and the minor is compared as a number, so
3.09 precedes 3.10. The launcher ranks the binaries beside it the same way.
- A game can be sent to the master service for review from Forge. P in the
editor exports, packs, and uploads it; an upload is not in the catalogue
until it has been reviewed, and Forge shows where each version of yours
stands. Behind it: singePack(directory, database) packs a game from
a script exactly as --pack does, and the network layer can send a request
body straight from a file, so a game is never held in memory whole.
- The menu knows which of its games are out of date, and updates them from the
game's own page. A packed game now carries VERSION in its games.dat, so the
file itself says what it is wherever it ends up. The menu reads that out of
every games.dat it loads and asks which of them are out of date, no sign-in
needed; a game with a newer version wears an "update" mark in the list, the
line under its details names the version, and button 3 fetches it through
the same checked download the Get Games tool uses. Nothing about this
machine is kept anywhere but the question: the menu says which games and
versions it holds and is told which are newer, because a record of what a
cabinet has installed could never have stayed true. Forge gives every
description a permanent GAME_ID the first time it is saved and writes it
into the games.dat it exports, so a Forge game can be published and updated.
- overlayGetFontWidth and overlayGetFontHeight report the size in pixels of one
character cell of the built-in console font. overlayPrint counts in cells
while every other drawing call counts in pixels, and until now nothing said
@ -930,10 +973,9 @@ API Changes
- 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.
underscores, chosen once. A player cannot change one afterwards, and an
email address cannot be changed at all. A handle nobody should claim, or
with no place on a screen in a public room, is refused when it is chosen.
- Online: accounts, a game catalogue, and high scores. A cabinet can sign
in to a master service (master.singeengine.com by default), browse every
@ -967,20 +1009,16 @@ API Changes
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
A game that stops being offered 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.
scoreBegin() says a play is starting, so the score that follows carries
how long it took as timed at the other end rather than on this machine.
It costs a game one call and nothing is refused on it.
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.
player's own machine, so nothing here pretends to verify one.
- Subtitles carried inside the video file: discGetSubtitleTracks(),
discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads
@ -994,6 +1032,51 @@ API Changes
New Features
------------
- Forge can judge a shot by the picture rather than by a hitbox. lightSensor
is a behaviour of the framework now, the way a home laserdisc gun works: a
spot on the picture flashes on the frames a target is shown, and a shot is
good when the picture under the gun changes the way the spot does. It began
as five games' own vocabulary and was general all along, so it moved in, and
those five name no vocabulary of their own any more. pictureAt came with it,
a condition any rule can ask -- is the picture at this point bright, dark, or
neither -- for a flash hidden in a game's own video, a colour cue, or a lamp
in a scene; a game with no disc reads dark everywhere. Both are in the
book's vocabulary tables, which are generated from the manifest, and scene
100 covers them.
- The launcher runs the best Singe it finds, and the support folder can be
rewritten on demand. Menu.sh and Menu.bat read each Singe* name beside them
and rank it by this machine's architecture first and the version second.
Architecture comes first because a newer build for another machine cannot run
at all, and one game directory on a share is read by a Pi and a PC alike; it
is worked out when the launcher runs, from uname or PROCESSOR_ARCHITECTURE,
rather than baked in by whichever machine wrote the folder, and on a Mac a
universal build counts as native. Among the builds this machine can run,
v3.10 beats v3.09, since the minor is compared as a number, and a beta loses
to the release it leads up to, so v3.00b2 gives way to v3.00 the moment it is
put there. A build for another architecture, or a name that does not parse
at all, is still run when it is the only one there: a complaint from the
engine says more than "cannot find Singe". So an
upgrade is a matter of putting the new binary in the folder; the old one
can stay. The new --refresh sets the Singe folder aside as
old-Singe-<version>, named from the version.txt the folder now carries, and
writes a fresh one, so a file an older release shipped and this one does not
cannot linger. A second --refresh keeps its own as old-Singe-<version>-1.
Nothing is deleted anywhere in this: what is set aside is the owner's to
remove. For the same reason the menu's game update and Forge's publish
write a new file beside the old one and only release the old copy once the
new one is in place, so an interrupted write cannot lose either.
- --patch gives back the space a replaced file was using. SQLite keeps the
pages a deleted blob held for the next thing written, so a developer who
repacks a video a dozen times ends up with a .game several times the size
of its contents. A patch now measures the free space once its transaction
has committed and, when there is at least a megabyte of it, compacts the
database and reports what it recovered -- an 8 MB file whose video was
replaced by a 1 KB placeholder comes back to 28 KB, the size of a fresh
pack. Under a megabyte it is left alone, since compacting rewrites the
whole file.
- Forge has tutorials: ten sittings at the front of Forge.pdf, each
ending with a game that plays, with the kit of pictures, sounds, a clip,
and a model they use packed in Forge.game, and the finished description
@ -1219,7 +1302,26 @@ New Features
the overlay it is drawn on, so a larger recording gets a larger tagline
rather than the menu's scaled up.
- The Singe logo is on the cover of all three books.
- The manual is two books. Everything about running Singe -- installing it,
installing games, the menu, controls, settings, the command line, bezels,
the game directory, and the questions people ask -- stays in Manual.pdf,
which is the one book the binary carries and unpacks. Everything about
making games -- the programming model, packing, games.dat, the GUI, 3D,
physics, particles, the formats, and every function in the Lua API -- is
the new Singe Reference, docs/Reference.adoc. The two link into each
other where they always did. ./build-docs.sh builds both, the Forge book,
and the beginner's course, and packs the three books that do not ship in
the binary, with the lesson files and the licence, into
Singe-Docs-v<version>.zip, an optional download beside the engine. The
HTML pages carry their pictures inside them, so each is one file wherever
it is copied.
- ./build-docs.sh preview builds the four books with PREVIEW laid diagonally
across every page, PDF and HTML alike, for proofreaders, and names the
documentation download -preview so it cannot pass for a release. The
plain build, and the manual the binary carries, never wear it.
- The Singe logo is on the cover of all four books.
- A third book: "Learn to Program with Singe", docs/Learn.adoc, built by
./build-docs.sh beside the manual and the Forge book. Thirty lessons that
@ -1380,6 +1482,21 @@ New Features
Fixes
-----
- Two things on the engine update's Windows path. Windows cannot replace a
running image, so the update starts the new Singe beside this one and lets
this one end; but with a console -- the default -- this one then sat in
getchar() waiting for a key, so two engines were alive at once and the old
one held the console the new one was writing to. The key wait now happens
only when nothing took over, and still happens when the start failed, since
that message is worth reading. And setting the running binary aside as
old-<name> ignored whether the rename worked. It usually does: renaming a
running executable is allowed on Windows as it is everywhere, as long as the
new name is on the same volume, and only deleting one is refused. It can
still be beaten by a folder the user cannot write or by a scanner holding the
file, and that was silent. The tool now says so, on screen and in the trace,
and carries on: the launcher runs the newest Singe in the folder, so the old
binary left in place is harmless.
- The menu's grid, horizon and sun were drawn black and unlit through the
intro, which kept the lights off them but not the blast's glow behind
them: a black line across a flash is a line, and the whole grid showed

View file

@ -20,7 +20,28 @@
cmake_minimum_required(VERSION 3.22)
project(singe2 VERSION 3.00 LANGUAGES C CXX)
# The version, in CMake's four numbers, because that is all CMake's project() will take:
#
# major . minor . patch . tweak
# ^^^^^ ^^^^^ ^^^^^
# kept as written (00 stays 00)
# 0 = a release, 1 = a beta
# which beta
#
# so 3.00.0.0 is "v3.00" and 3.00.1.2 is "v3.00b2". One line to edit, no second variable, and
# the parts stay numbers, which is what the Windows resource needs anyway.
#
# A beta of 3.00 comes BEFORE 3.00, and the master service knows it: it reads the same shape out
# of a version string and ranks a beta below the release of the same number, so a cabinet on
# v3.00b2 is offered v3.00 when it arrives.
project(singe2 VERSION 3.00.1.1 LANGUAGES C CXX)
if(PROJECT_VERSION_PATCH EQUAL 1)
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}b${PROJECT_VERSION_TWEAK}")
else()
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
endif()
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
@ -253,7 +274,7 @@ set(MANUAL_DIR ${CMAKE_SOURCE_DIR}/.builddir)
add_custom_command(
OUTPUT ${MANUAL_DIR}/Manual.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
DEPENDS docs/Manual.adoc
COMMENT "Rendering Manual.pdf"
VERBATIM
@ -315,19 +336,22 @@ file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/im
add_custom_command(
OUTPUT ${MANUAL_DIR}/Forge.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS}
COMMENT "Rendering Forge.pdf"
VERBATIM
)
add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf)
# Optional HTML manual for browsing: cmake --build . --target docs
# Optional HTML for browsing: cmake --build . --target docs. The user manual is the one book the
# binary carries; the reference, the Forge book, and the beginner's course are the optional
# documentation download that build-docs.sh packs.
if(ASCIIDOCTOR)
add_custom_target(docs
COMMAND ${ASCIIDOCTOR} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Reference.adoc -o ${MANUAL_DIR}/Reference.html
DEPENDS ${MANUAL_DIR}/Manual.pdf
COMMENT "Rendering Manual.html"
COMMENT "Rendering Manual.html and Reference.html"
VERBATIM
)
endif()
@ -596,7 +620,7 @@ endif()
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${PROJECT_VERSION}-${osInitial}${osRest}-${KANGAROO_ARCH}")
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
# Warnings apply to our code only; the vendored libraries are not ours to fix.
set_source_files_properties(${SINGE_SOURCE} PROPERTIES COMPILE_OPTIONS "-Wall;-Wextra")
@ -606,6 +630,9 @@ set_source_files_properties(thirdparty/librs232/bindings/lua/luars232.c PROPERTI
set_source_files_properties(thirdparty/basis_universal/transcoder/basisu_transcoder.cpp PROPERTIES COMPILE_OPTIONS "-w")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
# The platform part of the release name (Linux-x86_64), so a script can ask which release it
# is and fetch the right one.
SINGE_PLATFORM="${osInitial}${osRest}-${KANGAROO_ARCH}"
RS232_STATIC
BASISD_SUPPORT_KTX2=1
BASISD_SUPPORT_KTX2_ZSTD=1

View file

@ -104,6 +104,8 @@ local NAV_RADIUS_2D = 0.08 -- Agents in a 2D room, in navigation units: eig
local NAV_HEIGHT_2D = 0.5
local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point.
local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say.
local PICTURE_BRIGHT = 127 -- A light sensor's thresholds: how near white on every channel
local PICTURE_DARK = 20 -- reads as bright, how near black reads as dark, between them unknown.
local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" }
local dialogues = {} -- Compiled dialogues by name, from authorDialogues.
local talking = nil -- The dialogue on screen: { lines, choices, chosen }.
@ -1546,6 +1548,58 @@ AUTHOR.behaviours.target = {
end
}
AUTHOR.behaviours.lightSensor = {
help = "Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead.",
params = { x = "number", y = "number", left = "number", top = "number", high = "number", low = "number", trigger = "switch", player = "number" },
defaults = { high = PICTURE_BRIGHT, low = PICTURE_DARK, trigger = "SWITCH_BUTTON3", player = 1 },
attach = function(instance, params)
instance.sensor = params
instance.hearsAll = true -- The trigger comes as a press about nothing in particular.
instance.trigger = authorSwitchValue(params.trigger)
instance.vars.player = params.player or 1
instance.pulled = 0
instance.spotWas = nil
instance.gunWas = nil
end,
step = function(instance)
local p = instance.sensor
local spotState = authorPictureState(p.x or 0, p.y or 0, p.high, p.low)
if (instance.pulled > 0) and (instance.spotWas ~= nil) and (spotState ~= instance.spotWas) then
-- The picture changed frame: a sample. A shot into the spot's corner is not judged.
local gx, gy = authorPointer(instance.vars.player)
local gunState = "unknown"
if (gx < (p.left or 0)) or (gy < (p.top or 0)) then
gunState = authorPictureState(gx, gy, p.high, p.low)
end
if instance.pulled == 1 then
-- The second sample decides, when both samples of both spots were readable and the
-- gun's changed between them: good when it changed the way the spot did.
if (gunState ~= "unknown") and (spotState ~= "unknown") and (instance.gunWas ~= "unknown") and (instance.spotWas ~= "unknown") and (instance.gunWas ~= gunState) then
if gunState == spotState then
authorEvent("hit", { self = instance, player = instance.vars.player, x = gx, y = gy })
else
authorEvent("miss", { self = instance, player = instance.vars.player, offscreen = false, x = gx, y = gy })
end
end
else
instance.gunWas = gunState
end
instance.pulled = instance.pulled - 1
end
instance.spotWas = spotState
end,
on = function(instance, name, event)
-- The trigger: the shot is judged over the next two frames of the picture.
if (name == "pressed") and (event.switch == instance.trigger) then
instance.pulled = 2
instance.gunWas = nil
authorBehaviourEvent(instance, "fire")
end
end
}
AUTHOR.behaviours.walker = {
help = "Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.",
params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" },
@ -2100,6 +2154,13 @@ AUTHOR.conditions.pointerOffscreen = {
emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end
}
AUTHOR.conditions.pictureAt = {
help = "The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing.",
params = { x = "number", y = "number", is = "string", high = "number", low = "number" },
defaults = { is = "bright", high = PICTURE_BRIGHT, low = PICTURE_DARK },
emit = function(p) return "authorPictureIs(" .. p.x .. ", " .. p.y .. ", " .. p.is .. ", " .. p.high .. ", " .. p.low .. ")" end
}
AUTHOR.conditions.onScreen = {
help = "An instance's box is at least partly on the overlay.",
params = { entity = "entity" },
@ -4379,6 +4440,29 @@ function authorPointerOffscreen(player)
end
-- What the picture is at a point, as a light sensor sees it: bright, dark, or unknown between
-- the two thresholds. The engine answers zeroes when no video is playing, so a game with no
-- disc layer reads dark everywhere rather than failing.
function authorPictureState(x, y, high, low)
local r, g, b = vldpGetPixel(math.floor(x), math.floor(y))
high = high or PICTURE_BRIGHT
low = low or PICTURE_DARK
if ((255 - r) < high) and ((255 - g) < high) and ((255 - b) < high) then
return "bright"
elseif (r < low) and (g < low) and (b < low) then
return "dark"
end
return "unknown"
end
function authorPictureIs(x, y, want, high, low)
return authorPictureState(x, y, high, low) == want
end
-- What a gun can hit an instance in: its track's box at this frame when it has a hitbox
-- behaviour, its look's box otherwise. nil when the track has no box now.
function authorHitShape(instance)
@ -5683,6 +5767,17 @@ function Q.setupLevel(q, thisLevel)
q.iPathAend = 0
if q.swapScene then
q.swapScene()
else
-- The same answers, read from the description: the converter asked the game's swapScene
-- for every difficulty, level and scene, and kept the ones that differ. A game that still
-- carries the hook runs it, since a hook can be edited to do more than this describes.
local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0]
local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene]
if scene then
q.bSwap = true
q.iScene = scene
end
end
q.bFlip = false
if q.iScene > level[QTE.TOTALSCENES] then
@ -8682,6 +8777,17 @@ function K.setupLevel(q, thisLevel)
q.iPath, q.iPathAjmp, q.iPathAend = 0, 0, 0
if q.swapScene then
q.swapScene()
else
-- The same answers, read from the description: the converter asked the game's swapScene
-- for every difficulty, level and scene, and kept the ones that differ. A game that still
-- carries the hook runs it, since a hook can be edited to do more than this describes.
local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0]
local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene]
if scene then
q.bSwap = true
q.iScene = scene
end
end
q.bFlip = false
if q.iScene > level[QTE.TOTALSCENES] then

View file

@ -1214,6 +1214,7 @@ end
function forgeSave()
forgeIdFor(FORGE.game)
authorSave(FORGE.game, FORGE.path)
FORGE.dirty = false
FORGE.closing = false
@ -1304,10 +1305,13 @@ function forgeExport(folder, name)
if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then
video = base .. "/" .. video
end
-- The id travels with the release: it is what the master service keys scores and updates
-- on, and a game without one cannot be published. Every description has one from the moment
-- it is first saved (forgeIdFor).
dat = io.open(folder .. "/games.dat", "w")
if dat ~= nil then
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
title, base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tGAME_ID = %q,\n\t\tSCRIPT = %q,\n\t\tVIDEO = %q,\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
title, forgeIdFor(FORGE.game), base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
dat:close()
end
@ -1318,6 +1322,94 @@ function forgeExport(folder, name)
end
-- Moves a packed game into place, safely: what is there goes aside first, so a rename that fails
-- leaves the previous export where it was rather than a hole where it used to be. The old copy is
-- let go once the new one is in place -- pressing the key again rebuilds an export from the
-- description, so there is nothing here worth keeping a second copy of. The name it goes aside
-- under stops ending in ".game" on purpose, so the menu cannot list it as a second game.
function forgeReplaceFile(path, incoming)
local kept = nil
if lfs.attributes(path) then
local target = path .. ".old"
local n = 1
while lfs.attributes(target) and (n < 100) do
n = n + 1
target = path .. ".old-" .. n
end
if os.rename(path, target) then
kept = target
end
end
if not os.rename(incoming, path) then
if kept then
os.rename(kept, path)
end
return false
end
if kept then
os.remove(kept)
end
return true
end
-- Publish: export, pack, and send the package to the master service for review, in one press.
-- The service keeps it hidden until the operator approves it, and says which version it was
-- given; a later press sends a later version, which is reviewed on its own. The account is the
-- machine's, signed in through the menu's Online Account tool, so Forge signs nothing in itself.
function forgePublish()
if forgePublishing then
return
end
if masterSignedIn == nil then
dofile("Singe/Net.singe")
dofile("Singe/Master.singe")
masterLoad()
end
if not masterSignedIn() then
FORGE.message = "sign in first, in the menu's Online Account tool, then press P again"
forgeRefresh()
return
end
local folder = forgeExport(forgeReleaseFolder())
if not folder then
return
end
-- A file named .game is a container to the engine's own file lookup, and a script cannot read
-- one as a file; so the package is written under a working name, sent, and only then renamed
-- to what it is.
local file = folder .. ".game"
local working = file .. ".part"
if not singePack(folder, working) then
FORGE.message = "could not pack " .. folder
forgeRefresh()
return
end
forgePublishing = true
FORGE.message = "uploading " .. file
forgeRefresh()
masterUpload(working, nil,
function(sent, total)
FORGE.message = string.format("uploading: %d%%", total and math.floor(sent * 100 / total) or 0)
forgeRefresh()
end,
function(ok, reply)
forgePublishing = false
-- The package that was just sent takes its proper name, put there safely so a failure
-- leaves the previous export intact.
forgeReplaceFile(file, working)
if ok then
FORGE.message = string.format("sent for review as version %d; the service will list it once the operator approves it", reply.version or 0)
else
FORGE.message = "not sent: " .. tostring(reply)
end
forgeRefresh()
end)
end
-- Where X releases to: a folder in the data directory named for the game, which is also what
-- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the
-- folder and its name.
@ -4070,6 +4162,8 @@ function forgeKey(keysym, scancode)
forgeBuild(singeGetDataPath() .. "preview.singe")
elseif scancode == SCANCODE.X.value then
forgeExport(forgeReleaseFolder())
elseif scancode == SCANCODE.P.value then
forgePublish()
elseif scancode == SCANCODE.U.value then
forgeUndo()
elseif scancode == SCANCODE.R.value then
@ -4675,10 +4769,36 @@ end
-- Forge can write. Ones dropped into Forge's own directory are offered too, and opened as a copy
-- in the data directory, since inside a .game they are read only.
-- A game's id: the UUID the master service keys scores and updates on. Minted the first time a
-- description is saved and never changed after, so a game uploaded once can be updated for ever;
-- a description written by hand without one gets one the first time Forge saves it. Version 4,
-- from the engine's generator, which is as random as a game id needs to be.
function forgeIdFor(game)
if type(game.id) == "string" and game.id:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") then
return game.id
end
math.randomseed(os.time() + math.floor(os.clock() * 1000000))
local hex = {}
for index = 1, 32 do
local digit = math.random(0, 15)
if index == 13 then
digit = 4 -- the version nibble
elseif index == 17 then
digit = 8 + math.random(0, 3) -- the variant nibble: 8, 9, a, or b
end
hex[index] = string.format("%x", digit)
end
local text = table.concat(hex)
game.id = string.format("%s-%s-%s-%s-%s", text:sub(1, 8), text:sub(9, 12), text:sub(13, 16), text:sub(17, 20), text:sub(21, 32))
return game.id
end
-- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play
-- on, which is the point; everything in it can be changed or deleted.
function forgeTemplate()
return {
id = forgeIdFor({}),
title = "New game",
layers = { { kind = "world2d", gravity = 1500 } },
vars = { score = 0 },
@ -4944,6 +5064,10 @@ if not FORGE_LIBRARY then
function onOverlayUpdate()
-- The master service's client, once Publish has loaded it, needs a turn every frame.
if masterPump ~= nil then
masterPump()
end
if FORGE.game == nil then
forgeChooserDraw()
else

View file

@ -110,9 +110,12 @@ local function request(method, endpoint, body, onDone, opts)
if MASTER.token and not opts.anonymous then
headers["Authorization"] = "Bearer " .. MASTER.token
end
if opts.bodyFile then
headers["Content-Type"] = opts.contentType or "application/octet-stream"
end
netRequest({
url = MASTER.url .. endpoint, method = method, headers = headers,
body = body and cjson.encode(body) or nil,
body = body and cjson.encode(body) or nil, bodyFile = opts.bodyFile,
toFile = opts.toFile, onProgress = opts.onProgress,
}, function(ok, result)
if not ok then
@ -247,9 +250,84 @@ function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
end
function masterTellInstalled(gameId, version, onDone)
request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil },
onDone or function() end)
-- What this machine holds, and what is newer. The list is the menu's own, read off every
-- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which
-- of these have a newer version and which it has never heard of. No sign-in needed to ask.
function masterVersions(held, onDone)
local games = {}
for _, entry in ipairs(held) do
games[#games + 1] = { id = entry.id, version = math.floor(entry.version or 0) }
end
request("POST", "/v1/versions", { games = games }, function(ok, reply)
if ok then
for _, update in ipairs(reply.updates or {}) do
update.installed = update.installed and math.floor(update.installed) or 0
update.latest = update.latest and math.floor(update.latest) or nil
update.size = update.size and math.floor(update.size) or nil
end
end
onDone(ok, ok and (reply.updates or {}) or reply, ok and (reply.unknown or {}) or nil)
end)
end
-- Send a packed game up for review. The file goes as the body, from disk, so a big game is not
-- held in memory; onProgress(sent, total) says how it goes, and onDone(ok, reply) ends with the
-- version number the service gave it and the state it starts in, which is "pending".
function masterUpload(file, notes, onProgress, onDone)
local endpoint = "/v1/upload"
if notes and notes ~= "" then
endpoint = endpoint .. "?notes=" .. notes:gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", c:byte())
end)
end
request("POST", endpoint, nil, function(ok, reply)
if ok and reply.version then
reply.version = math.floor(reply.version)
end
onDone(ok, reply)
end, { bodyFile = file, onProgress = onProgress })
end
-- Is there a newer Singe for this machine? Public, no sign-in: the answer is the newest release
-- for the platform when it is newer than the version running, or newer = false.
function masterRelease(platform, running, onDone)
request("GET", "/v1/release/" .. platform .. "?version=" .. tostring(running), nil, function(ok, reply)
onDone(ok, reply)
end, { anonymous = true })
end
-- Fetch one release to a file, checked against the digest the service published, exactly as a
-- game is; a binary that arrives wrong must not be put beside the one that works.
function masterDownloadRelease(platform, version, toFile, expectSha, onProgress, onDone)
request("GET", "/v1/release/" .. platform .. "/" .. version, 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, anonymous = true })
end
-- This account's own games, every version with the review's word on it.
function masterMine(onDone)
request("GET", "/v1/catalogue/mine", nil, function(ok, reply)
onDone(ok, ok and (reply.games or {}) or reply)
end)
end

View file

@ -25,6 +25,7 @@
<div id="developer" class="line"></div>
<div id="publisher" class="line"></div>
<div id="port" class="line"></div>
<div id="notice" class="line"></div>
<div id="description"></div>
</div>
<div id="footer">

View file

@ -94,6 +94,7 @@ function loadGameAssets()
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
end
MENU_RENDER.showGame(game)
menuRefreshNotice()
end
@ -186,6 +187,8 @@ function onInputPressed(what)
else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
end
elseif what == SWITCH_BUTTON3 and menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
menuUpdateGame()
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
gameStart()
end
@ -337,6 +340,146 @@ for dir in lfs.dir(".") do
end
table.sort(GAME_LIST, compareTitles)
-- What this machine holds, by the id and version each games.dat carries. VERSION is stamped
-- into a packed game by the service when it is published; a game without one is version 0.
-- This table is the only record of installed versions anywhere: the service keeps none.
MENU_VERSIONS = {}
MENU_UPDATES = {}
for _, game in ipairs(GAME_LIST) do
if game.GAME_ID then
MENU_VERSIONS[game.GAME_ID] = math.floor(tonumber(game.VERSION) or 0)
end
end
-- Asks the service which of the games here have a newer version. No sign-in is needed to ask;
-- the answer arrives whenever it arrives and the renderers pick it up. Called once at startup
-- and again whenever the Get Games tool opens.
function menuCheckVersions()
local held = {}
for id, version in pairs(MENU_VERSIONS) do
held[#held + 1] = { id = id, version = version }
end
if #held == 0 then
return
end
masterVersions(held, function(ok, updates)
if not ok then
return
end
MENU_UPDATES = {}
for _, update in ipairs(updates) do
MENU_UPDATES[update.id] = update
end
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
end)
end
-- The newer version the service offers for a game, or nil.
function menuUpdateFor(game)
return game.GAME_ID and MENU_UPDATES[game.GAME_ID] or nil
end
-- Fetches the newer version of the selected game into place, saying how it goes where the
-- renderers show a notice. The download needs a sign-in, which the Online Account tool does.
function menuUpdateGame()
local game = GAME_LIST[GAME_SELECTED]
local update = menuUpdateFor(game)
if not update then
return false
end
if not masterSignedIn() then
menuNotice("Sign in first, in the Online Account tool (SERVICE), to fetch the update.")
return true
end
if MENU_UPDATING then
return true
end
MENU_UPDATING = true
shopInstall({ id = update.id, slug = update.slug, title = update.title or game.TITLE,
version = update.latest, sha256 = update.sha256, target = game.CONTAINER },
function(text)
menuNotice("Updating: " .. text)
end,
function(ok, text)
MENU_UPDATING = false
-- The list first, since refreshing it resets the notice to whatever the selection
-- warrants; then what happened, which stays until the selection moves.
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
menuNotice(ok and (text .. " Version " .. update.latest .. " plays from now on.") or text)
end)
return true
end
-- One line the renderers show under the game, for whatever the menu has to say.
MENU_NOTICE = nil
function menuNotice(text)
MENU_NOTICE = text
if MENU_RENDER.notice then
MENU_RENDER.notice(text)
end
end
-- The notice for the selected game when nothing is in flight: the update on offer, or, failing
-- that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
function menuRefreshNotice()
if MENU_UPDATING then
return
end
local update = GAME_SELECTED and menuUpdateFor(GAME_LIST[GAME_SELECTED]) or nil
if update then
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
elseif MENU_RELEASE then
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
else
menuNotice(nil)
end
end
-- Once a day, unless the settings say never: is there a newer Singe for this machine? The
-- answer is only a line on screen and a service tool; nothing is fetched or changed until a
-- person opens that tool and says so. The last look is remembered in the save store.
MENU_RELEASE = nil
local RELEASE_CHECK_EVERY = 24 * 60 * 60
function menuCheckRelease(force)
if not force and SINGE_UPDATE_CHECK == false then
return
end
local info = singeGetSystemInfo()
local last = tonumber(saveGet("releaseCheckedAt", 0)) or 0
if not force and (os.time() - last) < RELEASE_CHECK_EVERY then
MENU_RELEASE = saveGet("releaseOffered", nil)
if type(MENU_RELEASE) ~= "table" then
MENU_RELEASE = nil
end
menuRefreshNotice()
return
end
masterRelease(info.platform, info.version, function(ok, reply)
if not ok then
return
end
saveSet("releaseCheckedAt", os.time())
MENU_RELEASE = reply.newer and reply or nil
saveSet("releaseOffered", MENU_RELEASE or false)
menuRefreshNotice()
end)
end
if GAME_COUNT == 0 then
debugPrint("No games found! Exiting.")
singeQuit()
@ -391,6 +534,8 @@ else
dofile("Singe/MenuOverlay.singe")
end
MENU_RENDER.begin()
menuCheckVersions()
menuCheckRelease(false)
MENU_RENDER.layout()
MENU_RENDER.buildList()

View file

@ -260,7 +260,9 @@ MENU_RENDER.buildList = function()
local rows = {}
for index, game in ipairs(GAME_LIST) do
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. '</div>'
local badge = menuUpdateFor(game) and '<span class="badge">update</span>' or ""
rows[index] = '<div id="game' .. index .. '">' .. menuEscape(game.TITLE) .. badge .. '</div>'
end
menuElement("list").inner_rml = table.concat(rows)
for index = 1, GAME_COUNT do
@ -269,6 +271,21 @@ MENU_RENDER.buildList = function()
end
-- The service answered about versions, or an update landed: the badges and the notice change.
MENU_RENDER.refreshList = function()
MENU_RENDER.buildList()
if GAME_SELECTED then
menuRow(GAME_SELECTED):SetClass("selected", true)
menuRefreshNotice()
end
end
MENU_RENDER.notice = function(text)
guiSetValue(GUI, DOCUMENT, "notice", menuEscape(text or ""))
end
MENU_RENDER.showGame = function(game)
menuRow(GAME_SELECTED):SetClass("selected", true)
scrollPending = true

View file

@ -83,6 +83,7 @@ local spriteCabinet = nil
local spriteMarquee = nil
local textSprites = {}
local textShadows = {}
local noticeText = nil -- One line under the details: an update on offer, or how a fetch is going.
local textFirst = 0 -- The first line of the details on show, which walks by itself.
local textDown = true -- and which way it is walking.
local textClock = 0
@ -397,6 +398,18 @@ MENU_RENDER.buildList = function()
end
-- The service answered about versions, or an update landed. The badge is read every frame, so
-- only the notice has to be brought up to date.
MENU_RENDER.refreshList = function()
menuRefreshNotice()
end
MENU_RENDER.notice = function(text)
noticeText = text
end
MENU_RENDER.showGame = function(game)
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
@ -508,6 +521,9 @@ MENU_RENDER.frame = function()
-- Which game this is, since there is no list to look at. It heads the details rather than
-- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.
local title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)
if menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
title = title .. " [update]"
end
colorForeground(0, 0, 0, 255)
fontPrint(textX + SHADOW, textY + SHADOW, title)
@ -552,6 +568,12 @@ MENU_RENDER.frame = function()
-- there was nothing at all, so the only way to learn that up and down move one game and left
-- and right move a page was to be told.
fontSelect(hintFont)
if noticeText and noticeText ~= "" then
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY - LINE_HEIGHT + SHADOW, noticeText)
colorForeground(255, 211, 90, 255)
fontPrint(hintsX, hintsY - LINE_HEIGHT, noticeText)
end
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
colorForeground(178, 196, 224, 255)

View file

@ -150,7 +150,9 @@ local function buildRequest(request)
for name, value in pairs(request.headers or {}) do
lines[#lines + 1] = name .. ": " .. value
end
if request.body then
if request.bodyFile then
lines[#lines + 1] = "Content-Length: " .. request.bodySize
elseif request.body then
lines[#lines + 1] = "Content-Length: " .. #request.body
end
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
@ -221,10 +223,16 @@ local function pumpHandshake(request)
end
-- The head goes first as one string. A body from a file follows it a piece at a time, read as
-- it is sent, so a game is never held in memory whole: the same reason a download goes straight
-- to disk.
local NET_SEND_CHUNK = 65536
local function pumpSending(request)
if not request.outgoing then
request.outgoing = buildRequest(request)
end
if request.sent < #request.outgoing then
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
if sent then
@ -234,8 +242,53 @@ local function pumpSending(request)
else
return fail(request, "send failed: " .. tostring(err))
end
if request.sent >= #request.outgoing then
if request.sent < #request.outgoing then
return
end
request.lastProgress = singeGetTicks()
end
if not request.bodyFile then
request.state = "reading"
return
end
-- The body, from the file.
if not request.bodyHandle then
request.bodyHandle = io.open(request.bodyFile, "rb")
request.bodySent = 0
request.chunk = nil
request.chunkSent = 0
if not request.bodyHandle then
return fail(request, "cannot read " .. request.bodyFile)
end
end
if not request.chunk then
request.chunk = request.bodyHandle:read(NET_SEND_CHUNK)
request.chunkSent = 0
if not request.chunk then
request.bodyHandle:close()
request.bodyHandle = nil
request.state = "reading"
return
end
end
local sent, err, partial = request.socket:send(request.chunk, request.chunkSent + 1)
if sent then
request.chunkSent = sent
elseif err == "timeout" then
request.chunkSent = partial or request.chunkSent
else
return fail(request, "send failed: " .. tostring(err))
end
if request.chunkSent > 0 then
request.lastProgress = singeGetTicks()
end
if request.chunkSent >= #request.chunk then
request.bodySent = request.bodySent + #request.chunk
request.chunk = nil
if request.onProgress then
request.onProgress(request.bodySent, request.bodySize)
end
end
end
@ -442,9 +495,19 @@ function netRequest(opts, onDone)
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(),
bodyFile = opts.bodyFile, toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(), sent = 0,
}
-- A body sent from a file: its length has to be known before the head goes out.
if request.bodyFile then
local handle = io.open(request.bodyFile, "rb")
if not handle then
onDone(false, { error = "cannot read " .. tostring(request.bodyFile) })
return nil
end
request.bodySize = handle:seek("end")
handle:close()
end
nextId = nextId + 1
active[request.id] = request
beginConnect(request)

View file

@ -1026,6 +1026,162 @@ TOOLS[#TOOLS + 1] = {
}
-- ===== 10b. Engine update =====
-- A newer Singe, when the service has one for this platform: what is running, what is offered,
-- and one button that fetches it, checks it against the published digest, puts it beside the
-- running one under the release's own name, sets the old one aside as old-<name> so the launcher
-- cannot pick it, and starts the new one. Nothing happens until Button 1; the daily check in
-- the menu only puts a line on screen. The engine's own support files are not touched: the new
-- binary unpacks its own on its first run, as it does today.
local updateSaying = ""
local updateBusy = false
local function updateShow()
local info = singeGetSystemInfo()
local rows = {}
rows[#rows + 1] = toolsPair("Running", info.version .. " (" .. tostring(info.platform) .. ")")
rows[#rows + 1] = toolsPair("Executable", info.executable or "?")
if MENU_RELEASE then
rows[#rows + 1] = toolsPair("Offered", MENU_RELEASE.version .. " (" .. tostring(MENU_RELEASE.size) .. " bytes)")
if MENU_RELEASE.notes and MENU_RELEASE.notes ~= "" then
rows[#rows + 1] = toolsSub(MENU_RELEASE.notes)
end
else
rows[#rows + 1] = toolsPair("Offered", "nothing newer, as of the last look")
end
if SINGE_UPDATE_CHECK == false then
rows[#rows + 1] = toolsSub("The daily look is off (no_update_check in settings.cfg); Button 3 looks now.")
end
rows[#rows + 1] = toolsText(updateSaying)
if MENU_RELEASE and not updateBusy then
rows[#rows + 1] = toolsKeys("Button 1: download and restart into " .. MENU_RELEASE.version .. " Button 3: look again Button 2: back")
else
rows[#rows + 1] = toolsKeys("Button 3: look again Button 2: back")
end
toolsShow(rows)
end
local function updateFetch()
local info = singeGetSystemInfo()
local offered = MENU_RELEASE
if not offered or updateBusy or not info.executable then
return
end
local folder = info.executable:match("^(.*[/\\])") or ""
local newPath = folder .. offered.name
local partial = singeGetDataPath() .. offered.name .. ".part"
updateBusy = true
updateSaying = "Starting..."
updateShow()
masterDownloadRelease(info.platform, offered.version, partial, offered.sha256,
function(got, total)
updateSaying = total and string.format("Fetching: %d%% (%d of %d bytes)", math.floor(got * 100 / total), got, total)
or string.format("Fetching: %d bytes", got)
updateShow()
end,
function(ok, result)
updateBusy = false
if not ok then
updateSaying = "Sorry: " .. tostring(result)
updateShow()
return
end
-- Beside the running binary, under its own name. Only now, with the digest checked.
-- Anything already there is a leftover from an earlier try at the same version.
if not replaceFile(newPath, partial) then
-- A data directory on another disk: copy instead.
local from, to = io.open(partial, "rb"), io.open(newPath, "wb")
if from and to then
to:write(from:read("a"))
from:close()
to:close()
os.remove(partial)
else
updateSaying = "Downloaded, but could not put it beside the running Singe."
updateShow()
return
end
end
-- The one running is set aside under a name the launchers' Singe* glob cannot match,
-- so a start by hand after this can only find the new one. Renaming a running
-- executable is allowed everywhere, Windows included, as long as the new name is on
-- the same volume; deleting one is refused there, and it is not deleted here.
--
-- The rename can still be refused: a folder this user cannot write, or a scanner
-- holding the file open on Windows. That is survivable, since the launcher runs the
-- highest version it finds and the new one is higher, but it is not something to
-- swallow -- somebody looking at the folder later deserves to know why the old binary
-- is still in it.
local oldName = info.executable:match("([^/\\]+)$") or "Singe"
local aside = folder .. "old-" .. oldName
local moved, why = os.rename(info.executable, aside)
if moved then
updateSaying = "Restarting into " .. offered.version .. "..."
else
debugPrint(string.format("Engine Update: %s could not be set aside as old-%s (%s).", oldName, oldName, tostring(why)))
debugPrint(string.format("Engine Update: it stays in the folder; the launcher runs the newest Singe there, which is %s.", offered.name))
updateSaying = "Restarting into " .. offered.version .. ". " .. oldName .. " could not be set aside, so it stays in the folder; the launcher still starts the newer one."
end
updateShow()
saveSet("releaseOffered", false)
saveSet("releaseCheckedAt", 0)
-- --refresh on the way in: the new Singe puts the old support folder aside as
-- old-Singe-<version> and writes a fresh one, so a file this version no longer ships
-- does not linger. Nothing is deleted; the kept folder is the operator's to remove.
if not singeRelaunch(newPath, "--refresh") then
if moved then
os.rename(aside, info.executable)
end
updateSaying = "The new Singe is beside this one, but it could not be started; start it by hand."
updateShow()
end
end)
end
TOOLS[#TOOLS + 1] = {
name = "Engine Update",
help = "A newer Singe from the service, fetched and started when you say so.",
begin = function()
updateSaying = ""
updateShow()
end,
input = function(what)
if what == SWITCH_BUTTON1 and MENU_RELEASE and not updateBusy then
updateFetch()
return true
elseif what == SWITCH_BUTTON3 and not updateBusy then
updateSaying = "Looking..."
updateShow()
local info = singeGetSystemInfo()
masterRelease(info.platform, info.version, function(ok, reply)
if ok then
MENU_RELEASE = reply.newer and reply or nil
saveSet("releaseCheckedAt", os.time())
saveSet("releaseOffered", MENU_RELEASE or false)
updateSaying = reply.newer and ("Singe " .. reply.version .. " is offered.") or "This is the newest Singe for this machine."
menuRefreshNotice()
else
updateSaying = "Could not ask: " .. tostring(reply)
end
updateShow()
end)
return true
end
return false
end,
}
-- ===== 11. Online account =====
-- Signing this cabinet in to the master service, and saying where that service is. It belongs
@ -1189,12 +1345,62 @@ local function shopLocalName(game)
end
-- Puts a file aside instead of writing over it: "Rocks.game" becomes "Rocks.game.old", or the
-- first free ".old-2" after that. The name deliberately stops ending in ".game", so the menu does
-- not list the kept copy as a second game. Answers the name it was kept under, or nil when there
-- was nothing there.
--
-- Used two ways. For something there is no getting back -- the running engine -- the copy is kept
-- for good and removing it is the operator's business. For something the service or the editor
-- can make again, replaceFile below uses it only to make the swap safe and then lets it go: a
-- game is gigabytes and every version of it is still on the service, so keeping one would cost a
-- cabinet its disk to save something nobody needs saving.
function keepAside(path)
local target = path .. ".old"
local n = 1
if not lfs.attributes(path) then
return nil
end
while lfs.attributes(target) and (n < 100) do
n = n + 1
target = path .. ".old-" .. n
end
if os.rename(path, target) then
return target
end
return nil
end
-- Moves a downloaded file into place, safely: what is there goes aside first, so a rename that
-- fails leaves the working copy where it was rather than a hole where it used to be. The old copy
-- is let go once the new one is in place, because everything this is used for -- a game, an
-- export -- can be had again. Answers true, or false and what to tell the person.
function replaceFile(path, incoming)
local kept = keepAside(path)
if not os.rename(incoming, path) then
if kept then
os.rename(kept, path)
end
return false, "could not put it in place; nothing was changed"
end
if kept then
os.remove(kept)
end
return true
end
local function shopInstalledVersion(game)
-- What is on this machine, which is not necessarily what the service was last told.
-- What is on this machine, read off the games.dat the menu loaded (VERSION, stamped into the
-- package by the service when it was published), or 0 for a copy that predates that. The
-- service keeps no record of it; this machine is the only one that knows.
if not lfs.attributes(shopLocalName(game)) then
return nil
end
return game.installed or 0
return (MENU_VERSIONS and MENU_VERSIONS[game.id]) or 0
end
@ -1271,6 +1477,45 @@ local function shopRefresh()
end
-- Fetch one version of a game into place. Shared by the Get Games tool and by the menu's own
-- update button, so there is one download path. game needs id, slug, title, version, and sha256;
-- onProgress(text) is called as it goes and onDone(ok, text) once, with what to tell the player.
function shopInstall(game, onProgress, onDone)
-- An update goes over the file that holds the game, whatever it is called; a fresh download
-- takes the service's name for it.
local target = game.target or shopLocalName(game)
local partial = target .. ".part"
onProgress("Starting...")
masterDownload(game.id, game.version, partial, game.sha256,
function(got, total)
onProgress(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)
if not ok then
return onDone(false, "Sorry: " .. tostring(result))
end
-- Only now is the old copy touched, and the swap is made so that a failure leaves the
-- working copy where it was.
local moved, why = replaceFile(target, partial)
if not moved then
return onDone(false, "Downloaded, but " .. why .. ".")
end
-- The file itself now says which version it is; the menu remembers until it next
-- reads the games.dat inside it.
MENU_VERSIONS = MENU_VERSIONS or {}
MENU_VERSIONS[game.id] = game.version
if MENU_UPDATES then
MENU_UPDATES[game.id] = nil
end
onDone(true, game.title .. " is ready. " .. result.bytes .. " bytes.")
end)
end
local function shopDownload()
local game = shopGames[shopSelected]
@ -1282,35 +1527,16 @@ local function shopDownload()
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)
shopInstall(game,
function(text)
shopProgress = text
end,
function(ok, result)
function(ok, text)
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
shopSaying = text
shopShow()
end)
end
@ -1326,10 +1552,9 @@ local function shopRemove()
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)
if MENU_VERSIONS and game.id then
MENU_VERSIONS[game.id] = nil
end
end
shopShow()
@ -1341,6 +1566,8 @@ TOOLS[#TOOLS + 1] = {
help = "Everything the service offers: download, update, remove.",
begin = function()
-- Fresh word on what is newer than what is here, for the badges and for this page.
menuCheckVersions()
shopProgress = nil
if masterSignedIn() and #shopGames == 0 then
shopRefresh()

View file

@ -115,6 +115,22 @@ body {
color: #8fa2c4;
}
/* A game with a newer version on the service wears a small mark after its title, and the line
under its details says which version and which button. Gold, like the selection, since it is
the one thing on the page asking for a press. */
#list div .badge {
margin-left: 6dp;
padding: 0 4dp;
font-size: 10dp;
color: #101828;
background-color: #ffd35a;
border-radius: 3dp;
}
#notice {
color: #ffd35a;
}
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
page taken out of view has to be said again here or its display wins. */

View file

@ -113,6 +113,7 @@
-- Cabinets and front ends ------------------------------------------------
-- idleexit = 0 -- quit after this many seconds with no input; 0 never does
-- no_update_check = false -- never look for a newer Singe from the menu (Engine Update still works by hand)
-- fvalue = 0 -- one number handed to the game, which reads it with getFValue()

View file

@ -1,39 +1,100 @@
#!/usr/bin/env bash
# Build the Singe manual from docs/Manual.adoc, the Forge manual from docs/Forge.adoc, and the
# beginner's course from docs/Learn.adoc. Produces .builddir/Manual.html, Manual.pdf, Forge.html,
# Forge.pdf, Learn.html and Learn.pdf. The version comes from CMakeLists.txt.
# Build the four books: the user manual (docs/Manual.adoc, which ships inside the binary), the
# programming reference (docs/Reference.adoc), the Forge manual (docs/Forge.adoc), and the
# beginner's course (docs/Learn.adoc). Produces .builddir/<Book>.html and .pdf for each, and then
# packs the three books that do not ship in the binary, with the lesson files and the licence, into
# .builddir/Singe-Docs-v<version>.zip, the optional documentation download. The version comes from
# CMakeLists.txt.
#
# ./build-docs.sh the books as they ship
# ./build-docs.sh preview the same, with PREVIEW across every page, for proofreaders; the
# download is then named -preview so it cannot be mistaken for a
# release, and the binary's own copy of the manual (rendered by
# CMake, not here) is never touched.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
preview=
suffix=
if [[ "${1:-}" == "preview" ]]; then
preview=1
suffix=-preview
elif [[ -n "${1:-}" ]]; then
echo "usage: $0 [preview]" >&2
exit 2
fi
src=$here/docs/Manual.adoc
forge=$here/docs/Forge.adoc
learn=$here/docs/Learn.adoc
ref=$here/docs/Reference.adoc
for doc in "$src" "$forge" "$learn"; do
for doc in "$src" "$forge" "$learn" "$ref"; do
if [[ ! -f $doc ]]; then
echo "error: $doc not found" >&2
exit 1
fi
done
for tool in asciidoctor asciidoctor-pdf lua5.4; do
for tool in asciidoctor asciidoctor-pdf lua5.4 zip; do
if ! command -v "$tool" > /dev/null; then
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
exit 1
fi
done
version=$(sed -n 's/^project(singe2 VERSION \([0-9.]*\).*/\1/p' "$here/CMakeLists.txt")
# The version, from the four numbers of the project() line: patch 1 means a beta and tweak says
# which, so 3.00.1.2 is the books' "3.00b2", exactly as the binary is named.
read -r major minor patch tweak <<< "$(sed -n 's/^project(singe2 VERSION \([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1 \2 \3 \4/p' "$here/CMakeLists.txt")"
if [[ $patch == 1 ]]; then
version="${major}.${minor}b${tweak}"
else
version="${major}.${minor}"
fi
# The watermark, when asked for: a page background for the PDFs, a head snippet for the HTML.
pdfOpts=()
# The HTML pages carry their pictures inside them (data-uri), so a page is one file wherever it is
# copied and the download needs no images folder beside it.
htmlOpts=(-a data-uri)
if [[ -n $preview ]]; then
pdfOpts=(-a "page-background-image=image:$here/docs/images/preview.svg[fit=fill]")
htmlOpts+=(-a docinfo=shared -a "docinfodir=$here/docs/preview")
fi
out=$here/.builddir
mkdir -p "$out"
# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it.
lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc"
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html"
asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf"
asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html"
asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
asciidoctor -a revnumber="$version" "$learn" -o "$out/Learn.html"
asciidoctor-pdf -a revnumber="$version" "$learn" -o "$out/Learn.pdf"
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.html"
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.pdf"
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.html"
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.html"
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.pdf"
asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$ref" -o "$out/Reference.html"
asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$ref" -o "$out/Reference.pdf"
# The documentation download: everything but the user manual, which the binary carries. Built in
# a staging folder so the zip has one top-level directory and no stray build files.
stage=$out/Singe-Docs-v$version$suffix
rm -rf "$stage" "$stage.zip"
mkdir -p "$stage/learn"
cp "$out/Reference.pdf" "$out/Reference.html" "$out/Forge.pdf" "$out/Forge.html" "$out/Learn.pdf" "$out/Learn.html" "$stage/"
cp -r "$here/docs/learn/." "$stage/learn/"
cp "$here/COPYING" "$stage/"
cat > "$stage/README.txt" <<EOF
Singe $version documentation
Reference.pdf / .html Everything a game can do: the programming model and every function.
Learn.pdf / .html Learn to Program with Singe: thirty lessons from nothing to a finished game.
Forge.pdf / .html Forge, the authoring tool: ten tutorials and the vocabulary.
learn/ The finished script of every lesson, and the art kit the lessons use.
COPYING The licence, GPL version 3.
The user manual, Manual.pdf, is inside Singe itself and is unpacked into the Singe folder the first
time it runs. Forge is a separate download.
EOF
(cd "$out" && zip -q -r "Singe-Docs-v$version$suffix.zip" "Singe-Docs-v$version$suffix")
rm -rf "$stage"
echo "built:"
ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Forge.html" "$out/Forge.pdf" "$out/Learn.html" "$out/Learn.pdf"
ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Reference.html" "$out/Reference.pdf" "$out/Forge.html" "$out/Forge.pdf" "$out/Learn.html" "$out/Learn.pdf" "$out/Singe-Docs-v$version$suffix.zip"

View file

@ -441,7 +441,9 @@ string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
# The built file carries the platform's executable suffix; the copy in .builddir/ carries the
# preset's, which is the same thing for Windows and lets an experimental build live beside another.
set(singeBinaryBase "Singe-v${PROJECT_VERSION}-${osInitial}${osRest}-${KANGAROO_ARCH}")
# SINGE_VERSION_FULL, not PROJECT_VERSION: the four numbers CMake needs are not the version
# anybody sees, and a beta build is named v3.00b2.
set(singeBinaryBase "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
set(singeBinaryName "${singeBinaryBase}${SINGE_SUFFIX}")
set(singeBinary ${SB_PREFIX}/singe)
ExternalProject_Add(singe

View file

@ -22,7 +22,8 @@ Singe Lua written for you. It is itself a Singe game and is distributed on
its own, beside the engine rather than inside it. This document covers the
description format, the vocabulary, the compiler, and the editor. The engine
calls the generated code makes (`playerNew`, `collidePointRect`,
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual.
`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe
Reference.
== Tutorials
@ -1587,10 +1588,16 @@ event -- and may define any Lua the entries emit or call. A behaviour's
action's `emit` returns the Lua a rule compiles to. A behaviour that reads a
key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`,
and one that wants the presses about nothing in particular -- a trigger --
sets `instance.hearsAll` and reads them in `on`. The five ActionMax games are
the working example: their `lightSensor` behaviour, which judges a shot the
way the emulator did by the disc's pixels under the gun, lives in a
`Vocabulary.singe` beside them and in no game that is not theirs.
sets `instance.hearsAll` and reads them in `on`.
A word that turns out to be general belongs in the framework rather than
beside one game. `lightSensor`, which judges a shot by the picture under the
gun, began as the five ActionMax games' own vocabulary and is now one of the
behaviours above, with `pictureAt` beside it as a condition any rule can ask;
those five name no vocabulary at all any more. The test scene's `bob` is the
working example instead, and the rule to apply is the same one: a vocabulary
is for a kind of thing this game needs and nothing else does. The moment two
games would want it, move it.
[source,lua]
----
@ -1879,6 +1886,16 @@ for every named file there first, so the release runs wherever it is put.
`--pack` turns that directory into a `.game` like any other, and it runs on a
machine that has never had Forge on it.
`P` goes one step further: it exports, packs, and sends the package to the
master service for review, in one press. It is not in the catalogue until it
has been reviewed, and Forge says which version number it was given. Press
again after a change and a later version goes up on its own, without taking an
earlier one away from anybody who already has it. The account is the machine's,
signed in through the menu's Online Account tool; Forge signs nothing in
itself, and says so if the machine is not signed in. Every description has a
permanent `GAME_ID` from the first time it is saved, which is what a game is
known by, so a game published once can be updated for ever.
Every built game finds its own directory to load that runtime from, using
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
engine was *launched* with, so a game reached by `dofile` -- a test, a

View file

@ -154,6 +154,10 @@
| Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
| --
| `lightSensor`
| Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead.
| `high` (number), `left` (number), `low` (number), `player` (number), `top` (number), `trigger` (switch), `x` (number), `y` (number)
| `mover`
| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.
| `clamp` (boolean), `speed` (number)
@ -430,6 +434,10 @@
| Only the first time this rule would run -- per instance and per room unless scope is "game".
| `scope` (string), `tag` (string)
| `pictureAt`
| The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing.
| `high` (number), `is` (string), `low` (number), `x` (number), `y` (number)
| `pointerIn`
| A player's pointer is over an instance.
| `entity` (entity), `player` (number)

View file

@ -108,10 +108,11 @@ different place, for someone who does not want to write code at all. If you
came here to learn to program, you are in the right place. You do not need to
read the Forge book, and this one does not build on it.
The _Singe Manual_ is the reference: every function, every argument, every
return value. It is not a tutorial and does not try to be one. You will start
reaching for it around lesson six, and by the end of this book you will use it
more than you use this book.
The _Singe Reference_ is the reference: every function, every argument, every
return value, and the programming model behind them. It is not a tutorial and
does not try to be one. You will start reaching for it around lesson six, and
by the end of this book you will use it more than you use this book. It comes
in the same download as this one.
== The Series

File diff suppressed because it is too large Load diff

18605
docs/Reference.adoc Normal file

File diff suppressed because it is too large Load diff

BIN
docs/images/learn/09-pictures.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/10-sound.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/11-hitting-things.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/12-score.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/13-game-folder.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/20-gui.png (Stored with Git LFS)

Binary file not shown.

BIN
docs/images/learn/25-particles.png (Stored with Git LFS)

Binary file not shown.

9
docs/images/preview.svg Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- The proofreading watermark: laid under every page of a PDF built with ./build-docs.sh
preview, and never under a release build. One word, diagonal, faint enough to read through. -->
<svg xmlns="http://www.w3.org/2000/svg" width="595" height="842" viewBox="0 0 595 842">
<text x="297" y="421" text-anchor="middle" dominant-baseline="middle"
font-family="Helvetica" font-weight="bold" font-size="150"
fill="#000000" fill-opacity="0.11"
transform="rotate(-50 297 421)">PREVIEW</text>
</svg>

After

Width:  |  Height:  |  Size: 565 B

View file

@ -8,9 +8,9 @@ local STAR_COUNT = 40
local START_LIVES = 3
local HIT_SCORE = 10
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local starSprite = spriteLoad(DIR .. "art/star.png")
local screenWidth = overlayGetWidth()

View file

@ -8,9 +8,9 @@ local STAR_COUNT = 40
local START_LIVES = 3
local HIT_SCORE = 10
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local starSprite = spriteLoad(DIR .. "art/star.png")
local shootClip = soundLoad(DIR .. "art/shoot.wav")

View file

@ -16,9 +16,9 @@ local SHOT_HEIGHT = 10
local SHOT_SPEED = 6
local SHOW_BOXES = false
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")

View file

@ -21,9 +21,9 @@ local TITLE = "title"
local PLAYING = "playing"
local OVER = "over"
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")

View file

@ -21,9 +21,9 @@ local TITLE = "title"
local PLAYING = "playing"
local OVER = "over"
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")

View file

@ -6,7 +6,7 @@ singeSetPauseKeyEnabled(false)
local SHIP_Y = 300
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
local shipX = 0
local speed = 2

View file

@ -6,7 +6,7 @@ local SHIP_HEIGHT = 24
local SHIP_SPEED = 120
local RESPAWN_MS = 1400
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local sparkSprite = spriteLoad(DIR .. "art/star.png")
local boomSound = soundLoad(DIR .. "art/boom.wav")

View file

@ -4,9 +4,9 @@ The art kit for "Learn to Program with Singe"
Copy this folder next to your script, keeping the name "art", and the lessons
will find these files where they say they are.
ship.png 32 x 24 A ship pointing up.
rock.png 24 x 24 An asteroid.
shot.png 4 x 10 A bullet.
spaceship.png 32 x 24 A ship pointing up.
asteroid.png 24 x 24 An asteroid.
bullet.png 4 x 10 A bullet.
star.png 8 x 8 A small star, also used as a spark.
walk.png 96 x 32 Four frames of a walk cycle, each 24 x 32, side by side.
shoot.wav A shot.

View file

@ -133,7 +133,7 @@ first thing beginners get wrong.
`onOverlayUpdate`, which asks "is the player holding left?" sixty times a
second. If you moved the player inside `onInputPressed` instead, you would get
one step per press: Singe sends a press exactly once when the key goes down
and never repeats it, which the manual's entry for `onInputPressed` says
and never repeats it, which the reference's entry for `onInputPressed` says
plainly. Holding the key would do nothing at all.
So the press handler records what is true about the world, and the update
@ -753,7 +753,7 @@ draws the player, sets red, and then draws all the blocks without mentioning
colour again.
It does not affect `overlayPrint`. The console font is copied onto the overlay
exactly as it is, in its own colours, which the manual's entry for
exactly as it is, in its own colours, which the reference's entry for
`overlayPrint` says in as many words. Coloured text needs a loaded font, and
that is lesson nineteen.

View file

@ -244,7 +244,7 @@ The sibling message counts rather than checks:
That is an `overlayBox` with a corner missing. The odd-looking `argument(s)` is
the engine's, not a typo of yours.
When you get one of these, the manual's entry for that function is the answer,
When you get one of these, the reference's entry for that function is the answer,
and it will take you a minute. Every entry lists the arguments in order with
what each one has to be. Singe checks them because the alternative is a game
that runs for ten minutes and then draws garbage.

View file

@ -27,9 +27,9 @@ singe/ <- your work folder
rocks/
rocks.singe
art/
ship.png
rock.png
shot.png
spaceship.png
asteroid.png
bullet.png
star.png
walk.png
shoot.wav
@ -93,7 +93,7 @@ Start a new file next to your `art` folder and type this in.
----
dofile("Singe/Framework.singe")
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
function onOverlayUpdate()
@ -116,7 +116,7 @@ Four new things, one at a time.
[source,lua]
----
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
----
`spriteLoad` reads an image file off the disk, decodes it, and keeps the
@ -134,12 +134,12 @@ exactly what a variable is for.
`DIR` is new too, and it solves a problem the layout you have been using since
lesson one creates. Singe looks for files relative to the folder you were
standing in when you started it, *not* relative to your script. You start it
from your work folder, so a plain `art/ship.png` sends the engine looking for
`singe/art/ship.png`, out beside the `Singe` folder. Your artwork is not
there. It is one level down, in `rocks/art/ship.png`, with your script.
from your work folder, so a plain `art/spaceship.png` sends the engine looking for
`singe/art/spaceship.png`, out beside the `Singe` folder. Your artwork is not
there. It is one level down, in `rocks/art/spaceship.png`, with your script.
`DIR` closes that gap. It is a piece of text holding the folder your script is
in, with the slash already on the end, so `DIR .. "art/ship.png"` -- joining
in, with the slash already on the end, so `DIR .. "art/spaceship.png"` -- joining
two pieces of text with `..`, which you have been doing since part one --
names the picture correctly however the game was started, and keeps naming it
correctly when you move the whole folder somewhere else.
@ -173,7 +173,7 @@ If you would rather give the middle, say so:
----
A fourth argument of `true` means "treat that point as the centre". Both forms
are in the manual's entry for `spriteDraw`, along with two more that stretch
are in the reference's entry for `spriteDraw`, along with two more that stretch
the picture into a rectangle.
[source,lua]
@ -227,7 +227,7 @@ reasonable:
----
function onOverlayUpdate()
overlayClear()
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
spriteDraw(ship, 160, 100)
return OVERLAY_UPDATED
end
@ -358,7 +358,7 @@ leftmost figure and frame 4 the rightmost.
Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and
`spriteGetFrame` -- frames are numbered from 0. That is an accident of
history, both are in the manual, and this book uses `spriteDrawFrame` and
history, both are in the reference, and this book uses `spriteDrawFrame` and
counts from 1.
`spriteFrameWidth` reports the width of one frame, 24 here, rather than the 96
@ -415,9 +415,9 @@ local STAR_COUNT = 40
local START_LIVES = 3
local HIT_SCORE = 10
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local starSprite = spriteLoad(DIR .. "art/star.png")
local screenWidth = overlayGetWidth()
@ -664,7 +664,7 @@ One last note for when you read other people's Singe code. Every sprite call
in this book takes the handle *first*. Singe 2.10 put it last, and a game can
ask the engine to keep doing that, so code you find online may read
`spriteDraw(x, y, ship)`. Do not copy it. Handle first is the current order
and the one the manual documents.
and the one the reference documents.
=== Try It
@ -712,7 +712,7 @@ operating system's own wording and will differ slightly from machine to
machine.
`No such file or directory` is the computer being precise rather than unkind.
There genuinely is no file called `Ship.png`; there is one called `ship.png`,
There genuinely is no file called `Ship.png`; there is one called `spaceship.png`,
and to a Linux or macOS machine those are two different names. On Windows it
would have worked, which is worse, because it means the bug travels to
somebody else's machine and appears there for the first time.

View file

@ -335,7 +335,7 @@ It is ten seconds long and it was written to fade out at the end, so looping
it sounds exactly like a ten second loop that keeps fading out. That is fine
for learning the calls and no good at all for a finished
game, which ships a longer piece of its own. Any of the usual formats will do:
the manual's Music section lists them, and Ogg Vorbis or MP3 are the sensible
the reference's Music section lists them, and Ogg Vorbis or MP3 are the sensible
choices, because a few minutes of music as a WAV is an enormous file.
=== The Game Gets a Soundtrack
@ -545,7 +545,7 @@ The game refuses to start:
The line number you get is the line you actually typed it on, so yours may not
be 20. Then the function that objected, and the value it objected to. The
message does not tell you what the range is -- that is what the manual's entry
message does not tell you what the range is -- that is what the reference's entry
for `soundSetVolume` is for -- but it does tell you the number it would not
take, and once you know that effects stop at 63 the fix is one digit.
@ -557,7 +557,7 @@ anything.
This is the general shape of an engine error, as opposed to the Lua errors in
lesson one: a line number, the name of the engine function you called, and a
complaint about the values you handed it. When you see one, the manual's entry
complaint about the values you handed it. When you see one, the reference's entry
for that function is the next thing to read.
=== What You Learned

View file

@ -19,8 +19,8 @@ this, with the details of the rocks left out:
[source,lua]
----
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")
----
@ -84,7 +84,7 @@ end
Eight numbers in, `true` or `false` out, in the same order you just wrote them:
the first rectangle's corner and size, then the second one's. It is one of a
small family of collision functions, all of them plain questions with no
handles to keep and nothing to set up, and the manual's Collide section lists
handles to keep and nothing to set up, and the reference's Collide section lists
every one. Two edges that touch exactly count as touching, and a rectangle with
no width or height touches nothing at all.
@ -151,7 +151,7 @@ number rather than by hunting through your tests.
=== Firing Back
A shot is a small record in a list, exactly like a rock. `shot.png` is 4 wide
A shot is a small record in a list, exactly like a rock. `bullet.png` is 4 wide
and 10 tall, and shots travel up the screen instead of down:
[source,lua]
@ -160,7 +160,7 @@ local SHOT_WIDTH = 4
local SHOT_HEIGHT = 10
local SHOT_SPEED = 6
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local shots = {}
@ -416,7 +416,7 @@ many there are, and asking is cheap.
And when you want to know not just *whether* two things met but where the
moving one should end up -- sliding along a wall instead of stopping dead
inside it -- the collide calls deliberately do not answer that. A library
called `bump` is bundled with Singe for exactly that job, and the manual's
called `bump` is bundled with Singe for exactly that job, and the reference's
Included Libraries section says how to reach it.
=== Try It

View file

@ -38,9 +38,9 @@ Rocks/
games.dat What the menu reads
Rocks.singe The game
art/
ship.png
rock.png
shot.png
spaceship.png
asteroid.png
bullet.png
star.png
shoot.wav
boom.wav
@ -72,7 +72,7 @@ one.
Here is the change your script needs, and the reason it needs it.
Every name your game loads -- `art/ship.png`, `art/boom.wav` -- is looked for
Every name your game loads -- `art/spaceship.png`, `art/boom.wav` -- is looked for
starting at the folder Singe was *started in*, not the folder your script is
in. All through this book those have been the same folder, because you stood
inside the game's folder and typed `Singe rocks`. Start it from the
@ -85,9 +85,9 @@ your file from wherever Singe was started:
[source,lua]
----
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local rockSprite = spriteLoad(DIR .. "art/rock.png")
local shotSprite = spriteLoad(DIR .. "art/shot.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local rockSprite = spriteLoad(DIR .. "art/asteroid.png")
local shotSprite = spriteLoad(DIR .. "art/bullet.png")
local shootSound = soundLoad(DIR .. "art/shoot.wav")
local boomSound = soundLoad(DIR .. "art/boom.wav")
----
@ -220,7 +220,7 @@ again tomorrow.
[source,lua]
----
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
----
`DIR` is a variable `Singe/Framework.singe` sets before your script runs, and
@ -267,19 +267,19 @@ else's.
=== Break It on Purpose
Take the `DIR ..` back off one line, so it reads `spriteLoad("art/ship.png")`
Take the `DIR ..` back off one line, so it reads `spriteLoad("art/spaceship.png")`
again, and start the game from the folder above -- `Singe Rocks`, or from the
menu. The game dies before it draws anything, and the terminal says:
----
24:spriteLoad: Couldn't open art/ship.png: No such file or directory
24:spriteLoad: Couldn't open art/spaceship.png: No such file or directory
----
The exact wording after the colon comes from your operating system and differs
a little between them, but the shape is the same everywhere: the line, the
engine function that gave up, and the name it could not find.
It could not find `art/ship.png` because it looked in the folder you started
It could not find `art/spaceship.png` because it looked in the folder you started
Singe in, which is the folder holding `Rocks`, and there is no `art` there.
The file it wanted is one folder further down, which is exactly what `DIR`
knows and a bare name does not.

View file

@ -137,7 +137,7 @@ draw on is half the size of the video on each side, which for a 720 by 480
video is 360 by 240, which is exactly the size of the canvas you have been
drawing on for thirteen lessons. Your coordinates did not move. If you ever
play a video of some other size, the overlay changes size with it, and
`overlaySetResolution` sets it to whatever you want; the manual's entry has
`overlaySetResolution` sets it to whatever you want; the reference's entry has
the details.
This is the whole idea of the overlay. The video is underneath. Your drawing
@ -417,7 +417,7 @@ it again, forever. `held` is how you say "I have already done this once".
calls `discSkipToFrame(0)`. You have built an attract mode: a scene that
plays forever until somebody presses a button.
. *Ask for a frame that is not there.* Change the `SWITCH_RIGHT` line to
`discSkipToFrame(100000)`. It does not crash. Read the manual's entry for
`discSkipToFrame(100000)`. It does not crash. Read the reference's entry for
`discSkipToFrame` and find the sentence that says why.
. *Play it without the video.* Run `Singe -R movie` and then press
every key. Nothing you press can break it.

View file

@ -160,7 +160,7 @@ if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then
A shot is a point. A target is a rectangle. `collidePointRect` asks whether
the point is inside the rectangle, and the edge counts as inside. There is
nothing special about shooting. A gun game's hit test is the same hit test a
platform game uses to find out whether you landed on a block, and the manual's
platform game uses to find out whether you landed on a block, and the reference's
`collide` family has the rest of the shapes.
=== Targets That Come and Go

View file

@ -465,7 +465,7 @@ exactly what a final shot wants. The `discGetState()` half stops that
happening again on every one of the following frames: once the disc is paused
it is no longer `DISC_PLAYING`, so the condition is false and the seek is not
repeated sixty times a second. `discGetState` and the `DISC_*` constants are
in the manual under `discGetState`.
in the reference under `discGetState`.
[source,lua]
----

View file

@ -183,7 +183,7 @@ white, this is why.
=== The Trap: fontPrint Is Not Free
Here is the thing the manual says plainly and everyone reads past.
Here is the thing the reference says plainly and everyone reads past.
`fontPrint` does not draw a picture it made earlier. Every single call
rasterizes the whole string from the font outlines, builds an image of it,

View file

@ -30,7 +30,7 @@ dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
local shipX = 0
local speed = 2
local score = 0
@ -211,7 +211,7 @@ that might not be there without dying.
That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
There is far more available through RmlUi's own Lua objects, reachable from the
global `rmlui`, and the manual's GUI chapter shows how. You will get a long way
global `rmlui`, and the reference's GUI chapter shows how. You will get a long way
before you need it.
=== Hide It Again
@ -341,7 +341,7 @@ singeSetPauseKeyEnabled(false)
local SHIP_Y = 300
local ship = spriteLoad(DIR .. "art/ship.png")
local ship = spriteLoad(DIR .. "art/spaceship.png")
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
local shipX = 0
local speed = 2

View file

@ -93,7 +93,7 @@ Keep it in a variable, because every later call needs it.
`materialSetRoughness` runs from `0`, a mirror with a tiny hard highlight, to
`1`, a chalky matte surface with no highlight at all. `0.6` is paint on
wood. There are a dozen more `materialSet` calls, and the next lesson uses
some of them; the manual's Material section lists them all.
some of them; the reference's Material section lists them all.
[source,lua]
----
@ -366,7 +366,7 @@ to know how many times the player has spun, count that yourself.
That is the point: one shape, two places.
. *Hang the camera on the box.* Keep the second box from the last step, or
there will be nothing left to move against. Then look up `nodeSetParent` in
the manual and hang the camera under the first box. Hold an arrow key: the
the reference and hang the camera under the first box. Hold an arrow key: the
camera rides the box's rotation, so the box sits still and the rest of the
scene swings past. That is one line away from a first-person view, and it
is also how you find out that a chase camera wants a parent rather than

View file

@ -170,7 +170,7 @@ exactly one.
`lightSetIntensity` is brightness. For a directional light, `1` lights a
white surface square-on to it up to white, so `1.8` is a bright day.
`lightSetColor` tints it: a slightly warm white for sun, a cold blue for
moonlight, and the manual's Light section for the rest.
moonlight, and the reference's Light section for the rest.
`lightSetShadow(sun, true)` is what makes the dragon sit on the floor rather
than hover above it. Shadows are off by default because each shadow-casting
@ -246,7 +246,7 @@ One thing to know about metal: a metal surface shows you its reflections
rather than a color of its own, so in a scene with nothing to reflect it
looks dark and dull. That is why the dragon's dark metal needs the sun to
come alive. The proper fix is a sky (`sceneSetSky`), which wraps the scene in
a panorama that both shows behind everything and lights it; the manual's
a panorama that both shows behind everything and lights it; the reference's
"The Look of the Frame" covers it.
=== Movement the Model Brings with It
@ -334,7 +334,7 @@ The wings turn about their own X axis, and they turn around the shoulder
rather than the middle of the animal, because whoever built the file put each
part's node where the joint belongs. A model that was not built that way will
swing its parts around the model's center instead, and the fix is to move the
part's node, which the manual's Node section covers.
part's node, which the reference's Node section covers.
Notice that the whole dragon turning does not interfere with the wings
turning. Parts hang under the instance root, so rotating the root carries

View file

@ -437,7 +437,7 @@ and nobody can tell you exactly how it should look.
Do not reach for it when you know what should happen. A bullet that flies
straight is two lines from lesson two. A platform that goes up and down is
`math.sin`. A player that walks and jumps the way players expect wants a
character controller (`playerNew`, in the manual), because a person is not a
character controller (`playerNew`, in the reference), because a person is not a
box and never behaves like one. An enemy that patrols wants a path. A menu
that slides in wants a tween.

View file

@ -42,7 +42,7 @@ local SHIP_WIDTH = 32
local SHIP_HEIGHT = 24
local SHIP_SPEED = 120
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
local shipY = overlayGetHeight() - 56
@ -94,7 +94,7 @@ from lesson three, and `singeGetTicks` so the ship moves at the same speed
whatever the frame rate is.
The one line worth pointing at is `dofile("Singe/Framework.singe")`. That is
what defines `DIR`, the folder your script is in, so `DIR .. "art/ship.png"`
what defines `DIR`, the folder your script is in, so `DIR .. "art/spaceship.png"`
finds the picture no matter which folder you started Singe from.
=== Give It an Exhaust

View file

@ -455,7 +455,7 @@ From here on, do not set that node's position yourself. Two things moving one
node fight, and the fight looks like stuttering. If your character needs to
be shoved about by physics as well as walk, there is a call for that --
`navAgentSetPlayer` hands the steering to a physics controller instead -- and
the manual's entry for it explains when you want it.
the reference's entry for it explains when you want it.
[source,lua]
----
@ -500,7 +500,7 @@ the speed it is actually travelling looks alive; one whose walk cycle runs at
a fixed rate while it accelerates looks like it is skating. With a real model
in place of the box you would compare that speed against a couple of
thresholds and play an idle, a walk, or a run -- which is lesson
twenty-three's subject, and the manual's example under
twenty-three's subject, and the reference's example under
`navAgentGetVelocity` is written for exactly that.
Which is the real point of this lesson. A character that takes the fast

View file

@ -57,8 +57,8 @@ hit wins:
Inside the database, names are matched without regard to capital letters, with
either kind of slash, and relative to the game's root. A leading folder name
equal to the game's own is ignored, so `MyGame/art/ship.png`, `art/ship.png`,
and `DIR .. "art/ship.png"` all find the same file.
equal to the game's own is ignored, so `MyGame/art/spaceship.png`, `art/spaceship.png`,
and `DIR .. "art/spaceship.png"` all find the same file.
Two rules follow from that, and if you have been following the book you already
obey both. Use `DIR` for everything you load, so a name never depends on where
@ -83,7 +83,7 @@ MyGame.game the packed game
MyGame/ a folder with one changed file in it
----
Drop a single corrected `art/ship.png` in there and the packed game runs with
Drop a single corrected `art/spaceship.png` in there and the packed game runs with
your new picture and everything else out of the database. Nothing has to be
repacked to try a change, and you can keep your whole working folder there
while you finish the game. It is also how a player mods one, which you may or

View file

@ -190,7 +190,7 @@ startsilent = true
`idleexit` quits after that many seconds with no input of any kind, for a
cabinet that should hand the screen back to a menu or a front end.
`startsilent` starts muted until somebody touches something, which is how a
room full of machines is bearable. There are more; the manual's settings and
room full of machines is bearable. There are more; the reference's settings and
command line sections list every one.
=== Artwork Round the Picture
@ -631,7 +631,7 @@ the game runs.
. *Play free.* Turn free play on and check that start works with no coins and
that credits do not go negative.
. *Add a page.* Put a second page of service information behind button 3 --
the fields of `singeGetSystemInfo()` are a good start, and the manual's entry
the fields of `singeGetSystemInfo()` are a good start, and the reference's entry
lists all of them.
. *Turn the screen.* Run it with `--rotate=90` and look at your layout. Then
make the service page readable at that rotation, which is harder than it
@ -693,7 +693,7 @@ tell when two things touched, how to keep a score somewhere it survives, how
to run film as a world, how to put a model in a room with a light on it, and
how to hand the whole thing to a stranger.
What you need now is the _Singe Manual_, not this book. It is the reference to
What you need now is the _Singe Reference_, not this book. It is the reference to
every function, every argument, and every return value, and it is the thing you
will actually have open while you work. It ships with the engine as
`Singe/Manual.pdf`. Read the entry before you use the call; there is a note in

20
docs/preview/docinfo.html Normal file
View file

@ -0,0 +1,20 @@
<!-- The proofreading watermark for the HTML books: ./build-docs.sh preview adds this to every
page's head, and a release build never does. Fixed to the window, so it is on every screen of
the document, and it lets clicks through. -->
<style>
body::before {
content: "PREVIEW";
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-50deg);
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
font-size: 22vw;
color: #000000;
opacity: 0.11;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
</style>

View file

@ -29,9 +29,11 @@
#include <math.h>
#include <sys/stat.h>
#ifdef _WIN32
// _describeCpu reads the processor name out of the registry, which needs the Windows headers.
// _describeCpu reads the processor name out of the registry, which needs the Windows headers;
// _relaunch starts the next executable with _spawnv.
#include <windows.h>
#include <io.h>
#include <process.h>
#else
#include <unistd.h>
#endif
@ -70,7 +72,8 @@
#define MENU_OPTIONS "-k -w -v"
#define MENU_BINARY_PREFIX "Singe" // The launcher searches for this beside itself; the binary is named Singe-vX.YY-...
#define MENU_BINARY_PREFIX "Singe" // The launcher looks beside itself for this, highest version first
#define SUPPORT_VERSION_FILE "version.txt" // Inside the Singe folder: which Singe wrote it
#define PRIMARY_DISPLAY 0
#define MIXER_FREQUENCY 44100
#define MIXER_CHANNELS 2
@ -131,6 +134,8 @@ typedef enum LongOptionE {
OPT_MAPJOYSTICKS,
OPT_MONOCHROME,
OPT_NOGAMEPAD,
OPT_NO_UPDATE_CHECK,
OPT_REFRESH,
OPT_SCREEN,
OPT_SOUNDFONT,
OPT_STARTSILENT,
@ -217,6 +222,8 @@ static const OptionT _options[] = {
{ OPT_MAPJOYSTICKS, "mapjoysticks", ap_yes, "BOOLEAN", "write a gamepad mapping for a device SDL does not recognise (the default)", false, true },
{ OPT_MONOCHROME, "monochrome", ap_no, NULL, "start with the disc picture in grey", false, true },
{ OPT_NOGAMEPAD, "nogamepad", ap_no, NULL, "ignore every gamepad, as --nomouse ignores the mice", false, true },
{ OPT_NO_UPDATE_CHECK, "no_update_check", ap_no, NULL, "never look for a newer Singe from the menu", false, true },
{ OPT_REFRESH, "refresh", ap_no, NULL, "write a fresh Singe folder, keeping the old one as old-Singe-vX.YY", false, false },
{ OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true },
{ OPT_SOUNDFONT, "soundfont", ap_yes, "FILE", "synthesise MIDI files with this SoundFont (.sf2)", false, true },
{ OPT_STARTSILENT, "startsilent", ap_no, NULL, "start muted until the first input of any kind", false, true },
@ -290,6 +297,7 @@ static bool _parseBoolean(const char *text, bool *value);
static bool _parseFloat(const char *text, double *value);
static bool _parseGamepadOrder(const char *text);
static bool _parseInteger(const char *text, int32_t *value);
static bool _relaunch(const char *path, int32_t argc, char *argv[]);
static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit);
static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what);
static void _resolveFiles(const char *exeName, ConfigT *conf);
@ -301,7 +309,63 @@ static void _startSDL(void);
static void _stopSDL(void);
static void _stripDatabaseNames(char *path);
static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device);
static void _unpackData(const char *exePath, bool absolute);
static void _archiveSupport(void);
static void _unpackData(const char *exePath, bool absolute, bool refresh);
// The executable this process was started as, absolute, kept for singeGetSystemInfo and for a
// relaunch. main() owns it; everyone else reads it.
static char *_executable = NULL;
const char *mainExecutablePath(void) {
return _executable;
}
// Starts path in this process's place, with this process's arguments, once everything here has
// been torn down. On POSIX that is execv, which never returns when it works. Windows will not
// replace a running image, and does not need to: the new Singe has its own name, so it is
// started beside this one and this one ends. The file is made executable first, since it
// arrived by download. True when the next Singe is running, which on Windows also means this
// process must end without waiting for a key: the two would otherwise share one console, with
// the old one holding it open until somebody pressed something.
static bool _relaunch(const char *path, int32_t argc, char *argv[]) {
const char *extra = singeRelaunchExtra();
char **args = calloc((size_t)argc + 2, sizeof(char *));
int32_t x = 0;
#ifdef _WIN32
intptr_t child = -1;
#endif
if (args == NULL) {
return false;
}
args[0] = (char *)path;
for (x = 1; x < argc; x++) {
args[x] = argv[x];
}
if (extra != NULL) {
args[x] = (char *)extra;
x++;
}
args[x] = NULL;
utilChMod(path, S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH);
#ifdef _WIN32
child = _spawnv(_P_NOWAIT, path, (const char *const *)args);
if (child != -1) {
free(args);
return true;
}
#else
execv(path, args);
#endif
// Only reached when the start failed; say so and end normally, with the new file left in place.
utilSay("Could not start %s.", path);
free(args);
return false;
}
// Runs one list of options into a configuration: the command line when source is NULL, or one
@ -685,6 +749,16 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
conf->noGamepad = true;
break;
// Never Ask For A Newer Singe
case OPT_NO_UPDATE_CHECK:
conf->noUpdateCheck = true;
break;
// A Fresh Support Folder
case OPT_REFRESH:
conf->refreshSupport = true;
break;
// Which Display
case OPT_SCREEN:
if (!_parseInteger(arg, &conf->screen) || (conf->screen < SCREEN_MIN)) {
@ -1733,7 +1807,55 @@ static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDev
}
static void _unpackData(const char *exePath, bool absolute) {
// Puts the support folder aside instead of deleting it: "old-Singe-v3.00", or with a number after
// that when the name is taken. Nothing here ever removes anything -- what to keep is the person's
// business, and a folder they had edited is exactly the thing not to throw away on their behalf.
// The version comes from the marker the folder carries; a folder written before markers existed
// is archived as old-Singe-unknown.
static void _archiveSupport(void) {
char *marker = NULL;
char *version = NULL;
char *target = NULL;
size_t bytes = 0;
int32_t n = 0;
char *at = NULL;
if (!utilPathExists(VFS_ENGINE_DIRECTORY)) {
return;
}
marker = utilCreateString("%s%c%s", VFS_ENGINE_DIRECTORY, utilGetPathSeparator(), SUPPORT_VERSION_FILE);
version = utilReadFile(marker, &bytes);
free(marker);
if (version == NULL) {
version = strdup("unknown");
}
// The marker is one line; keep what is on it and nothing else.
at = strpbrk(version, "\r\n");
if (at != NULL) {
*at = '\0';
}
if (version[0] == '\0') {
free(version);
version = strdup("unknown");
}
target = utilCreateString("old-%s-%s", VFS_ENGINE_DIRECTORY, version);
while (utilPathExists(target) && (n < 100)) {
n++;
free(target);
target = utilCreateString("old-%s-%s-%d", VFS_ENGINE_DIRECTORY, version, n);
}
if (rename(VFS_ENGINE_DIRECTORY, target) == 0) {
_showHeader();
utilSay(">>> Kept File: %s (the old %s folder; delete it when you are happy)", target, VFS_ENGINE_DIRECTORY);
} else {
utilSay("Could not put %s aside as %s; it is being written over instead.", VFS_ENGINE_DIRECTORY, target);
}
free(target);
free(version);
}
static void _unpackData(const char *exePath, bool absolute, bool refresh) {
const EmbeddedFileT files[] = {
{ "Framework.singe", Framework_singe, Framework_singe_len },
{ "controls.cfg.example", controls_cfg, controls_cfg_len },
@ -1770,6 +1892,11 @@ static void _unpackData(const char *exePath, bool absolute) {
bool created = false;
// Extract missing or outdated support files. We do this here so they are not generated if launched from a front end.
// --refresh puts the whole folder aside first, so what is written is only what this version
// ships: an update that stops shipping a file would otherwise leave the old one lying there.
if (refresh) {
_archiveSupport();
}
if (!utilMkDirP(VFS_ENGINE_DIRECTORY, DIRECTORY_MODE)) {
utilDie("Unable to create %s directory.", VFS_ENGINE_DIRECTORY);
}
@ -1780,6 +1907,14 @@ static void _unpackData(const char *exePath, bool absolute) {
free(temp);
}
// Which Singe wrote this folder, so --refresh can name what it puts aside and a person can tell
// at a glance. Written like the rest, so it only changes when the version does.
temp = utilCreateString("%s%c%s", VFS_ENGINE_DIRECTORY, utilGetPathSeparator(), SUPPORT_VERSION_FILE);
data = utilCreateString("%s\n", VERSION_STRING);
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
free(data);
free(temp);
// Script to start menu system. The launcher finds the binary beside itself rather than naming
// it, because the binary carries its version and a named one stops working at the next release;
// the game launchers in the wild do the same. With --gamedir the binary is elsewhere, so there
@ -1790,7 +1925,73 @@ static void _unpackData(const char *exePath, bool absolute) {
if (absolute) {
data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
} else {
data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\nSINGE=\nfor f in ./%s*; do\n if [[ -x \"$f\" ]] && [[ -f \"$f\" ]]; then\n SINGE=\"$f\" && break\n fi\ndone\nif [[ -z \"$SINGE\" ]]; then\n echo \"Cannot find the %s program beside this script.\"\n exit 1\nfi\n\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
// The best binary beside the script, not the first name the shell happens to list: a
// folder holding both 3.00 and a beta sorts differently under different locales, and a
// person who unpacked a beta over a release would otherwise start whichever the
// collation preferred. The key is this machine's architecture first, then the version
// the master service ranks by -- major, minor, then a beta below the release of that
// number, then which beta -- built as fixed-width digits so a plain string compare does
// the right thing. Architecture outranks version because a newer build for another
// machine cannot run at all, and one game folder shared between, say, a Pi and a PC
// holds a binary for each; it is decided here rather than baked in, since whichever
// machine wrote this folder is not necessarily the one reading it. A name for neither
// this architecture nor any other we can read still gets its turn when nothing better
// is there, because a real complaint from the binary beats "cannot find Singe".
// Anything put aside as "old-..." is never matched by the pattern, so it stays out of
// the running. 10# forces base ten: a minor written "09" is not octal, whatever
// printf thinks.
data = utilCreateString(
"#!/usr/bin/env bash\n"
"\n"
"cd \"$(dirname \"$0\")\"\n"
"os=$(uname -s)\n"
"cpu=$(uname -m)\n"
"case $os in\n"
" Darwin) os=Macos ;;\n"
"esac\n"
"case $cpu in\n"
" amd64) cpu=x86_64 ;;\n"
" arm64) cpu=aarch64 ;;\n"
"esac\n"
"mine() {\n"
" local n=\"${1##*/}\"\n"
" n=\"${n%%.exe}\"\n"
" if [[ $n == *-\"$os-$cpu\" ]]; then\n"
" printf '1'\n"
" elif [[ $os == Macos && $n == *-Macos-universal ]]; then\n"
" printf '1'\n"
" else\n"
" printf '0'\n"
" fi\n"
"}\n"
"key() {\n"
" local n=\"${1##*/}\"\n"
" n=\"${n%%.exe}\"\n"
" if [[ $n =~ ^%s-v([0-9]+)\\.([0-9]+)b([0-9]+)- ]]; then\n"
" printf '%%05d%%05d0%%05d' $((10#${BASH_REMATCH[1]})) $((10#${BASH_REMATCH[2]})) $((10#${BASH_REMATCH[3]}))\n"
" elif [[ $n =~ ^%s-v([0-9]+)\\.([0-9]+)- ]]; then\n"
" printf '%%05d%%05d1%%05d' $((10#${BASH_REMATCH[1]})) $((10#${BASH_REMATCH[2]})) 0\n"
" else\n"
" printf '0000000000000000'\n"
" fi\n"
"}\n"
"SINGE=\n"
"BEST=\n"
"for f in ./%s*; do\n"
" [[ -x \"$f\" && -f \"$f\" ]] || continue\n"
" k=$(mine \"$f\")$(key \"$f\")\n"
" if [[ -z $SINGE || $k > $BEST ]]; then\n"
" SINGE=\"$f\"\n"
" BEST=$k\n"
" fi\n"
"done\n"
"if [[ -z \"$SINGE\" ]]; then\n"
" echo \"Cannot find the %s program beside this script.\"\n"
" exit 1\n"
"fi\n"
"\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n",
MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX,
MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
}
} else {
// Winders
@ -1798,7 +1999,86 @@ static void _unpackData(const char *exePath, bool absolute) {
if (absolute) {
data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
} else {
data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nset \"SINGE=\"\r\nfor /f \"tokens=* usebackq\" %%%%f in (`dir /b %s*.exe`) do (set \"SINGE=%%%%f\" & goto :next)\r\n:next\r\nif not defined SINGE (\r\n echo Cannot find the %s program beside this script.\r\n pause\r\n exit /b 1\r\n)\r\nstart \"\" \"%%SINGE%%\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
// The best binary beside the script, as Menu.sh picks it, and for the same reasons:
// a folder holding a release and a beta must start the release, and "dir" hands them
// over in the directory's own order, which put 3.09 ahead of 3.10 and started the
// older one. The key is ten digits -- 1 for a build this machine can run and 0 for
// one it cannot, then a leading 1, the major and minor in two each, then 1 for a
// release or 0 for a beta, then which beta in three -- so the architecture decides
// first, the version decides after it, and the whole thing still fits in the 32 bits
// cmd compares numbers in and reads the same as a string either way. The architecture
// comes from the environment rather than being baked in, because one folder on a share
// can be read by more than one machine. The name's own last two pieces are compared
// whole, since "Windows-x86" is the front of "Windows-x86_64" and a 32-bit Windows
// would otherwise think a 64-bit build was its own. ARCHITEW6432 is there because a
// 32-bit cmd on
// a 64-bit Windows reports the emulated architecture in the usual variable and the real
// one in that. Anything set aside as "old-..." is never matched by the pattern, so it
// stays out of the running. Zeroes are trimmed by taking the last digits of a padded
// string rather than by arithmetic, because "set /a" reads a leading zero as octal and
// 3.09 would stop the launcher dead.
data = utilCreateString(
"@echo off\r\n"
"cd /d \"%%~dp0\"\r\n"
"setlocal enabledelayedexpansion\r\n"
"set \"CPU=%%PROCESSOR_ARCHITECTURE%%\"\r\n"
"if defined PROCESSOR_ARCHITEW6432 set \"CPU=%%PROCESSOR_ARCHITEW6432%%\"\r\n"
"if /i \"%%CPU%%\"==\"AMD64\" set \"CPU=x86_64\"\r\n"
"if /i \"%%CPU%%\"==\"ARM64\" set \"CPU=aarch64\"\r\n"
"set \"PLAT=Windows-%%CPU%%\"\r\n"
"set \"SINGE=\"\r\n"
"set \"BEST=\"\r\n"
"for /f \"delims=\" %%%%f in ('dir /b /a-d %s*.exe 2^>nul') do (\r\n"
" call :key \"%%%%~nf\"\r\n"
" if not defined SINGE (\r\n"
" set \"SINGE=%%%%f\"\r\n"
" set \"BEST=!KEY!\"\r\n"
" ) else if !KEY! GTR !BEST! (\r\n"
" set \"SINGE=%%%%f\"\r\n"
" set \"BEST=!KEY!\"\r\n"
" )\r\n"
")\r\n"
"if not defined SINGE (\r\n"
" echo Cannot find the %s program beside this script.\r\n"
" pause\r\n"
" exit /b 1\r\n"
")\r\n"
"start \"\" \"!SINGE!\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n"
"exit /b 0\r\n"
"\r\n"
":key\r\n"
"set \"n=%%~1\"\r\n"
"set \"MINE=0\"\r\n"
"for /f \"tokens=3,4 delims=-\" %%%%a in (\"!n!\") do (\r\n"
" if /i \"%%%%a-%%%%b\"==\"!PLAT!\" set \"MINE=1\"\r\n"
")\r\n"
"set \"KEY=!MINE!000000000\"\r\n"
"set \"v=!n:%s-v=!\"\r\n"
"if \"!v!\"==\"!n!\" exit /b\r\n"
"for /f \"tokens=1 delims=-\" %%%%a in (\"!v!\") do set \"v=%%%%a\"\r\n"
"set \"maj=\"\r\n"
"set \"min=\"\r\n"
"for /f \"tokens=1,2 delims=.\" %%%%a in (\"!v!\") do (\r\n"
" set \"maj=%%%%a\"\r\n"
" set \"min=%%%%b\"\r\n"
")\r\n"
"if not defined min exit /b\r\n"
"set \"rel=1\"\r\n"
"set \"bet=0\"\r\n"
"set \"trimmed=!min:b=!\"\r\n"
"if not \"!trimmed!\"==\"!min!\" (\r\n"
" set \"rel=0\"\r\n"
" for /f \"tokens=1,2 delims=b\" %%%%a in (\"!min!\") do (\r\n"
" set \"min=%%%%a\"\r\n"
" set \"bet=%%%%b\"\r\n"
" )\r\n"
")\r\n"
"set \"maj=00!maj!\"\r\n"
"set \"min=00!min!\"\r\n"
"set \"bet=000!bet!\"\r\n"
"set \"KEY=!MINE!1!maj:~-2!!min:~-2!!rel!!bet:~-3!\"\r\n"
"exit /b\r\n",
MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY, MENU_BINARY_PREFIX);
}
}
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
@ -2093,6 +2373,7 @@ int main(int argc, char *argv[]) {
ConfigT *replacement = NULL;
QueueT *q = NULL;
bool ok = false;
bool relaunched = false;
#ifndef _WIN32
signal(SIGSEGV, _crashHandler);
@ -2125,8 +2406,8 @@ int main(int argc, char *argv[]) {
}
}
_unpackData(exePath, conf->gameDir != NULL);
free(exePath);
_unpackData(exePath, conf->gameDir != NULL, conf->refreshSupport);
_executable = exePath;
// -d names the base under which every game gets a data directory; without it, data/ in the
// working directory serves (2.x wrote beside the game, which may not be writable).
@ -2198,7 +2479,18 @@ int main(int argc, char *argv[]) {
free(_commandLine);
free(_settingsSummary);
if (utilGetConsoleEnabled()) {
// A script asked for another executable to take over -- the menu's Engine Update, having put
// a newer Singe beside this one. Everything above has been torn down, so what starts now
// starts as clean as a fresh launch, with the same arguments.
if (singeRelaunchPath() != NULL) {
relaunched = _relaunch(singeRelaunchPath(), argc, argv);
}
free(_executable);
// A key wait here would leave this process holding the console the Singe it just started is
// writing to, so it only happens when nothing took over. A relaunch that failed said so
// above and is worth reading, so that still waits.
if (utilGetConsoleEnabled() && !relaunched) {
utilWaitForKeyOnWindows();
}

View file

@ -65,6 +65,7 @@ bool isFrameFileName(const char *filename);
char *mainDescribeAudioDecoders(void);
char *mainDescribeCpu(void);
char *mainDescribeOs(void);
const char *mainExecutablePath(void);
bool parseSindenString(const char *sindenString, ConfigT *conf);
void queueScript(const ConfigT *conf);
char *resolveDataDir(const ConfigT *conf);

View file

@ -68,6 +68,9 @@ typedef struct WriterS {
} WriterT;
// Below this, rebuilding the file costs more than the space is worth.
#define PACK_RECLAIM_MINIMUM (1024 * 1024)
static const char *const _badFilenames[] = { "controls.dat", "Framework.singe", NULL };
static const char *const _badExtensions[] = { "exe", "sh", "bat", "cmd", NULL };
static const char *const _textExtensions[] = { "singe", "lua", "dat", "cfg", "txt", NULL };
@ -91,6 +94,7 @@ static bool _scanEscapes(const EntryT *list);
static bool _validateGamesDat(WriterT *writer);
static bool _writeBlob(FILE *file, const void *data, size_t bytes);
static bool _writeMeta(sqlite3 *db, const char *key, const char *value);
static int64_t _reclaim(const char *database);
static bool _writerClose(WriterT *writer, bool commit);
static bool _writerOpen(WriterT *writer, const char *database, bool create);
@ -577,6 +581,54 @@ static bool _writeMeta(sqlite3 *db, const char *key, const char *value) {
}
// Gives a patched game back the space its old contents were using. SQLite keeps a freed page for
// its own reuse rather than handing it to the filesystem, which is right for a database being
// written all day and wrong for a game somebody is about to ship: replace a 300 MB video with a
// 30 MB one and the file stays 300 MB for ever. VACUUM rebuilds it. Only worth the rebuild when
// there is something to gain, so a patch that swapped like for like does not pay for one, and the
// bytes recovered are reported because a file changing size on its own should say why.
//
// Returns the bytes recovered, 0 when nothing was worth doing, or -1 when the rebuild failed --
// which loses nothing: the patched game is already correct, just larger than it needs to be.
static int64_t _reclaim(const char *database) {
sqlite3 *db = NULL;
sqlite3_stmt *stmt = NULL;
int64_t free = 0;
int64_t page = 0;
int64_t was = 0;
bool ok = true;
if (sqlite3_open(database, &db) != SQLITE_OK) {
sqlite3_close(db);
return -1;
}
if (sqlite3_prepare_v2(db, "PRAGMA freelist_count", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
free = sqlite3_column_int64(stmt, 0);
}
sqlite3_finalize(stmt);
}
if (sqlite3_prepare_v2(db, "PRAGMA page_size", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
page = sqlite3_column_int64(stmt, 0);
}
sqlite3_finalize(stmt);
}
was = free * page;
if (was < PACK_RECLAIM_MINIMUM) {
sqlite3_close(db);
return 0;
}
ok = _exec(db, "VACUUM");
sqlite3_close(db);
if (!ok) {
return -1;
}
return was;
}
static bool _writerClose(WriterT *writer, bool commit) {
bool ok = true;
@ -781,6 +833,17 @@ bool packPatch(const char *database, const char *source) {
if (!_writerClose(&writer, ok)) {
ok = false;
}
// Once the writer has let go of the file: a patch that replaced something big with something
// small leaves the space behind, and a game about to be shipped should not carry it.
if (ok) {
int64_t recovered = _reclaim(database);
if (recovered > 0) {
utilSay(">>> Recovered %" PRId64 " bytes of space the old contents were using", recovered);
} else if (recovered < 0) {
utilSay("!!! Could not recover the space the old contents were using; %s is correct but larger than it needs to be.", database);
}
}
return ok;
}

View file

@ -67,6 +67,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L);
#include "main.h"
#include "midi.h"
#include "midiIo.h"
#include "pack.h"
#include "util.h"
#include "frameFile.h"
#include "vfs.h"
@ -561,6 +562,8 @@ typedef struct GlobalS {
SDL_Renderer *renderer;
SDL_GPUDevice *device;
bool reloadRequested; // singeReload, F5 or a watched file changing
char *relaunchPath; // singeRelaunch: the executable to start once this one has ended
char *relaunchExtra; // and one argument added to the ones this run was given
WatchedFileT *watched;
int32_t watchedCount;
uint64_t watchTick; // When the watched files were last checked
@ -1371,7 +1374,9 @@ static int32_t apiSingeGetScriptPath(lua_State *L);
static int32_t apiSingeGetTicks(lua_State *L);
static int32_t apiSingeGetWidth(lua_State *L);
static int32_t apiSingeHasGpu(lua_State *L);
static int32_t apiSingePack(lua_State *L);
static int32_t apiSingeQuit(lua_State *L);
static int32_t apiSingeRelaunch(lua_State *L);
static int32_t apiSingeReload(lua_State *L);
static int32_t apiSingeScreenshot(lua_State *L);
static int32_t apiSingeSetAudioCalibration(lua_State *L);
@ -5786,6 +5791,8 @@ static void _pushConstants(lua_State *L) {
lua_setglobal(L, "SINGE_LEGACY_SPRITE_ARGS");
lua_pushboolean(L, _global.conf->disc);
lua_setglobal(L, "SINGE_DISC");
lua_pushboolean(L, !_global.conf->noUpdateCheck);
lua_setglobal(L, "SINGE_UPDATE_CHECK");
}
@ -6242,7 +6249,9 @@ static void _registerApi(lua_State *L) {
lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00
lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx
lua_register(L, "singeHasGpu", apiSingeHasGpu); // 3.00
lua_register(L, "singePack", apiSingePack); // 3.00
lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG
lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00
lua_register(L, "singeReload", apiSingeReload); // 3.00
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
@ -13516,6 +13525,8 @@ static int32_t apiSingeGetSystemInfo(lua_State *L) {
lua_newtable(L);
lua_pushstring(L, VERSION_STRING); lua_setfield(L, -2, "version");
lua_pushstring(L, (os != NULL) ? os : ""); lua_setfield(L, -2, "os");
lua_pushstring(L, SINGE_PLATFORM); lua_setfield(L, -2, "platform");
lua_pushstring(L, (mainExecutablePath() != NULL) ? mainExecutablePath() : ""); lua_setfield(L, -2, "executable");
lua_pushstring(L, (cpu != NULL) ? cpu : ""); lua_setfield(L, -2, "cpu");
lua_pushstring(L, SDL_GetRendererName(_global.renderer)); lua_setfield(L, -2, "renderer");
lua_pushstring(L, (_global.device != NULL) ? rgpuGetDeviceDriver(_global.device) : "none (3D unavailable)"); lua_setfield(L, -2, "gpu");
@ -13642,6 +13653,26 @@ static int32_t apiSingeHasGpu(lua_State *L) {
}
// result = singePack(directory, database)
//
// The packer that --pack runs, from a script: what lets an authoring tool release a game without
// leaving the engine. The reasons for a refusal are printed as they are for the command line.
static int32_t apiSingePack(lua_State *L) {
const char *directory = NULL;
const char *database = NULL;
bool ok = false;
_argCheck(L, "singePack", 2, 2);
directory = _argString(L, "singePack", 1);
database = _argString(L, "singePack", 2);
ok = packGame(directory, database);
_luaTrace(L, "singePack", "%s %s %s", directory, database, ok ? "ok" : "failed");
lua_pushboolean(L, ok);
return 1;
}
static int32_t apiSingeQuit(lua_State *L) {
_luaTrace(L, "singeQuit", "Quit requested.");
_global.running = false;
@ -13650,6 +13681,44 @@ static int32_t apiSingeQuit(lua_State *L) {
}
// result = singeRelaunch(path [, extra])
//
// Ends the engine as singeQuit does and, once everything has been torn down, starts path in its
// place with the same arguments, plus extra when one is given. For the menu's Engine Update,
// which has put a newer Singe beside this one and asks it for a fresh support folder. The file
// has to exist; it is made executable when the moment comes.
const char *singeRelaunchExtra(void) {
return _global.relaunchExtra;
}
const char *singeRelaunchPath(void) {
return _global.relaunchPath;
}
static int32_t apiSingeRelaunch(lua_State *L) {
const char *path = NULL;
_argCheck(L, "singeRelaunch", 1, 2);
path = _argString(L, "singeRelaunch", 1);
free(_global.relaunchExtra);
_global.relaunchExtra = (lua_gettop(L) >= 2) ? strdup(_argString(L, "singeRelaunch", 2)) : NULL;
if (!utilFileExists(path)) {
_luaTrace(L, "singeRelaunch", "%s does not exist", path);
lua_pushboolean(L, false);
return 1;
}
free(_global.relaunchPath);
_global.relaunchPath = strdup(path);
_global.running = false;
_luaTrace(L, "singeRelaunch", "%s", path);
lua_pushboolean(L, true);
return 1;
}
// singeReload(): runs the game again from its script at the end of this frame
static int32_t apiSingeReload(lua_State *L) {
_luaTrace(L, "singeReload", "Reload requested.");

View file

@ -113,6 +113,8 @@ typedef struct ConfigS {
bool disc; // Play a laserdisc video; otherwise the canvas is the world
bool softwareVideo; // Skip the platform's hardware decoder
bool noGamepad; // --nogamepad: ignore every gamepad, as --nomouse ignores the mice
bool noUpdateCheck; // --no_update_check: the menu never asks the service for a newer Singe
bool refreshSupport; // --refresh: put the Singe folder aside as old-Singe-vX.YY and write a fresh one
bool mapJoysticks; // --mapjoysticks: write a gamepad mapping for a device SDL does not recognise
bool joyMouse; // --joymouse: the first gamepad's left stick drives the mouse cursor
bool manyMouse; // --manymouse: the user asks for one mouse device per player, whatever the game asks for
@ -163,6 +165,8 @@ ConfigT *confFromGamesDat(const ConfigT *conf);
void settingsFree(SettingT *settings, int32_t count);
SettingT *settingsLoad(const ConfigT *conf, int32_t *count);
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);
const char *singeRelaunchExtra(void);
const char *singeRelaunchPath(void);
#endif // SINGE_H

View file

@ -28,10 +28,14 @@
// ===== THIS FILE IS AUTOMATICALLY GENERATED FROM version.h.in - DO NOT EDIT =====
// The version number lives in the project() line of CMakeLists.txt.
#define SINGE_VERSION @PROJECT_VERSION@
// SINGE_VERSION is a number, because scripts are handed it as one, and it is the major and minor
// alone: a beta of 3.00 is still 3.00 to anything doing arithmetic. VERSION_STRING is what
// people and the master service see, and it says which beta: "v3.00b2".
#define SINGE_VERSION @PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@
#define SINGE_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define SINGE_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define VERSION_STRING "v@PROJECT_VERSION@"
#define SINGE_VERSION_BETA @PROJECT_VERSION_TWEAK@ // 0 unless this is a beta
#define VERSION_STRING "v@SINGE_VERSION_FULL@"
#define COPYRIGHT_END_YEAR "@SINGE_COPYRIGHT_END_YEAR@"

View file

@ -0,0 +1,37 @@
-- The pictureAt condition: what the disc's picture is at a point. One watcher counts the frames
-- the spot reads dark and the frames it reads bright, so a run with no video proves the documented
-- answer (dark everywhere, since the engine reads zeroes when nothing is playing) and a run with
-- --framefile proves the condition sees the picture change.
return {
title = "Author test: the picture at a point",
players = 1,
size = { w = 720, h = 480 },
layers = { { kind = "overlay" } },
vars = { dark = 0, bright = 0, neither = 0 },
types = {
watcher = { look = { kind = "none" } }
},
rooms = {
{ name = "only", reset = true,
entities = {
{ id = "eye", type = "watcher", x = 8, y = 8 }
} }
},
rules = {
{ note = "The spot is dark",
on = "frame", each = "watcher",
when = { { "pictureAt", x = 200, y = 120, is = "dark" } },
act = { { "addVar", name = "dark", amount = 1 } } },
{ note = "The spot is bright",
on = "frame", each = "watcher",
when = { { "pictureAt", x = 200, y = 120, is = "bright" } },
act = { { "addVar", name = "bright", amount = 1 } } },
{ note = "The spot is neither",
on = "frame", each = "watcher",
when = { { "pictureAt", x = 200, y = 120, is = "unknown" } },
act = { { "addVar", name = "neither", amount = 1 } } }
}
}

View file

@ -0,0 +1,89 @@
-- Forge: the picture at a point, and the light sensor that judges a shot by it. pictureAt is the
-- condition a rule asks -- bright, dark, or neither -- and lightSensor is the behaviour built on
-- the same read, the ActionMax way of judging a shot over video. Both are the framework's own
-- words, so this scene is what covers them from the repository alone: the description compiles,
-- the emitted Lua calls the runtime, and the answers are the documented ones. Run plain, where
-- no video is playing and every frame must read dark; run with -v, where the picture is a moving
-- one and bright frames must appear.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
local built = authorBuild("testScripts/author/picture.forge", singeGetDataPath() .. "picture.singe")
local file = built and io.open(singeGetDataPath() .. "picture.singe", "r")
local text = file and file:read("a") or ""
if file then
file:close()
end
if not built then
debugPrint("PICTURE FAIL the description did not compile")
singeQuit()
return
end
if text:find("authorPictureIs", 1, true) == nil then
debugPrint("PICTURE FAIL the built game does not call authorPictureIs")
end
if AUTHOR.behaviours.lightSensor == nil then
debugPrint("PICTURE FAIL the framework has no lightSensor behaviour")
end
dofile(singeGetDataPath() .. "picture.singe")
local gameUpdate = onOverlayUpdate
local frames = 0
local FRAMES = 120
-- The read on its own, beside the condition's: the runtime answers one of three words, and a
-- point outside the picture is still answered rather than failing.
local function checkRead()
local here = authorPictureState(200, 120)
local far = authorPictureState(-50, -50)
if (here ~= "dark") and (here ~= "bright") and (here ~= "unknown") then
debugPrint("PICTURE FAIL authorPictureState answered '" .. tostring(here) .. "'")
end
if far == nil then
debugPrint("PICTURE FAIL a point off the picture answered nothing")
end
end
function onOverlayUpdate()
local result = gameUpdate and gameUpdate() or OVERLAY_UPDATED
local dark = AUTHOR_VARS.dark or 0
local bright = AUTHOR_VARS.bright or 0
local other = AUTHOR_VARS.neither or 0
frames = frames + 1
if frames == 1 then
checkRead()
end
overlayPrint(1, 1, string.format("pictureAt at 200,120: dark %d bright %d neither %d of %d frames", dark, bright, other, frames))
overlayPrint(1, 2, "lightSensor and pictureAt are the framework's own words now")
if frames == 60 then
singeScreenshot()
elseif frames == FRAMES then
debugPrint(string.format("PICTURE RESULT dark %d, bright %d, neither %d, of %d frames", dark, bright, other, frames))
if (dark + bright + other) ~= frames then
debugPrint("PICTURE FAIL the three answers do not add up to the frames drawn")
end
-- No video: the count of frames on the disc is zero, which is how a script tells.
if discGetFrameCount() == 0 then
if dark ~= frames then
debugPrint("PICTURE FAIL with no video playing every frame should read dark")
end
elseif (bright + other) == 0 then
-- Over a picture the read must see something other than the zeroes a missing video
-- gives. Which of bright and neither it sees is the video's business: the menu's
-- backdrop is mid-tone, so it reads neither and never bright.
debugPrint("PICTURE FAIL over a moving picture every frame still read dark")
end
singeQuit()
end
return result
end

View file

@ -149,13 +149,13 @@ def describe(folder, game):
% (lua("The last game's " + ident), lua(ident), lua(words)))
out = []
out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game])
out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the light sensor.")
out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the")
out.append("-- framework's own lightSensor behaviour.")
out.append("return {")
out.append("\ttitle = %s," % lua(TITLES[game]))
out.append("\tplayers = 1,")
out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT))
out.append("\tlayers = { { kind = \"disc\", file = %s }, { kind = \"overlay\" } }," % lua("frame_" + game + ".txt"))
out.append("\tvocabulary = \"Vocabulary.singe\", -- The light sensor, these games' own (beside them).")
out.append("\tvars = { shots = 0, good = 0, bad = 0, ammo = 0, limited = false, beat = 0, scoreShown = 0, lightShown = 0 },")
out.append("")
out.append("\ttypes = {")

View file

@ -219,6 +219,20 @@ end
env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir
assert(loadfile(dir .. "/" .. main, "t", env))()
-- The add-ons too, when the game has them: they carry the hooks the loop calls, and a hook that
-- is a function of the state can be read here rather than run at play (see swaps below). A file
-- that will not load under the stubs is no loss; the game still names it and the loop runs it.
local addonsFile = dir .. "/Script/addons.singe"
local addonsOk = false
do
local chunk = loadfile(addonsFile, "t", env)
if chunk then
addonsOk = pcall(chunk)
end
end
-- ===== The settings ===========================================================================
-- The dips, by name, from what the game was last saved with or shipped with: the framework
@ -329,6 +343,53 @@ local function scenesOf(level)
return by[0], nil
end
-- A game's swapScene is a function of the difficulty, the level, and the scene: Tron plays a
-- different scene of five levels on the two easier settings. Nothing else goes into it, so every
-- answer can be read here and written as a table -- difficulty, then level, then the scene asked
-- for, giving the scene played -- and a game whose only hook is this one needs no Lua at play.
-- Only the answers that differ are kept, so a game that swaps nothing writes nothing.
local function swaps()
if type(env.swapScene) ~= "function" then
return nil
end
local out = {}
local found = false
for difficulty = 0, 3 do
local byLevel = {}
for level = 1, (env.finalstage or 0) do
local scenes = (env.Level[level] or {})[4] or 0
local byScene = {}
for scene = 1, scenes do
env.dip_Difficulty = difficulty
env.iLevel = level
env.iScene = scene
env.bSwap = false
local ok = pcall(env.swapScene)
if ok and (type(env.iScene) == "number") and (env.iScene ~= scene) then
byScene[scene] = env.iScene
found = true
end
end
if next(byScene) then
byLevel[level] = byScene
end
end
if next(byLevel) then
out[difficulty] = byLevel
end
end
env.dip_Difficulty = nil
env.iLevel = nil
env.iScene = nil
env.bSwap = nil
return found and out or nil
end
-- ===== Writing ================================================================================
local out = {}
@ -619,6 +680,7 @@ local qte = {
saves = kimmy and saves() or nil,
settings = settings,
dips = dips,
swaps = (not rdg) and swaps() or nil,
highScores = rdg and RDG_BOARD or highScores(),
tweaks = rdg and RDG_TWEAKS[name] or nil,
sdq = sdq and sdqOrder() or nil,
@ -677,9 +739,11 @@ put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
put("")
put("\ttypes = {")
-- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a
-- framework's is drawn by the runtime's look for that loop.
put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
-- No look: the loops expose score, lives, credits, level and prompt as vars for a game's own
-- hud layer to bind, and draw none of the game's own screens. The hud and <loop>Hud looks this
-- line used to name belong to the drawing work the owner parked (FORGE.md 14.15) and are in no
-- manifest, so naming one only made the runtime complain about a look it does not have.
put("\t\tgame = { look = { kind = \"none\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
put("\t},")
put("")
put("\trooms = {")

View file

@ -39,6 +39,46 @@ ACTIONMAX = "ActionMax"
# and fonts (the loop draws its own text), the launcher art in Assets/ (the menu takes its
# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework).
NOT_KARIS = {"KarisFramework", "MazescaterFramework", "games.dat", "Assets", "Structure"}
# A title proved to play from its description alone (util/forgeStandalone.py writes the list): its
# port carries none of the game's Lua, and the description in it names none. What goes with the
# script is the Script folder it kept its add-ons in and the Fonts and Overlay folders only the
# game's own drawing opened; the loop draws with the engine's font, and its eight sounds come from
# Sounds, so that stays.
STANDALONE_LIST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "ports", "standalone.txt")
HOSTING = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$')
def standalone():
names = set()
if os.path.isfile(STANDALONE_LIST):
for line in open(STANDALONE_LIST):
line = line.strip()
if line and not line.startswith("#"):
names.add(line)
return names
STANDALONE = standalone()
def unhost(description):
# The description in the port, with the lines that name the game's Lua taken out.
if not os.path.isfile(description):
return
kept = [line for line in open(description, errors="replace").read().splitlines(True) if not HOSTING.match(line)]
with open(description, "w") as out:
out.writelines(kept)
def hostedFiles(description):
# What a description names beside itself, so a standalone port can leave it behind: the
# game's script, and the folders that went with it.
text = open(description, errors="replace").read()
named = set(re.findall(r'^\s*(?:script|addons)\s*=\s*"([^"]+)"', text, re.M))
out = {"Fonts", "Overlay"}
for name in named:
out.add(name.split("/")[0])
return out
def engine():
@ -189,6 +229,8 @@ def main():
# A hand-written title (one with a vocabulary beside its description) loads its own
# fonts, which a framework title's port never does.
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
if name in STANDALONE:
leave |= hostedFiles(path)
placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave)
if os.path.isfile(os.path.join(LIBRARY, name, "games.dat")):
dat = entries(os.path.join(LIBRARY, name, "games.dat"))
@ -226,6 +268,8 @@ def main():
# A hand-written title (one with a vocabulary beside its description) loads its own
# fonts, which a framework title's port never does.
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
if title in STANDALONE:
leave |= hostedFiles(path)
placeTree(source, folder, leave | ({"singe", "Singe", "data", "Menu.sh", "MANIFEST.md"} if flat else set()), (".zip",) if flat else ())
# The framefile at the top of the title (with exactly the videos it names, loose beside
# it or under Video/), or the one beside the script, already copied with the game.
@ -248,6 +292,12 @@ def main():
builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe")))
print("assembled %s (Hypseus)" % title)
# A standalone port's description names no Lua, so the loop reads its snapshot instead of
# loading files that are no longer beside it.
for description, _ in builds:
if os.path.basename(os.path.dirname(description)) in STANDALONE or os.path.basename(os.path.dirname(os.path.dirname(description))) in STANDALONE:
unhost(description)
# The compiler is Forge's, so the engine runs it: every description built into its folder,
# from a directory with Forge beside Singe, never the repository.
run = tempfile.mkdtemp(prefix="forgePorts.")

143
util/forgeStandalone.py Normal file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
# Which Forge ports play from their description alone, with none of the game's own Lua?
#
# The qte and kimmy behaviours load only what the description names, so deleting its "script" and
# "addons" lines leaves the loop nothing but the snapshot the converter wrote. This runs each
# framework title that way, against its original, through the same comparison the ports are proved
# with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt.
# util/forgePorts.py reads that list and assembles those ports without the game's Lua.
#
# python3 util/forgeStandalone.py [<title> ...] (no arguments does them all)
# python3 util/forgeStandalone.py --hosted [<title> ...]
#
# --hosted runs each title as it ships instead, with its Lua where it is: the check to run after
# regenerating descriptions, since a converter that has learned something writes a description the
# loop reads differently. It writes no list.
#
# SINGE the engine binary (default: the harness picks the newest build)
# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported)
#
# A title is tested on a hard-linked copy of itself, so the library is never edited and the videos
# are not duplicated. A title whose description still needs its Lua stays off the list and keeps
# it: startConf drawing at random and specialScore's own scoring are code, not data.
import os
import re
import shutil
import subprocess
import sys
import tempfile
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported"))
LIST = os.path.join(REPO, "testScripts", "ports", "standalone.txt")
HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$')
def titles():
"""Every description the qte or kimmy behaviour plays, as (name, family, game argument,
description path). The library's own are a directory and a script; a Hypseus title is its
folder under hypseus/."""
found = []
for root, dirs, files in os.walk(LIBRARY):
for name in sorted(f for f in files if f.endswith(".forge")):
path = os.path.join(root, name)
text = open(path, errors="replace").read()
loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text)
if not loop or loop.group(1) not in ("qte", "kimmy"):
continue
script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M)
if not script:
continue
relative = os.path.relpath(path, LIBRARY)
if relative.startswith("hypseus" + os.sep):
title = relative.split(os.sep)[1]
found.append((title, "hypseus", title, path))
else:
folder = relative.split(os.sep)[0]
found.append((folder, "karis", "%s/%s" % (folder, script.group(1)), path))
return found
def strip(source, into):
"""The title, hard-linked so no video is copied, with its description's hosting lines gone.
The description itself is replaced rather than edited, since a hard link is shared."""
os.makedirs(into, exist_ok=True)
target = os.path.join(into, os.path.basename(source))
shutil.rmtree(target, ignore_errors=True)
subprocess.run(["cp", "-al", source, target], check=True)
return target
def unhost(descriptions):
for path in descriptions:
kept = [line for line in open(path, errors="replace").read().splitlines(True) if not HOSTED.match(line)]
os.remove(path) # The link, not the library's file.
with open(path, "w") as out:
out.writelines(kept)
def compare(family, game, library):
done = subprocess.run([os.path.join(REPO, "testScripts", "ports", "compare.sh"), family, game],
cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library),
capture_output=True, text=True, timeout=1800)
out = done.stdout + done.stderr
if "plays as the original does" in out:
return True, [line for line in out.splitlines() if "plays as" in line][0]
first = [line for line in out.splitlines() if ("differ" in line) or ("Error" in line) or ("FAIL" in line)]
return False, first[0] if first else "no verdict"
def main():
wanted = sys.argv[1:]
hosted = "--hosted" in wanted
if hosted:
wanted.remove("--hosted")
rows = [row for row in titles() if not wanted or row[0] in wanted]
if not rows:
sys.exit("forgeStandalone: no titles to test")
work = tempfile.mkdtemp(prefix="forgeStandalone.")
passed = []
for title, family, game, path in rows:
if hosted:
# As it ships: the library itself, nothing copied, nothing stripped.
ok, saying = compare(family, game, LIBRARY)
print("%-28s %s" % (title, "agrees" if ok else "DIFFERS"), flush=True)
print(" %s" % saying, flush=True)
if ok:
passed.append(title)
continue
# The title, and for a Hypseus one the hypseus/ folder the harness looks under.
if family == "hypseus":
library = os.path.join(work, "hypseus")
copied = strip(os.path.join(LIBRARY, "hypseus", title), library)
else:
library = work
copied = strip(os.path.join(LIBRARY, title), library)
inside = [os.path.join(r, f) for r, _, fs in os.walk(copied) for f in fs if f.endswith(".forge")]
unhost(inside)
ok, saying = compare(family, game, library)
print("%-28s %s" % (title, "STANDS ALONE" if ok else "needs its Lua"), flush=True)
print(" %s" % saying, flush=True)
if ok:
passed.append(title)
shutil.rmtree(copied, ignore_errors=True)
shutil.rmtree(work, ignore_errors=True)
if hosted:
print("\n%d of %d agree as they ship" % (len(passed), len(rows)))
elif not wanted:
with open(LIST, "w") as out:
out.write("# Titles whose Forge port plays from its description alone, proved by\n")
out.write("# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua\n")
out.write("# out of these ports. Regenerate the list; do not edit it by hand.\n")
for title in sorted(passed):
out.write(title + "\n")
print("\n%d of %d stand alone; written to %s" % (len(passed), len(rows), LIST))
else:
print("\n%d of %d stand alone (the list is only written by a full run)" % (len(passed), len(rows)))
if __name__ == "__main__":
main()

View file

@ -134,9 +134,9 @@ def boomShape(t, done):
def main():
os.makedirs(OUT, exist_ok=True)
save(ship(), "ship.png")
save(rock(), "rock.png")
save(shot(), "shot.png")
save(ship(), "spaceship.png")
save(rock(), "asteroid.png")
save(shot(), "bullet.png")
save(star(), "star.png")
save(walk(), "walk.png")
tone("shoot.wav", 0.18, shootShape)

View file

@ -134,4 +134,5 @@ def main():
print("%s: %d frames, %dx%d at %g fps, %.2f seconds" % (args.out, intro, args.width, args.height, 1000.0 / args.step, intro * args.step / 1000.0))
main()
if __name__ == "__main__":
main()

277
util/renderReel.py Normal file
View file

@ -0,0 +1,277 @@
# Renders a sizzle reel: the menu's intro with its sound, then a run of short shots of games and
# scenes, each with a caption, cut together as one H.264 file. Every shot is the engine itself
# running a script headless and photographing every frame, the way util/renderMenuAd.py records
# the intro, so what the reel shows is what the engine draws and nothing is mocked up.
#
# The shots are a list at the top of this file: a caption, a script (in the repo's testScripts,
# or a lesson from docs/learn), how many frames, and whether it needs the disc. Add a line to
# add a shot.
#
# The intro keeps its own sound. Nothing in the tree loops under the rest -- the backdrop
# recording has no audio and the menu's intro fades out by design -- so the shots are silent
# unless --music names a track. The engine's offscreen video driver stops at 1024x768, so anything larger records
# under a virtual X display (xvfb-run) on the software rasteriser, which is slow -- a frame is a
# second or two at 1080p -- and the same picture; a quick look at 1024x576 records on the real
# GPU in a fraction of the time.
#
# Usage: python3 util/renderReel.py (1920x1080 at 50 fps)
# python3 util/renderReel.py --width 1024 --height 576 --step 33 --out quick.mp4
import argparse
import os
import re
import shutil
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OFFSCREEN_W = 1024 # The biggest the offscreen video driver gives; larger goes under Xvfb.
OFFSCREEN_H = 768
CRF = 15
AUDIO_BITRATE = "320k"
FONT = os.path.join(REPO, "assets", "FreeSansBold.ttf")
VIDEO = "Singe/menuBackground.mkv"
# The reel: what to show, in order. frames is how long each shot runs at the chosen step; at
# 20 ms a shot of 150 frames is three seconds. A shot with disc = True runs over the menu's
# backdrop video, which every install has. A shot names a script in testScripts or a lesson in
# docs/learn (the lesson folder layout the book teaches is built for it).
SHOTS = [
# The engine's own test scenes make the pictures; they write what they are checking over the
# top, which "clean" wipes away (see RUNNER). A 2D shot must not be cleaned: it draws into
# the overlay, and cleaning would wipe the game itself. Several scenes put the disc picture on
# a material and will not start without one, so they are given the menu's backdrop, which is
# the one video every install has.
{ "caption": "Sponza, lit and shadowed", "script": "testScripts/scene27.singe", "frames": 150, "clean": True },
{ "caption": "Sky, fog, and a sun that casts", "script": "testScripts/scene30.singe", "frames": 150, "clean": True },
{ "caption": "Bloom", "script": "testScripts/scene32.singe", "frames": 150, "clean": True },
{ "caption": "Terrain", "script": "testScripts/scene34.singe", "frames": 150, "clean": True },
{ "caption": "Water", "script": "testScripts/scene24.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "A bulb in a room", "script": "testScripts/scene21.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "Materials", "script": "testScripts/scene28.singe", "frames": 150, "clean": True },
{ "caption": "Physics", "script": "testScripts/scene9.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "Ragdolls", "script": "testScripts/scene25.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "Vehicles", "script": "testScripts/scene23.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "Particles", "script": "testScripts/scene18.singe", "frames": 150, "clean": True, "disc": True },
{ "caption": "Characters that find their own way","script": "testScripts/scene33.singe", "frames": 150, "clean": True },
{ "caption": "Animation, blended", "script": "testScripts/scene29.singe", "frames": 150, "clean": True },
# And what somebody makes with it: the lessons of the beginner's book, which are real games
# and are ours. These draw into the overlay, so they are never cleaned.
{ "caption": "Lesson seven: a game from nothing","lesson": "07-a-game", "frames": 150 },
{ "caption": "Lesson eleven: sprites and sound", "lesson": "11-hitting-things", "frames": 150 },
{ "caption": "Thirty lessons from nothing", "lesson": "22-3d", "frames": 150 },
]
# The runner every shot goes through: the shot's own script, untouched, with a frame counter that
# photographs each frame and quits when the shot is over.
RUNNER = '''-- Written by util/renderReel.py: runs the shot's own script and photographs every frame.
dofile(%(script)s)
local shotUpdate = onOverlayUpdate
local shotFrames = 0
function onOverlayUpdate()
local result = nil
if shotUpdate then
result = shotUpdate()
end
-- A test scene writes what it is checking over its own picture, which is right for a test and
-- wrong for a reel. A 3D scene is drawn by the engine underneath the overlay, so wiping the
-- overlay after the scene has had its turn leaves the picture and takes the notes away. Only
-- for shots that say so: a 2D scene draws INTO the overlay and would be wiped out with them.
if %(clean)s then
colorBackground(0, 0, 0, 0)
overlayClear()
end
singeScreenshot()
shotFrames = shotFrames + 1
if shotFrames >= %(frames)d then
singeQuit()
end
return OVERLAY_UPDATED
end
'''
# Where a shot's pictures actually landed. The engine files screenshots under
# <data directory>/<game name>/ when the script it was launched with sits in a folder of its own,
# and straight into the data directory when it does not; and it numbers from the first free name,
# which is not always zero. Answers the directory and the number to start at, or None.
def framesIn(folder):
places = [folder] + [os.path.join(folder, name) for name in sorted(os.listdir(folder))
if os.path.isdir(os.path.join(folder, name))]
for place in places:
numbers = sorted(int(m.group(1)) for m in
(re.match(r"^singe(\d+)\.png$", f) for f in os.listdir(place)) if m)
if numbers:
return place, numbers[0], len(numbers)
return None, 0, 0
def findBinary():
folder = os.path.join(REPO, ".builddir", "linux-gcc", "x86_64", "singe")
for name in sorted(os.listdir(folder)):
path = os.path.join(folder, name)
if name.startswith("Singe") and os.path.isfile(path) and os.access(path, os.X_OK):
return path
raise SystemExit("no Singe binary in %s; build first" % folder)
def engine(binary, work, frames, width, height, step, script, disc):
env = dict(os.environ)
env["SDL_AUDIO_DRIVER"] = "dummy"
env["SDL_AUDIODRIVER"] = "dummy"
command = [binary, "-k", "-s", "-C", "%dx%d" % (width, height), "-x", str(width), "-y", str(height),
"--deterministic=%d" % step, "-d", frames + os.sep]
if disc:
command += ["-v", VIDEO]
command.append(script)
if (width > OFFSCREEN_W) or (height > OFFSCREEN_H):
command = ["xvfb-run", "-a", "-s", "-screen 0 %dx%dx24" % (width, height)] + command
else:
env["SDL_VIDEODRIVER"] = "offscreen"
result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=3600)
return result.returncode, result.stdout.decode("utf-8", "replace")
# The intro, exactly as util/renderMenuAd.py records it, borrowed rather than copied.
def renderIntro(binary, work, frames, width, height, step):
sys.path.insert(0, os.path.join(REPO, "util"))
import renderMenuAd
with open(os.path.join(work, "render.singe"), "w") as out:
out.write(renderMenuAd.DRIVER % {"step": step, "width": width, "height": height})
text = renderMenuAd.render(binary, work, frames, width, height, step)
found = re.search(r"RENDER intro=(\d+) step=(\d+)", text)
if not found:
sys.stdout.write(text)
raise SystemExit("the engine never said how long the intro is")
return int(found.group(1))
def setUpShot(work, shot, runnerName):
"""Lays the shot out as the engine expects and answers (what to dofile, what to launch), both
relative to the work directory. A lesson's runner goes INSIDE the lesson's folder: DIR is the
folder of the script the engine was launched with, and the lesson loads its art through DIR."""
if "lesson" in shot:
# A lesson runs from its own folder in a work folder, with the art kit beside it.
source = os.path.join(REPO, "docs", "learn")
folder = os.path.join(work, shot["lesson"])
shutil.rmtree(folder, ignore_errors=True)
os.makedirs(folder)
shutil.copyfile(os.path.join(source, shot["lesson"] + ".singe"), os.path.join(folder, shot["lesson"] + ".singe"))
if os.path.isdir(os.path.join(source, "art")):
shutil.copytree(os.path.join(source, "art"), os.path.join(folder, "art"))
for extra in os.listdir(source):
if os.path.isfile(os.path.join(source, extra)) and not extra.endswith(".singe"):
shutil.copyfile(os.path.join(source, extra), os.path.join(folder, extra))
return (shot["lesson"] + "/" + shot["lesson"] + ".singe",
shot["lesson"] + "/" + runnerName)
link = os.path.join(work, "testScripts")
if not os.path.islink(link):
os.symlink(os.path.join(REPO, "testScripts"), link)
return shot["script"], runnerName
def main():
parser = argparse.ArgumentParser(description="render the sizzle reel")
parser.add_argument("--out", default=os.path.join(REPO, ".builddir", "SingeReel.mp4"))
parser.add_argument("--width", type=int, default=1920)
parser.add_argument("--height", type=int, default=1080)
parser.add_argument("--step", type=int, default=20, help="milliseconds a frame: 20 is 50 fps, 33 is 30.3")
parser.add_argument("--crf", type=int, default=CRF)
parser.add_argument("--binary", help="the engine to record with, in place of the built one")
parser.add_argument("--music", help="a track to play quietly under the shots; without one they are silent")
parser.add_argument("--musicLevel", type=float, default=0.25, help="how loud that track is, 1.0 being as recorded")
parser.add_argument("--only", type=int, help="record only this many shots after the intro, for a quick look")
parser.add_argument("--keep", action="store_true", help="leave the frames behind")
args = parser.parse_args()
binary = args.binary or findBinary()
work = os.path.join(REPO, ".builddir", "reel")
os.makedirs(work, exist_ok=True)
fps = 1000.0 / args.step
pieces = []
# The intro, with its own sound.
frames = os.path.join(work, "intro")
shutil.rmtree(frames, ignore_errors=True)
os.makedirs(frames)
print("intro")
introFrames = renderIntro(binary, work, frames, args.width, args.height, args.step)
where, first, got = framesIn(frames)
pieces.append({ "frames": where or frames, "first": first, "count": got or introFrames, "caption": None,
"sound": os.path.join(REPO, "assets", "menuIntro.flac") })
# The shots.
shots = SHOTS[:args.only] if args.only else SHOTS
for index, shot in enumerate(shots, 1):
frames = os.path.join(work, "shot%02d" % index)
shutil.rmtree(frames, ignore_errors=True)
os.makedirs(frames)
script, runner = setUpShot(work, shot, "reel%02d.singe" % index)
with open(os.path.join(work, runner), "w") as out:
out.write(RUNNER % {"script": '"%s"' % script, "frames": shot["frames"],
"clean": "true" if shot.get("clean") else "false"})
print("shot %d of %d: %s" % (index, len(shots), shot["caption"]))
code, text = engine(binary, work, frames, args.width, args.height, args.step, runner, shot.get("disc", False))
where, first, got = framesIn(frames)
if got == 0:
sys.stdout.write(text[-2000:])
raise SystemExit("shot %d recorded nothing" % index)
pieces.append({ "frames": where, "first": first, "count": got, "caption": shot["caption"], "sound": None })
# Each piece becomes a clip with its caption burnt in, then the clips are joined. The intro
# keeps its sound; the shots run under the intro's own tail, looped quietly, so the reel is
# never silent.
clips = []
for index, piece in enumerate(pieces):
clip = os.path.join(work, "clip%02d.mp4" % index)
command = ["ffmpeg", "-y", "-loglevel", "error",
"-framerate", "1000/%d" % args.step, "-start_number", str(piece["first"]),
"-i", os.path.join(piece["frames"], "singe%03d.png")]
filters = ["format=yuv420p"]
if piece["caption"]:
text = piece["caption"].replace("'", "\\'").replace(":", "\\:")
filters.append("drawtext=fontfile='%s':text='%s':fontsize=%d:fontcolor=white:borderw=3:bordercolor=black@0.8:x=(w-text_w)/2:y=h-text_h-%d"
% (FONT, text, args.height // 18, args.height // 12))
command += ["-vf", ",".join(filters), "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf), "-profile:v", "high", "-an", clip]
subprocess.run(command, check=True)
clips.append(clip)
listing = os.path.join(work, "clips.txt")
with open(listing, "w") as out:
for clip in clips:
out.write("file '%s'\n" % clip)
silent = os.path.join(work, "reel-silent.mp4")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", listing, "-c", "copy", silent], check=True)
# Sound: the intro's own, then whatever --music names, quietly, under the shots. Nothing in
# the tree loops: the backdrop recording has no audio at all, and the menu's intro fades out
# by design, so the shots are silent unless a track is given.
total = sum(p["count"] for p in pieces) * args.step / 1000.0
introSeconds = pieces[0]["count"] * args.step / 1000.0
bed = os.path.join(work, "bed.wav")
tail = max(0.0, total - introSeconds)
if args.music:
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-stream_loop", "-1", "-i", args.music,
"-vn", "-t", "%.3f" % tail, "-af", "volume=%g,afade=t=in:d=1,afade=t=out:st=%.3f:d=2" % (args.musicLevel, max(0.0, tail - 2.0)),
"-ar", "48000", "-ac", "2", bed], check=True)
else:
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
"-t", "%.3f" % tail, bed], check=True)
sound = os.path.join(work, "sound.wav")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", pieces[0]["sound"], "-i", bed,
"-filter_complex", "[0:a]aformat=sample_rates=48000:channel_layouts=stereo[i];[1:a]adelay=%d|%d[b];[i][b]amix=inputs=2:duration=longest:dropout_transition=0,volume=2.0[a]" % (int(introSeconds * 1000), int(introSeconds * 1000)),
"-map", "[a]", "-t", "%.3f" % total, sound], check=True)
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", silent, "-i", sound,
"-c:v", "copy", "-c:a", "aac", "-b:a", AUDIO_BITRATE, "-movflags", "+faststart", "-shortest", args.out], check=True)
if not args.keep:
for piece in pieces:
shutil.rmtree(piece["frames"], ignore_errors=True)
print("%s: %d shots, %dx%d at %g fps, %.1f seconds" % (args.out, len(pieces) - 1, args.width, args.height, fps, total))
if __name__ == "__main__":
main()

View file

@ -139,6 +139,12 @@ def shoot(binary, renderer):
shots = os.path.join(work, "shots")
shutil.rmtree(shots, ignore_errors=True)
os.makedirs(shots)
# Point the menu at a master service other than the real one -- a local instance while working
# on the online features -- by naming its URL in SINGE_MASTER_URL; the menu reads it from
# master.dat in its data directory, which this run has just emptied.
if os.environ.get("SINGE_MASTER_URL"):
with open(os.path.join(shots, "master.dat"), "w") as out:
out.write('{"url": "%s"}' % os.environ["SINGE_MASTER_URL"])
# The menu finds its games in the folders beside it: the test scripts' games.dat is enough.
link = os.path.join(work, "testScripts")
if not os.path.islink(link):