From 25aa909c3d8c4dfd79bf9dfb0241fe32ac05fd05 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Wed, 23 Sep 2026 22:15:01 -0500 Subject: [PATCH] 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. --- CHANGELOG | 147 +- CMakeLists.txt | 41 +- assets/Forge/Author.singe | 106 + assets/Forge/Forge.singe | 128 +- assets/Master.singe | 86 +- assets/Menu.rml | 1 + assets/Menu.singe | 145 + assets/MenuDocument.singe | 19 +- assets/MenuOverlay.singe | 22 + assets/Net.singe | 79 +- assets/Tools.singe | 289 +- assets/menu.rcss | 16 + assets/settings.cfg | 1 + build-docs.sh | 87 +- cmake/Superbuild.cmake | 4 +- docs/Forge.adoc | 27 +- docs/ForgeVocabulary.adoc | 8 + docs/Learn.adoc | 9 +- docs/Manual.adoc | 18721 +------------------ docs/Reference.adoc | 18605 ++++++++++++++++++ docs/images/learn/09-pictures.png | 4 +- docs/images/learn/10-sound.png | 4 +- docs/images/learn/11-hitting-things.png | 4 +- docs/images/learn/12-score.png | 2 +- docs/images/learn/13-game-folder.png | 2 +- docs/images/learn/20-gui.png | 2 +- docs/images/learn/25-particles.png | 4 +- docs/images/preview.svg | 9 + docs/learn/09-pictures.singe | 6 +- docs/learn/10-sound.singe | 6 +- docs/learn/11-hitting-things.singe | 6 +- docs/learn/12-score.singe | 6 +- docs/learn/13-game-folder.singe | 6 +- docs/learn/20-gui.singe | 2 +- docs/learn/25-particles.singe | 2 +- docs/learn/art/README.txt | 6 +- docs/learn/art/{rock.png => asteroid.png} | 0 docs/learn/art/{shot.png => bullet.png} | 0 docs/learn/art/{ship.png => spaceship.png} | 0 docs/lessons/07-a-game.adoc | 4 +- docs/lessons/08-when-it-goes-wrong.adoc | 2 +- docs/lessons/09-pictures.adoc | 34 +- docs/lessons/10-sound.adoc | 6 +- docs/lessons/11-hitting-things.adoc | 12 +- docs/lessons/13-game-folder.adoc | 22 +- docs/lessons/14-video.adoc | 4 +- docs/lessons/17-light-guns.adoc | 2 +- docs/lessons/18-qte.adoc | 2 +- docs/lessons/19-text.adoc | 2 +- docs/lessons/20-gui.adoc | 6 +- docs/lessons/22-3d.adoc | 4 +- docs/lessons/23-models.adoc | 6 +- docs/lessons/24-physics.adoc | 2 +- docs/lessons/25-particles.adoc | 4 +- docs/lessons/26-navigation.adoc | 4 +- docs/lessons/29-shipping.adoc | 6 +- docs/lessons/30-cabinet.adoc | 6 +- docs/preview/docinfo.html | 20 + src/main.c | 310 +- src/main.h | 1 + src/pack.c | 63 + src/singe.c | 69 + src/singe.h | 4 + src/version.h.in | 8 +- testScripts/author/picture.forge | 37 + testScripts/scene100.singe | 89 + util/forgePortActionMax.py | 4 +- util/forgePortKaris.lua | 70 +- util/forgePorts.py | 50 + util/forgeStandalone.py | 143 + util/learnKit.py | 6 +- util/renderMenuAd.py | 3 +- util/renderReel.py | 277 + util/shootMenu.py | 6 + 74 files changed, 21111 insertions(+), 18789 deletions(-) create mode 100644 docs/Reference.adoc create mode 100644 docs/images/preview.svg rename docs/learn/art/{rock.png => asteroid.png} (100%) rename docs/learn/art/{shot.png => bullet.png} (100%) rename docs/learn/art/{ship.png => spaceship.png} (100%) create mode 100644 docs/preview/docinfo.html create mode 100644 testScripts/author/picture.forge create mode 100644 testScripts/scene100.singe create mode 100644 util/forgeStandalone.py create mode 100644 util/renderReel.py diff --git a/CHANGELOG b/CHANGELOG index 5db3716ae..72a834e16 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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-, 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-. 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-, 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--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.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- 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8922ccbd1..0ce52b3e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/assets/Forge/Author.singe b/assets/Forge/Author.singe index c4c6b1327..cca791539 100644 --- a/assets/Forge/Author.singe +++ b/assets/Forge/Author.singe @@ -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 diff --git a/assets/Forge/Forge.singe b/assets/Forge/Forge.singe index 009c32ee7..ef70474c2 100644 --- a/assets/Forge/Forge.singe +++ b/assets/Forge/Forge.singe @@ -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 diff --git a/assets/Master.singe b/assets/Master.singe index fe74465cd..67b722e0e 100644 --- a/assets/Master.singe +++ b/assets/Master.singe @@ -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 diff --git a/assets/Menu.rml b/assets/Menu.rml index 7bf84d350..0329ec59a 100644 --- a/assets/Menu.rml +++ b/assets/Menu.rml @@ -25,6 +25,7 @@
+