Some issues fixed with networking. More Forge work.
This commit is contained in:
parent
67e2a1ddcd
commit
59741f25e8
43 changed files with 6199 additions and 2229 deletions
51
CHANGELOG
51
CHANGELOG
|
|
@ -71,6 +71,33 @@ API Changes
|
||||||
[, argument]) starts another executable when this one ends. Neither the
|
[, argument]) starts another executable when this one ends. Neither the
|
||||||
question nor the download needs an account.
|
question nor the download needs an account.
|
||||||
|
|
||||||
|
- A game can say what it needs of a machine. REQUIRES in its games.dat lists
|
||||||
|
the capabilities it uses -- SCENE, GUI, SHADOWS, POST, SKIN, MORPH -- and no
|
||||||
|
REQUIRES means 2D; TIER (0 to 3) says how much machine it wants. You rarely
|
||||||
|
write either by hand: the engine notices what a game uses as it runs, says in
|
||||||
|
the trace when the game uses something its REQUIRES leaves out, and --pack
|
||||||
|
writes REQUIRES into an entry that has none. The menu measures this
|
||||||
|
machine's tier from its own backdrop the first time it runs, keeps it in
|
||||||
|
machine.cfg, and marks a game this machine cannot run (and says why) or may
|
||||||
|
run slowly. Nothing is hidden. Behind it: singeCapabilities() reports what
|
||||||
|
the machine can do, singeSaveTier(tier) keeps the tier, and sceneMeasure(on)
|
||||||
|
times the 3D scene's real work rather than the display's pacing.
|
||||||
|
|
||||||
|
- Connections to the master service trust two lists of certificate
|
||||||
|
authorities at once: the one Singe ships and the one the machine keeps
|
||||||
|
(the distribution's file on Linux, the root store on Windows, the system
|
||||||
|
anchors on macOS). Either one vouching for the server is enough, so a
|
||||||
|
cabinet whose copy of Singe has aged can still fetch the update that
|
||||||
|
replaces it. A self-hosted service with its own authority puts that
|
||||||
|
certificate in cacerts.local.pem in the data folder, which Singe never
|
||||||
|
writes. A refused connection says which lists it checked and what the
|
||||||
|
machine's clock reads when a certificate is not valid yet, and says the
|
||||||
|
clock is wrong outright when it reads earlier than the date of the list
|
||||||
|
Singe ships -- the usual trouble on a board with no clock battery that
|
||||||
|
has not had network time. Behind it:
|
||||||
|
netTrustSystem(context) adds the machine's roots to a LuaSec context, and
|
||||||
|
every request shares one context instead of building one each time.
|
||||||
|
|
||||||
- Beta builds have a version of their own. CMake's project version has to be
|
- 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
|
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
|
is v3.00 and 3.00.1.2 is v3.00b2, which is what the binary is named, what
|
||||||
|
|
@ -79,8 +106,16 @@ API Changes
|
||||||
offered v3.00 the day it appears, and the minor is compared as a number, so
|
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.
|
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
|
- A Forge game says who made it. The game's form has credits fields --
|
||||||
editor exports, packs, and uploads it; an upload is not in the catalogue
|
developer, publisher, year, platform, genre, creator, source, and
|
||||||
|
description -- and a release writes them into its games.dat, where the menu
|
||||||
|
shows them. Notes go into a CREDITS.txt beside the game, and licence
|
||||||
|
files named in the credits travel with it. A port of an existing game gets
|
||||||
|
its original author's credits filled in from the original's own games.dat
|
||||||
|
and script header.
|
||||||
|
|
||||||
|
- A game can be sent to the master service for review from Forge. Shift+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
|
until it has been reviewed, and Forge shows where each version of yours
|
||||||
stands. Behind it: singePack(directory, database) packs a game from
|
stands. Behind it: singePack(directory, database) packs a game from
|
||||||
a script exactly as --pack does, and the network layer can send a request
|
a script exactly as --pack does, and the network layer can send a request
|
||||||
|
|
@ -1125,7 +1160,17 @@ New Features
|
||||||
|
|
||||||
- SIGTERM and SIGINT end all of Singe: the running game shuts down as usual,
|
- SIGTERM and SIGINT end all of Singe: the running game shuts down as usual,
|
||||||
and the menu it came from does not come back. A second signal while that
|
and the menu it came from does not come back. A second signal while that
|
||||||
is under way ends the process at once.
|
is under way ends the process at once, unless it comes within a second of
|
||||||
|
the first, since timeout and friends send the same request twice.
|
||||||
|
|
||||||
|
- A script stuck in a loop no longer hangs Singe. Once a script has gone half
|
||||||
|
a second without returning to the engine, Singe keeps the window's events
|
||||||
|
moving for it, so the desktop doesn't mark the window frozen. If you then
|
||||||
|
close the window, or send SIGTERM or SIGINT, and the script still hasn't
|
||||||
|
returned after three seconds, Singe stops it and shuts down as usual, your
|
||||||
|
onShutdown included. The log shows where the script was when it was
|
||||||
|
stopped. A script that returns promptly never notices any of this, and one
|
||||||
|
catching errors with pcall can't hold the stop off.
|
||||||
|
|
||||||
- --program traces where each frame's time went, every five seconds: per
|
- --program traces where each frame's time went, every five seconds: per
|
||||||
loop, the disc, the script, the scene, the GUIs, the overlay upload and the
|
loop, the disc, the script, the scene, the GUIs, the overlay upload and the
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,8 @@ set(SINGE_SOURCE
|
||||||
src/midi.h
|
src/midi.h
|
||||||
src/midiIo.c
|
src/midiIo.c
|
||||||
src/midiIo.h
|
src/midiIo.h
|
||||||
|
src/netTrust.c
|
||||||
|
src/netTrust.h
|
||||||
thirdparty/tinysoundfont/tsf.h
|
thirdparty/tinysoundfont/tsf.h
|
||||||
thirdparty/tinysoundfont/tml.h
|
thirdparty/tinysoundfont/tml.h
|
||||||
src/render.c
|
src/render.c
|
||||||
|
|
@ -415,6 +417,8 @@ set(SINGE_SOURCE
|
||||||
src/renderGpu.c
|
src/renderGpu.c
|
||||||
src/scene.c
|
src/scene.c
|
||||||
src/scene.h
|
src/scene.h
|
||||||
|
src/scriptWatch.c
|
||||||
|
src/scriptWatch.h
|
||||||
src/singe.c
|
src/singe.c
|
||||||
src/singe.h
|
src/singe.h
|
||||||
src/stddclmr.h
|
src/stddclmr.h
|
||||||
|
|
@ -718,6 +722,7 @@ elseif(KANGAROO_OS STREQUAL "macos")
|
||||||
-Wl,-framework,AudioToolbox
|
-Wl,-framework,AudioToolbox
|
||||||
-Wl,-framework,AVFoundation
|
-Wl,-framework,AVFoundation
|
||||||
-Wl,-framework,Foundation
|
-Wl,-framework,Foundation
|
||||||
|
-Wl,-framework,Security
|
||||||
-Wl,-weak_framework,GameController
|
-Wl,-weak_framework,GameController
|
||||||
-Wl,-weak_framework,Metal
|
-Wl,-weak_framework,Metal
|
||||||
-Wl,-weak_framework,QuartzCore
|
-Wl,-weak_framework,QuartzCore
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ local LOOP_AT = 9.90 -- Where the recording of all this begins its loop
|
||||||
-- the step util/renderMenuVideo.py records at, which checks that
|
-- the step util/renderMenuVideo.py records at, which checks that
|
||||||
-- rather than taking it on trust.
|
-- rather than taking it on trust.
|
||||||
local LIFT_RISE = 13.0 -- World units it climbs on the way out.
|
local LIFT_RISE = 13.0 -- World units it climbs on the way out.
|
||||||
|
local MEASURE_FROM = 2.0 -- Seconds: the backdrop is timed from here, the logo settled and flapping,
|
||||||
|
local MEASURE_TO = 4.0 -- to here, before the grid comes up, and the average sets this machine's
|
||||||
|
-- tier (PLAN.md section 57). The menu is the benchmark: it runs anyway.
|
||||||
|
local TIER_MS = { 2.5, 6.0, 20.0 } -- A frame of it at or under these milliseconds is tier 3, 2, or 1;
|
||||||
|
-- slower is 0. First guesses from this machine and the design's
|
||||||
|
-- reference hardware, to be set again on the real parts.
|
||||||
|
|
||||||
-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground.
|
-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground.
|
||||||
local CELL = 6.0
|
local CELL = 6.0
|
||||||
|
|
@ -150,6 +156,8 @@ local gridLit = false -- Whether the grid has finished coming up and its
|
||||||
local gridShown = false -- Whether the grid, the horizon and the sun are drawn at all yet.
|
local gridShown = false -- Whether the grid, the horizon and the sun are drawn at all yet.
|
||||||
local backdropParts = {} -- Those nodes: hidden through the intro, shown as the grid comes up.
|
local backdropParts = {} -- Those nodes: hidden through the intro, shown as the grid comes up.
|
||||||
local backdropFont = nil
|
local backdropFont = nil
|
||||||
|
local measuring = false -- Whether the backdrop is being timed right now.
|
||||||
|
local measured = false -- Whether this run has timed it, or has no intro to time.
|
||||||
|
|
||||||
-- ===== The backdrop ==========================================================================
|
-- ===== The backdrop ==========================================================================
|
||||||
--
|
--
|
||||||
|
|
@ -497,6 +505,31 @@ function backdropFrame()
|
||||||
emitterBurst(sparks, 130)
|
emitterBurst(sparks, 130)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The machine's tier, from how long this very scene takes to draw.
|
||||||
|
if not measured then
|
||||||
|
if (not measuring) and (t >= MEASURE_FROM) then
|
||||||
|
sceneMeasure(true)
|
||||||
|
measuring = true
|
||||||
|
elseif measuring and (t >= MEASURE_TO) then
|
||||||
|
local ms = sceneMeasure(false)
|
||||||
|
local tier = 0
|
||||||
|
|
||||||
|
measuring = false
|
||||||
|
measured = true
|
||||||
|
if ms then
|
||||||
|
for index, limit in ipairs(TIER_MS) do
|
||||||
|
if (tier == 0) and (ms <= limit) then
|
||||||
|
tier = #TIER_MS + 1 - index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if singeCapabilities().tier ~= tier then
|
||||||
|
singeSaveTier(tier)
|
||||||
|
end
|
||||||
|
debugPrint(string.format("Backdrop: %.2f ms a frame, tier %d", ms, tier))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The fire's light dies over the first second and a half.
|
-- The fire's light dies over the first second and a half.
|
||||||
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5)))
|
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5)))
|
||||||
|
|
||||||
|
|
@ -629,6 +662,8 @@ function backdropBegin(showIntro)
|
||||||
|
|
||||||
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
|
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
|
||||||
blown = not showIntro
|
blown = not showIntro
|
||||||
|
measured = not showIntro
|
||||||
|
measuring = false
|
||||||
gridLit = false
|
gridLit = false
|
||||||
gridShown = false
|
gridShown = false
|
||||||
for _, part in ipairs(backdropParts) do
|
for _, part in ipairs(backdropParts) do
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,17 @@ function gameSelect(index)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Starts the selected game. The menu comes back from the top when it ends.
|
-- Starts the selected game. The menu comes back from the top when it ends. A game this machine
|
||||||
|
-- cannot run says why instead of failing part way in.
|
||||||
function gameStart()
|
function gameStart()
|
||||||
|
local game = GAME_LIST[GAME_SELECTED]
|
||||||
|
|
||||||
|
if game.CANNOT_RUN then
|
||||||
|
menuNotice(game.MACHINE_FIT)
|
||||||
|
return
|
||||||
|
end
|
||||||
SHUTDOWN_FROM_PUSH = true
|
SHUTDOWN_FROM_PUSH = true
|
||||||
scriptPush(GAME_LIST[GAME_SELECTED])
|
scriptPush(game)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,6 +297,35 @@ function unloadGameAssets()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- What this machine can run (PLAN.md section 57), asked once, and what each capability is called
|
||||||
|
-- when a game needs one the machine lacks.
|
||||||
|
local MACHINE = singeCapabilities()
|
||||||
|
local CAPABILITY_WORDS = { SCENE = "3D", GUI = "a GPU for its screens", SHADOWS = "shadows", POST = "post effects", SKIN = "skinned models", MORPH = "morph targets" }
|
||||||
|
|
||||||
|
|
||||||
|
-- How a game suits this machine: a line for the notice, or nil when it simply runs, and whether it
|
||||||
|
-- cannot run here at all. The list annotates; it does not hide. A game missing from a list gets
|
||||||
|
-- asked about, and one that says why it will not run does not. The Get Games tool asks it of the
|
||||||
|
-- catalogue's entries too, which carry the same REQUIRES and TIER.
|
||||||
|
function menuMachineFit(game)
|
||||||
|
local missing = {}
|
||||||
|
|
||||||
|
for _, name in ipairs(type(game.REQUIRES) == "table" and game.REQUIRES or {}) do
|
||||||
|
if not MACHINE[name] then
|
||||||
|
missing[#missing + 1] = CAPABILITY_WORDS[name] or name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #missing > 0 then
|
||||||
|
return "Needs " .. table.concat(missing, " and ") .. ", and this machine has none.", true
|
||||||
|
end
|
||||||
|
if MACHINE.tier and ((tonumber(game.TIER) or 0) > MACHINE.tier) then
|
||||||
|
return "Wants a faster machine than this one. It may run slowly.", false
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Adds the entries of one games.dat. container is the game database the file came from, or nil.
|
-- Adds the entries of one games.dat. container is the game database the file came from, or nil.
|
||||||
local function loadGamesDat(source, container)
|
local function loadGamesDat(source, container)
|
||||||
GAMES = {}
|
GAMES = {}
|
||||||
|
|
@ -309,6 +345,7 @@ local function loadGamesDat(source, container)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
value.MACHINE_FIT, value.CANNOT_RUN = menuMachineFit(value)
|
||||||
table.insert(GAME_LIST, value)
|
table.insert(GAME_LIST, value)
|
||||||
GAME_COUNT = GAME_COUNT + 1
|
GAME_COUNT = GAME_COUNT + 1
|
||||||
end
|
end
|
||||||
|
|
@ -430,15 +467,18 @@ function menuNotice(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
-- The notice for the selected game when nothing is in flight: the update on offer, or, failing
|
-- The notice for the selected game when nothing is in flight: how it suits this machine, or the
|
||||||
-- that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
|
-- update on offer, or, failing that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
|
||||||
function menuRefreshNotice()
|
function menuRefreshNotice()
|
||||||
if MENU_UPDATING then
|
if MENU_UPDATING then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local update = GAME_SELECTED and menuUpdateFor(GAME_LIST[GAME_SELECTED]) or nil
|
local game = GAME_SELECTED and GAME_LIST[GAME_SELECTED] or nil
|
||||||
|
local update = game and menuUpdateFor(game) or nil
|
||||||
|
|
||||||
if update then
|
if game and game.MACHINE_FIT then
|
||||||
|
menuNotice(game.MACHINE_FIT)
|
||||||
|
elseif update then
|
||||||
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
|
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
|
||||||
elseif MENU_RELEASE then
|
elseif MENU_RELEASE then
|
||||||
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
|
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,10 @@ end
|
||||||
-- rather than handed to the menu as its disc, so the renderer that draws its own never opens it.
|
-- rather than handed to the menu as its disc, so the renderer that draws its own never opens it.
|
||||||
MENU_RENDER.backdropBegin = function(showIntro)
|
MENU_RENDER.backdropBegin = function(showIntro)
|
||||||
introDone = not showIntro
|
introDone = not showIntro
|
||||||
|
-- No GPU device, no 3D: tier 0, which is all this machine will ever measure (PLAN.md section 57).
|
||||||
|
if singeCapabilities().tier ~= 0 then
|
||||||
|
singeSaveTier(0)
|
||||||
|
end
|
||||||
backdrop = videoLoad("Singe/menuBackground.mkv")
|
backdrop = videoLoad("Singe/menuBackground.mkv")
|
||||||
videoPlay(backdrop)
|
videoPlay(backdrop)
|
||||||
if not showIntro then
|
if not showIntro then
|
||||||
|
|
|
||||||
106
assets/Net.singe
106
assets/Net.singe
|
|
@ -40,10 +40,13 @@
|
||||||
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
|
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
|
||||||
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
|
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
|
||||||
--
|
--
|
||||||
-- Without a pin the chain is verified against a CA list. Linux has one; Windows, macOS and the
|
-- Without a pin the chain is verified against the union of two CA lists: Mozilla's, which the
|
||||||
-- handhelds have nothing LuaSec can read, so the engine extracts Mozilla's list as
|
-- engine extracts as Singe/cacerts.pem beside the other support files, and the machine's own
|
||||||
-- Singe/cacerts.pem beside the other support files and that is used wherever the platform's own
|
-- (the distribution's file on Linux, the root store on Windows, the system anchors on macOS),
|
||||||
-- is missing.
|
-- which netTrustSystem adds, with a cacerts.local.pem from the data directory for a self-hosted
|
||||||
|
-- server's CA. A connection verifies when either list knows the server's root, so a cabinet
|
||||||
|
-- whose bundle has gone stale can still reach the release that brings a new one, as long as the
|
||||||
|
-- machine's own list is current (PLAN.md section 61).
|
||||||
|
|
||||||
|
|
||||||
local socket = require("socket")
|
local socket = require("socket")
|
||||||
|
|
@ -58,11 +61,11 @@ NET_REDIRECT_MAX = 5
|
||||||
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
|
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
|
||||||
NET_PINS = {}
|
NET_PINS = {}
|
||||||
|
|
||||||
NET_CA_SYSTEM = "/etc/ssl/certs/ca-certificates.crt" -- Linux's list, when it is there,
|
NET_CA_SHIPPED = "Singe/cacerts.pem" -- The list Singe carries; the machine's own is added to it.
|
||||||
NET_CA_SHIPPED = "Singe/cacerts.pem" -- and the one Singe carries otherwise.
|
|
||||||
|
|
||||||
local active = {}
|
local active = {}
|
||||||
local nextId = 1
|
local nextId = 1
|
||||||
|
local trust = nil -- The one client context every request shares, and what it was built from.
|
||||||
|
|
||||||
|
|
||||||
-- The SHA-256 of a peer's public key, in the form the pins are written in.
|
-- The SHA-256 of a peer's public key, in the form the pins are written in.
|
||||||
|
|
@ -159,33 +162,100 @@ local function buildRequest(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
-- The CA list a connection without a pin is verified against.
|
-- The shipped bundle's own name for its version, from its header, so a failure can say how old it
|
||||||
local function caFile()
|
-- is without a second copy of the date to keep in step.
|
||||||
local lfs = require("lfs")
|
local function bundleVersion()
|
||||||
|
local file = io.open(NET_CA_SHIPPED, "r")
|
||||||
|
local line = nil
|
||||||
|
local seen = nil
|
||||||
|
|
||||||
if lfs.attributes(NET_CA_SYSTEM, "mode") == "file" then
|
if not file then
|
||||||
return NET_CA_SYSTEM
|
return "missing"
|
||||||
end
|
end
|
||||||
|
for _ = 1, 5 do
|
||||||
|
line = file:read("l")
|
||||||
|
if not line then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
seen = seen or line:match("ca%-certificates (%d+)")
|
||||||
|
end
|
||||||
|
file:close()
|
||||||
|
|
||||||
return NET_CA_SHIPPED
|
return seen or "of unknown date"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
local function startTls(request)
|
-- What the clock has to do with a failed verification. Certificates are checked against the date,
|
||||||
|
-- and a board with no battery-backed clock that has not had network time reads a date long past:
|
||||||
|
-- when the clock reads earlier than the shipped bundle was made, it is certainly wrong, whatever
|
||||||
|
-- OpenSSL called the failure. Otherwise a "not valid yet" certificate still points at the clock.
|
||||||
|
local function clockNote(err)
|
||||||
|
local now = os.time()
|
||||||
|
local reads = os.date("%Y-%m-%d %H:%M", now)
|
||||||
|
local y, m, d = bundleVersion():match("^(%d%d%d%d)(%d%d)(%d%d)$")
|
||||||
|
|
||||||
|
if y and (now < os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0 })) then
|
||||||
|
return ". This machine's clock reads " .. reads .. ", before the certificate list Singe ships was made (" .. y .. "-" .. m .. "-" .. d .. "): set the date and time"
|
||||||
|
end
|
||||||
|
if tostring(err):find("not yet valid", 1, true) then
|
||||||
|
return ". The certificate is not valid yet by this machine's clock, which reads " .. reads .. ": check the date and time"
|
||||||
|
end
|
||||||
|
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The client context, built once: the shipped bundle, then the machine's own roots and the local
|
||||||
|
-- file on top. Rebuilding it per request parsed the whole bundle every connection.
|
||||||
|
local function trustContext()
|
||||||
|
if trust then
|
||||||
|
return trust.context
|
||||||
|
end
|
||||||
local params = {
|
local params = {
|
||||||
mode = "client",
|
mode = "client",
|
||||||
protocol = "any",
|
protocol = "any",
|
||||||
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
|
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
|
||||||
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
|
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
|
||||||
-- what decides. Without one, a CA list is asked -- and if it has nothing to say, the
|
-- what decides. Without one, the lists are asked -- and if they have nothing to say, the
|
||||||
-- request fails rather than proceeding unverified.
|
-- request fails rather than proceeding unverified.
|
||||||
verify = (#NET_PINS > 0) and "none" or "peer",
|
verify = (#NET_PINS > 0) and "none" or "peer",
|
||||||
cafile = caFile(),
|
cafile = NET_CA_SHIPPED,
|
||||||
}
|
}
|
||||||
local wrapped, err = ssl.wrap(request.socket, params)
|
local context, err = ssl.newcontext(params)
|
||||||
|
|
||||||
|
if not context then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
local _, report = netTrustSystem(context)
|
||||||
|
|
||||||
|
trust = { context = context, report = report }
|
||||||
|
|
||||||
|
return context
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- What a failed verification was checked against, for the message a person reads.
|
||||||
|
local function trustChecked()
|
||||||
|
local lists = { "shipped " .. NET_CA_SHIPPED .. " (" .. bundleVersion() .. ")" }
|
||||||
|
|
||||||
|
for line in ((trust and trust.report) or ""):gmatch("[^\n]+") do
|
||||||
|
lists[#lists + 1] = line
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(lists, "; ")
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function startTls(request)
|
||||||
|
local context, err = trustContext()
|
||||||
|
|
||||||
|
if not context then
|
||||||
|
return fail(request, "TLS setup failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
local wrapped, why = ssl.wrap(request.socket, context)
|
||||||
|
|
||||||
if not wrapped then
|
if not wrapped then
|
||||||
return fail(request, "TLS setup failed: " .. tostring(err))
|
return fail(request, "TLS setup failed: " .. tostring(why))
|
||||||
end
|
end
|
||||||
wrapped:settimeout(0)
|
wrapped:settimeout(0)
|
||||||
if wrapped.sni then
|
if wrapped.sni then
|
||||||
|
|
@ -218,7 +288,7 @@ local function pumpHandshake(request)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
|
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
|
||||||
fail(request, "TLS handshake failed: " .. tostring(err))
|
fail(request, "TLS handshake failed: " .. tostring(err) .. ". Checked: " .. trustChecked() .. clockNote(err) .. ".")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1429,6 +1429,13 @@ local function shopShow()
|
||||||
elseif held ~= nil then
|
elseif held ~= nil then
|
||||||
state = (held >= game.version) and "installed" or ("update to " .. game.version)
|
state = (held >= game.version) and "installed" or ("update to " .. game.version)
|
||||||
end
|
end
|
||||||
|
-- How it suits this machine, from the REQUIRES and TIER the service took from the game itself.
|
||||||
|
local fit, cannot = menuMachineFit({ REQUIRES = game.requires, TIER = game.tier })
|
||||||
|
if cannot then
|
||||||
|
state = state .. ", will not run here"
|
||||||
|
elseif fit then
|
||||||
|
state = state .. ", may run slowly"
|
||||||
|
end
|
||||||
rows[#rows + 1] = toolsItem(game.title, index == shopSelected, state)
|
rows[#rows + 1] = toolsItem(game.title, index == shopSelected, state)
|
||||||
end
|
end
|
||||||
if #shopGames == 0 then
|
if #shopGames == 0 then
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Root certificates the master service is verified against on platforms with no trust store
|
# Root certificates Singe trusts alongside the machine's own (Net.singe adds those through
|
||||||
# LuaSec can read (Windows, macOS, the handhelds). Mozilla's CA list, as packaged by Debian
|
# netTrustSystem), so a machine with no current list of its own still verifies. Mozilla's CA
|
||||||
# ca-certificates 20260601, taken 2026-09-13. Distributed under the Mozilla Public License 2.0.
|
# list, as packaged by Debian ca-certificates 20260601, taken 2026-09-13. Distributed under the
|
||||||
# Refresh from /etc/ssl/certs/ca-certificates.crt on a current Linux; see Net.singe.
|
# Mozilla Public License 2.0. Refresh from /etc/ssl/certs/ca-certificates.crt on a current Linux.
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE
|
MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE
|
||||||
AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw
|
AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw
|
||||||
|
|
|
||||||
|
|
@ -1311,44 +1311,40 @@ gun wants none drawn; `discPlay` and `discPause` let the disc go and hold
|
||||||
it, a still behind a title; `pointerX(1)` and `pointerY(1)` in an
|
it, a still behind a title; `pointerX(1)` and `pointerY(1)` in an
|
||||||
expression say where the pointer is. The light sensor way of judging a shot
|
expression say where the pointer is. The light sensor way of judging a shot
|
||||||
over video -- the picture under the gun agreeing with a sensor spot across
|
over video -- the picture under the gun agreeing with a sensor spot across
|
||||||
two frames -- is a `lightSensor` behaviour those five games carry in a
|
two frames -- is Forge's `lightSensor` behaviour, with `pictureAt` as the
|
||||||
vocabulary of their own (see <<own-vocabulary>>), not one of Forge's.
|
condition that reads the picture.
|
||||||
|
|
||||||
*A QTE game from the KarisFramework.* The `qte` behaviour plays a
|
*A quick-time-event game.* The `qte` behaviour plays a laserdisc
|
||||||
laserdisc quick-time-event game as that framework does -- attract loop,
|
quick-time-event game from tables under `qte` in the description: an
|
||||||
credits, levels of scenes with moves to answer in a window of frames, death
|
attract loop, credits, levels of scenes with moves to answer, death clips,
|
||||||
clips, continues, the dips -- from the framework's own tables under
|
continues, and the game's settings. The port converter writes those tables
|
||||||
`qte` in the description, which `util/forgePortKaris.lua` writes from a
|
from an existing game's script, and the game's own script and add-ons run
|
||||||
game's script. The game's script and its `Script/addons.singe` run inside
|
inside the behaviour, so a port keeps its own scoring and level order.
|
||||||
the behaviour, so a game keeps its own scoring and level order. The
|
|
||||||
seventeen games of the test library and eleven of the Hypseus library are
|
`qte.options.style` picks how the game plays:
|
||||||
ported this way and play as their originals do (FORGE.md section 14), and
|
|
||||||
so is the one game on the MazescaterFramework -- that framework
|
* `windows`, the default: each move is answered in a window of frames.
|
||||||
with compound moves (sequences of two or three inputs, circles from any
|
Moves can be compound -- sequences of two or three inputs, circles from
|
||||||
start, mashes of a direction or a button, a direction with the second or
|
any start, mashes of a direction or a button, a direction with a second
|
||||||
third button), which the behaviour judges from tables of its own once the
|
button.
|
||||||
converter has marked the description with the framework's name. The
|
* `held`: moves are judged by what's held together (combos, three kinds of
|
||||||
`kimmy` behaviour plays the same author's 2024 engine, whose moves are
|
mash, holds, sequences, paths, timed and yes/no branches), with tilt, a
|
||||||
judged by what is held together -- combos, mashes in three variants,
|
life bar, a die-and-retry count, a new-game menu, and a level select.
|
||||||
holds, sequences, paths, timed and yes/no branches -- with tilt, a life
|
* `segments`: levels of segments, a move being an input, a mash, or a pair
|
||||||
bar, a die-and-retry count, a new-game menu, and a level select; the same
|
of inputs in a window, with a map between levels, running the game's own
|
||||||
converter writes its description, and the seven Kimmy games are ported
|
level, move, death-clip, and order functions as they are.
|
||||||
with it. The `rdg` behaviour plays the map-mode lineage those frameworks
|
* `checkpoints`: segments with checkpoints, a time-stop choice of three, a
|
||||||
grew from (RDG's LUA SINGE 1.0 and 1.1): a game of that kind keeps its
|
tally by deaths, and a life per quota.
|
||||||
levels, moves, death clips, order, and map as functions of its own
|
* `trophies`: segmented levels judged the windows way, with a difficulty
|
||||||
main.singe, and the behaviour runs those files as they are, beside the
|
select, a level-clear screen, and trophies.
|
||||||
description, over its own loop -- levels of segments, a move an input or
|
|
||||||
a mash or a pair of inputs in a window, a shot at a zone of the picture
|
Where two styles differ on the same screen (whether a game over after a
|
||||||
where the game carries one, a map between levels -- with the copies'
|
continue plays on from the continue clip, whether a death after the hints
|
||||||
drift (the later generation's level order, game type, and state numbers)
|
counts as a wrong move, and so on), the difference is a named option in
|
||||||
gated by what the game's script declares, and what a title does without
|
`qte.options`, and each style sets its own default. A description can ask
|
||||||
declaring it named in a `tweaks` table the converter writes. The
|
for either behaviour whatever its style. Each port is compared with its
|
||||||
`timegal` behaviour plays one of the same author's remakes, a compact
|
original move for move, by the traces both print on the same inputs;
|
||||||
loop of its own (checkpoints, a time-stop choice of three, a tally by
|
FORGE.md section 14 has how, and where each title stands.
|
||||||
deaths, a life per quota, a level order by tiers), over the game's own
|
|
||||||
files in the same way; and the `sdq` behaviour plays one game that is
|
|
||||||
the one hybrid of map-mode data under the Karis framework's judging,
|
|
||||||
with its difficulty select, level clear, and trophies.
|
|
||||||
|
|
||||||
*A hand-written game.* A game that was a program of its own rather than
|
*A hand-written game.* A game that was a program of its own rather than
|
||||||
a framework's tables -- a set of light gun titles, a typing edition of one
|
a framework's tables -- a set of light gun titles, a typing edition of one
|
||||||
|
|
@ -1885,6 +1881,31 @@ 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
|
`--pack` turns that directory into a `.game` like any other, and it runs on a
|
||||||
machine that has never had Forge on it.
|
machine that has never had Forge on it.
|
||||||
|
|
||||||
|
[#credits]
|
||||||
|
==== Credits
|
||||||
|
|
||||||
|
Say who made the game. The game's form (nothing selected, entities panel) has a
|
||||||
|
row of `credits` fields: developer, publisher, year, platform, genre, creator,
|
||||||
|
source, and description. They land in the release's `games.dat` under the keys
|
||||||
|
the menu shows in its details panel, so whoever made the game is named
|
||||||
|
wherever it is played. Two more fields hold what the others have no room for:
|
||||||
|
`credits.notes`, lines separated by `|` that go into a `CREDITS.txt` beside the
|
||||||
|
game, and `credits.licenses`, files that travel with the release like any other
|
||||||
|
file the description names.
|
||||||
|
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
credits = {
|
||||||
|
developer = "Somebody Else",
|
||||||
|
year = 1983,
|
||||||
|
notes = { "Original game: Somebody Else", "Forge version: You" },
|
||||||
|
licenses = { "COPYING.txt" }
|
||||||
|
},
|
||||||
|
----
|
||||||
|
|
||||||
|
If your game is based on someone else's, this is where they get their due, and
|
||||||
|
if their licence says it has to ship with the game, `licenses` is how it does.
|
||||||
|
|
||||||
`Shift+P` goes one step further: it exports, packs, and sends the package to the
|
`Shift+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
|
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
|
has been reviewed, and Forge says which version number it was given. Press
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
| --
|
| --
|
||||||
|
|
||||||
| `parser`
|
| `parser`
|
||||||
| A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event.
|
| A text parser: a prompt the player types at, and words the game knows. What is typed raises the said event.
|
||||||
| `ignore` (table), `prompt` (string), `words` (table)
|
| `ignore` (table), `prompt` (string), `words` (table)
|
||||||
|
|
||||||
| `scene3d`
|
| `scene3d`
|
||||||
|
|
@ -78,6 +78,10 @@
|
||||||
| A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.
|
| A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.
|
||||||
| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number)
|
| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number)
|
||||||
|
|
||||||
|
| `qteHud`
|
||||||
|
| The HUD of a quick-time-event game: what its qte behaviour's state says a player sees (the score and the record, the lives or the life bar, and the rest), drawn from the game's own pictures and font as the game lays them out.
|
||||||
|
| --
|
||||||
|
|
||||||
| `sprite`
|
| `sprite`
|
||||||
| An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. The var copies draws it that many times in a row, step pixels apart (a row of bullets, of hearts): negative steps go left.
|
| An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. The var copies draws it that many times in a row, step pixels apart (a row of bullets, of hearts): negative steps go left.
|
||||||
| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number), `step` (number)
|
| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number), `step` (number)
|
||||||
|
|
@ -107,7 +111,7 @@
|
||||||
| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere)
|
| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere)
|
||||||
|
|
||||||
| `branching`
|
| `branching`
|
||||||
| A branching laserdisc game: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.
|
| Branch windows over the disc: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.
|
||||||
| `track` (track)
|
| `track` (track)
|
||||||
|
|
||||||
| `camera`
|
| `camera`
|
||||||
|
|
@ -150,10 +154,6 @@
|
||||||
| Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware.
|
| Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware.
|
||||||
| `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode)
|
| `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode)
|
||||||
|
|
||||||
| `kimmy`
|
|
||||||
| 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`
|
| `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.
|
| 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)
|
| `high` (number), `left` (number), `low` (number), `player` (number), `top` (number), `trigger` (switch), `x` (number), `y` (number)
|
||||||
|
|
@ -179,21 +179,13 @@
|
||||||
| `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number)
|
| `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number)
|
||||||
|
|
||||||
| `qte`
|
| `qte`
|
||||||
| Plays a laserdisc quick-time-event game the KarisFramework way (or the MazescaterFramework's, with its compound moves), from the framework's own tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the dips, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
|
| Plays a laserdisc quick-time-event game from the tables under game.qte: an attract loop, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the settings, and the game's own add-ons. qte.options.style says how its moves are judged and its levels run: windows (the default: a move answered in a window, compound moves too), held (moves judged by what is held together: combos, mashes, holds, sequences, paths, timed and yes/no branches, tilt, a life bar, die and retry, a new-game menu, a level select), segments (levels of segments, a map between them, the game's own level functions run as they are), checkpoints (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota), or trophies (segmented levels judged the windows way, a difficulty select, a level-clear screen, trophies). The port converter writes the tables from an existing game's script.
|
||||||
| --
|
| --
|
||||||
|
|
||||||
| `racer`
|
| `racer`
|
||||||
| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.
|
| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.
|
||||||
| `reach` (number), `throttle` (number), `track` (track)
|
| `reach` (number), `throttle` (number), `track` (track)
|
||||||
|
|
||||||
| `rdg`
|
|
||||||
| Plays a laserdisc quick-time-event game of RDG's map-mode lineage (the ancestor of the Karis framework): levels of segments, a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. Written by util/forgePortKaris.lua from a game's script.
|
|
||||||
| --
|
|
||||||
|
|
||||||
| `sdq`
|
|
||||||
| Plays one hybrid map-mode game: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script.
|
|
||||||
| --
|
|
||||||
|
|
||||||
| `seek`
|
| `seek`
|
||||||
| Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it.
|
| Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it.
|
||||||
| `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string)
|
| `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string)
|
||||||
|
|
@ -230,10 +222,6 @@
|
||||||
| A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.
|
| A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.
|
||||||
| `force` (number), `turn` (number)
|
| `force` (number), `turn` (number)
|
||||||
|
|
||||||
| `timegal`
|
|
||||||
| Plays one of RDG2010's remakes: levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script.
|
|
||||||
| --
|
|
||||||
|
|
||||||
| `timer`
|
| `timer`
|
||||||
| Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.
|
| Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.
|
||||||
| `after` (number), `every` (number), `name` (string), `start` (boolean)
|
| `after` (number), `every` (number), `name` (string), `start` (boolean)
|
||||||
|
|
@ -505,7 +493,7 @@
|
||||||
|
|
|
|
||||||
|
|
||||||
| `die`
|
| `die`
|
||||||
| The Sierra death: a message, then the game starts over.
|
| A death: a message, then the game starts over.
|
||||||
| `text` (expression)
|
| `text` (expression)
|
||||||
| yes
|
| yes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,18 @@ showing, `SHOW_INTRO` is false when it is returning from a game so the intro
|
||||||
is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see
|
is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see
|
||||||
<<menurenderers,The two renderers>>).
|
<<menurenderers,The two renderers>>).
|
||||||
|
|
||||||
|
Not every machine runs every game. A 3D game on a box with no 3D, or a heavy
|
||||||
|
game on a small one, would fail or crawl, so the menu tells you first. When you
|
||||||
|
select a game this machine cannot run, the line under its details says what it
|
||||||
|
needs ("Needs 3D, and this machine has none."), and starting it just repeats
|
||||||
|
that instead of failing part way in. A game that wants more machine than this
|
||||||
|
one gets "It may run slowly." and starts anyway. Nothing is hidden. The menu
|
||||||
|
works out how fast this machine is the first time its intro plays, by timing
|
||||||
|
its own backdrop, and keeps the answer in `machine.cfg`; it never asks you to
|
||||||
|
run a benchmark. Games say what they need in their `games.dat`
|
||||||
|
(xref:Reference.adoc#gamesdat[REQUIRES and TIER]), and the Get Games tool marks
|
||||||
|
the catalogue the same way.
|
||||||
|
|
||||||
[#menurenderers]
|
[#menurenderers]
|
||||||
==== The Two Renderers
|
==== The Two Renderers
|
||||||
|
|
||||||
|
|
@ -244,6 +256,16 @@ the menu made. See xref:Reference.adoc#onlinescores[Online high scores] for the
|
||||||
blocks a frame, because Lua in Singe is cooperative and a large download would
|
blocks a frame, because Lua in Singe is cooperative and a large download would
|
||||||
otherwise look like a hang.
|
otherwise look like a hang.
|
||||||
|
|
||||||
|
Singe checks the server's certificate against two lists at once: the one it
|
||||||
|
ships with and the one your machine keeps. Either one vouching for the server
|
||||||
|
is enough, so an old copy of Singe can still reach the update that replaces it
|
||||||
|
as long as your system keeps its own list current. Running your own service
|
||||||
|
with a certificate from your own authority? Put that authority's certificate in
|
||||||
|
`cacerts.local.pem` in the data folder. Singe never writes that file, so it
|
||||||
|
survives updates. If a connection is refused, the message says which lists were
|
||||||
|
checked, and a certificate that is "not yet valid" almost always means the
|
||||||
|
machine's clock is wrong.
|
||||||
|
|
||||||
[#servicetools]
|
[#servicetools]
|
||||||
==== The Service Tools
|
==== The Service Tools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -585,7 +585,7 @@ GAMES = {
|
||||||
|
|
||||||
The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
|
The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
|
||||||
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
|
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
|
||||||
`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
|
`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, `LEGACY_SPRITE_ARGS`, `REQUIRES`, and `TIER` are read by the engine when the menu (or your own
|
||||||
script, through `scriptExecute` / `scriptPush`) launches the entry; they
|
script, through `scriptExecute` / `scriptPush`) launches the entry; they
|
||||||
override the command line. The `VIDEO` line is the disc: an entry that names
|
override the command line. The `VIDEO` line is the disc: an entry that names
|
||||||
one is a laserdisc game, and an entry that leaves it out or blank runs
|
one is a laserdisc game, and an entry that leaves it out or blank runs
|
||||||
|
|
@ -597,6 +597,24 @@ release whose other languages ship as separate files, the same thing
|
||||||
with the old sprite argument order (see <<migrating,Migrating from Singe
|
with the old sprite argument order (see <<migrating,Migrating from Singe
|
||||||
2.10>>). The remaining keys are read by the menu for display.
|
2.10>>). The remaining keys are read by the menu for display.
|
||||||
|
|
||||||
|
`REQUIRES` says what the game needs of a machine, as a list of names:
|
||||||
|
`"SCENE"` (the 3D scene), `"GUI"` (GUI documents), `"SHADOWS"`, `"POST"`
|
||||||
|
(bloom and the other effects that want float render targets), `"SKIN"`
|
||||||
|
(skinned models), and `"MORPH"` (morph targets). No `REQUIRES` means 2D. The
|
||||||
|
menu marks a game this machine cannot run and says why instead of starting it,
|
||||||
|
and you rarely write the list yourself: the engine notices what a game uses as
|
||||||
|
it runs, says in the trace when the game uses something `REQUIRES` does not
|
||||||
|
list, and `--pack` writes the list into an entry that has none. `TIER` (0 to
|
||||||
|
3) is how much machine the game wants; the menu measures this machine's tier
|
||||||
|
from its own backdrop and marks a game that wants more as one that may run
|
||||||
|
slowly. See <<singecapabilities,singeCapabilities>>.
|
||||||
|
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
REQUIRES = { "SCENE", "SHADOWS" },
|
||||||
|
TIER = 1,
|
||||||
|
----
|
||||||
|
|
||||||
A script launched directly, by name or by its directory, gets the same
|
A script launched directly, by name or by its directory, gets the same
|
||||||
treatment: Singe looks for a `games.dat` in the script's directory and up to
|
treatment: Singe looks for a `games.dat` in the script's directory and up to
|
||||||
three directories above it, and if an entry's `SCRIPT` names the launched
|
three directories above it, and if an entry's `SCRIPT` names the launched
|
||||||
|
|
@ -5339,7 +5357,7 @@ The picture each particle wears: a loaded sprite, or none (one argument, or `nil
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
-- Snow: a sheet of six flake shapes, one picked at random per particle.
|
-- Snow: a sheet of six flake shapes, one picked at random per particle.
|
||||||
flakes = spriteLoadFrames(DIR .. "Sprites/Flakes", 6)
|
flakes = spriteLoadFrames(6, DIR .. "Sprites/flakes.png")
|
||||||
snow = emitterNew()
|
snow = emitterNew()
|
||||||
emitterSetTexture(snow, flakes)
|
emitterSetTexture(snow, flakes)
|
||||||
emitterSetFrames(snow, 0, 5)
|
emitterSetFrames(snow, 0, 5)
|
||||||
|
|
@ -9928,6 +9946,49 @@ navSave(nav, "level1.nav")
|
||||||
debugPrint("Navigation mesh baked and saved.")
|
debugPrint("Navigation mesh baked and saved.")
|
||||||
----
|
----
|
||||||
|
|
||||||
|
[#net]
|
||||||
|
=== Net
|
||||||
|
|
||||||
|
What the engine adds underneath `Singe/Net.singe`, the menu's HTTP client.
|
||||||
|
Most games never touch it: posting a score or fetching a catalogue goes
|
||||||
|
through `Net.singe`, which calls this for you.
|
||||||
|
|
||||||
|
[#nettrustsystem]
|
||||||
|
==== netTrustSystem
|
||||||
|
|
||||||
|
[source,text]
|
||||||
|
----
|
||||||
|
lists, report = netTrustSystem(context)
|
||||||
|
----
|
||||||
|
|
||||||
|
Adds the machine's own trusted root certificates to a LuaSec client context,
|
||||||
|
on top of whatever the context already holds (normally the shipped
|
||||||
|
`Singe/cacerts.pem`). A connection made with the context then verifies when
|
||||||
|
either list knows the server's root. The machine's list is, in order: the file
|
||||||
|
or directory named by `SSL_CERT_FILE` or `SSL_CERT_DIR` when set; otherwise the
|
||||||
|
distribution's bundle on Linux (the first of the usual six places that
|
||||||
|
exists), the root system store on Windows, or the system anchors on macOS.
|
||||||
|
`cacerts.local.pem` in the data folder is added as well when it exists; the
|
||||||
|
engine never writes it.
|
||||||
|
|
||||||
|
`lists` is how many lists added anything. `report` names what was consulted,
|
||||||
|
one `what path` per line (`what` is `system` or `local`), for an error message
|
||||||
|
a person can act on.
|
||||||
|
|
||||||
|
*Since:* 3.00.
|
||||||
|
*See also:* <<utilsha256,utilSha256>>
|
||||||
|
|
||||||
|
.Example
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
-- One context for every request: the shipped bundle, then this machine's roots on top.
|
||||||
|
local ssl = require("ssl")
|
||||||
|
local context = ssl.newcontext({ mode = "client", protocol = "any", verify = "peer", cafile = "Singe/cacerts.pem" })
|
||||||
|
local lists, report = netTrustSystem(context)
|
||||||
|
|
||||||
|
debugPrint("Trusting the shipped bundle and " .. lists .. " more:\n" .. report)
|
||||||
|
----
|
||||||
|
|
||||||
[#node]
|
[#node]
|
||||||
=== Node
|
=== Node
|
||||||
|
|
||||||
|
|
@ -12716,6 +12777,39 @@ function onOverlayUpdate()
|
||||||
end
|
end
|
||||||
----
|
----
|
||||||
|
|
||||||
|
[#scenemeasure]
|
||||||
|
==== sceneMeasure
|
||||||
|
|
||||||
|
[source,text]
|
||||||
|
----
|
||||||
|
sceneMeasure(on)
|
||||||
|
ms = sceneMeasure(false)
|
||||||
|
----
|
||||||
|
|
||||||
|
Times the 3D scene. `sceneMeasure(true)` starts: every frame after it waits for
|
||||||
|
the GPU to finish the scene and adds that time up, so what is measured is the
|
||||||
|
work rather than the display's pacing. `sceneMeasure(false)` stops and returns
|
||||||
|
the milliseconds a frame averaged, or `nil` when no frame was drawn. The wait
|
||||||
|
costs frame rate, so time a few seconds, not a whole game.
|
||||||
|
|
||||||
|
*Returns:* nothing when starting; a number or `nil` when stopping.
|
||||||
|
|
||||||
|
*Since:* 3.00.
|
||||||
|
*See also:* <<singesavetier,singeSaveTier>>, <<scenegetstats,sceneGetStats>>
|
||||||
|
|
||||||
|
.Example
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
-- Two seconds of the title screen tell whether the full effects fit.
|
||||||
|
sceneMeasure(true)
|
||||||
|
-- ... two seconds later:
|
||||||
|
local ms = sceneMeasure(false)
|
||||||
|
|
||||||
|
if ms and (ms > 8) then
|
||||||
|
sceneSetBloom(1.0, 0.0)
|
||||||
|
end
|
||||||
|
----
|
||||||
|
|
||||||
[#sceneprobegui]
|
[#sceneprobegui]
|
||||||
==== sceneProbeGui
|
==== sceneProbeGui
|
||||||
|
|
||||||
|
|
@ -13379,6 +13473,38 @@ if utilSha256(bytes) ~= expected then
|
||||||
end
|
end
|
||||||
----
|
----
|
||||||
|
|
||||||
|
[#singecapabilities]
|
||||||
|
==== singeCapabilities
|
||||||
|
|
||||||
|
[source,text]
|
||||||
|
----
|
||||||
|
capabilities = singeCapabilities()
|
||||||
|
----
|
||||||
|
|
||||||
|
What this machine can run. The table has one entry per capability name a
|
||||||
|
`games.dat` `REQUIRES` list can use (`SCENE`, `GUI`, `SHADOWS`, `POST`, `SKIN`,
|
||||||
|
`MORPH`), each `true` or `false`, and `tier`: the machine's measured
|
||||||
|
performance class from 0 (no 3D) to 3 (a desktop GPU), or `nil` before the menu
|
||||||
|
has measured it. A game asks so it can do without something rather than be
|
||||||
|
refused for it.
|
||||||
|
|
||||||
|
*Returns:* a table.
|
||||||
|
|
||||||
|
*Since:* 3.00.
|
||||||
|
*See also:* <<singehasgpu,singeHasGpu>>, <<singesavetier,singeSaveTier>>, <<scenemeasure,sceneMeasure>>
|
||||||
|
|
||||||
|
.Example
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
-- Shadows where the machine has them, a plain sun where it does not.
|
||||||
|
local machine = singeCapabilities()
|
||||||
|
|
||||||
|
lightSetShadow(sun, machine.SHADOWS)
|
||||||
|
if (machine.tier or 0) < 2 then
|
||||||
|
sceneSetBloom(1.0, 0.0)
|
||||||
|
end
|
||||||
|
----
|
||||||
|
|
||||||
[#singegetaudiocalibration]
|
[#singegetaudiocalibration]
|
||||||
==== singeGetAudioCalibration
|
==== singeGetAudioCalibration
|
||||||
|
|
||||||
|
|
@ -13888,6 +14014,36 @@ function onInputPressed(input)
|
||||||
end
|
end
|
||||||
----
|
----
|
||||||
|
|
||||||
|
|
||||||
|
[#singesavetier]
|
||||||
|
==== singeSaveTier
|
||||||
|
|
||||||
|
[source,text]
|
||||||
|
----
|
||||||
|
singeSaveTier(tier)
|
||||||
|
----
|
||||||
|
|
||||||
|
Keeps this machine's performance class, 0 to 3, in `machine.cfg` beside the
|
||||||
|
audio delay and the geometry, where <<singecapabilities,singeCapabilities>>
|
||||||
|
reports it and the menu compares it with each game's `TIER`. The bundled menu
|
||||||
|
measures the tier itself, from how long its 3D backdrop takes to draw
|
||||||
|
(<<scenemeasure,sceneMeasure>>), and saves it only when it changes; nothing
|
||||||
|
asks the player.
|
||||||
|
|
||||||
|
*Returns:* nothing.
|
||||||
|
|
||||||
|
*Since:* 3.00.
|
||||||
|
*See also:* <<singecapabilities,singeCapabilities>>, <<singesavegeometry,singeSaveGeometry>>
|
||||||
|
|
||||||
|
.Example
|
||||||
|
[source,lua]
|
||||||
|
----
|
||||||
|
-- A machine with no 3D at all is tier 0.
|
||||||
|
if not singeHasGpu() then
|
||||||
|
singeSaveTier(0)
|
||||||
|
end
|
||||||
|
----
|
||||||
|
|
||||||
[#singescreenshot]
|
[#singescreenshot]
|
||||||
==== singeScreenshot
|
==== singeScreenshot
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ It is perfect for a frame counter. It is wrong for a title, a score, a menu, or
|
||||||
anything a player is meant to look at rather than read past.
|
anything a player is meant to look at rather than read past.
|
||||||
|
|
||||||
This lesson loads a real font off disk, prints with it, colours it, measures it
|
This lesson loads a real font off disk, prints with it, colours it, measures it
|
||||||
so you can centre things, and shows you the one performance trap that catches
|
so you can centre things, and shows you what printing text actually costs. At
|
||||||
everybody. At the end you will have a title screen that looks deliberate
|
the end you will have a title screen that looks deliberate instead of
|
||||||
instead of accidental.
|
accidental.
|
||||||
|
|
||||||
=== Set the Overlay to a Size You Can Think In
|
=== Set the Overlay to a Size You Can Think In
|
||||||
|
|
||||||
|
|
@ -181,41 +181,40 @@ above `spriteDraw` instead does nothing at all, because a sprite is drawn with
|
||||||
its own pixels and is never tinted. If your carefully coloured title comes out
|
its own pixels and is never tinted. If your carefully coloured title comes out
|
||||||
white, this is why.
|
white, this is why.
|
||||||
|
|
||||||
=== The Trap: fontPrint Is Not Free
|
=== What Printing Costs
|
||||||
|
|
||||||
NOTE: Rendering text to a sprite for performance is deprecated. Singe 3.00's
|
The first time `fontPrint` sees a string, it does real work: it rasterizes
|
||||||
`fontPrint` keeps every string it renders and only copies it again while the
|
every letter from the font's outlines and builds an image of the whole string.
|
||||||
string, font, quality, and colours stay the same, so printing a title every
|
Then it keeps that image. The next time you print the same string, with the
|
||||||
frame now costs about what drawing a sprite of it does. The trap below is the
|
same font, quality, and colours, it skips all of that and copies the image it
|
||||||
one earlier versions set, and the pattern still works; new games can just
|
already has, which is about what drawing a sprite costs. A string you stop
|
||||||
`fontPrint`, and keep `fontToSprite` for text that has to be a sprite -- to be
|
printing is let go a second later, so nothing piles up.
|
||||||
measured, scaled, or turned.
|
|
||||||
|
|
||||||
Here is the thing the reference says plainly and everyone reads past.
|
That means the obvious way to write a title screen is also the fast one.
|
||||||
|
Printing `"ASTEROID PATROL"` sixty times a second renders it once. A score that
|
||||||
|
changes twice a second renders twice a second, on its own, with no bookkeeping
|
||||||
|
from you.
|
||||||
|
|
||||||
`fontPrint` does not draw a picture it made earlier. Every single call
|
The one case that costs is text that is *different* every frame -- a timer in
|
||||||
rasterizes the whole string from the font outlines, builds an image of it,
|
milliseconds, a frame counter -- because every frame is a string `fontPrint`
|
||||||
copies that image onto the overlay, and throws it away. Then, one sixtieth of a
|
has never seen. On a desktop it does not matter. On a Raspberry Pi or a
|
||||||
second later, it does all of that again. For a title that has not changed since
|
handheld, a screenful of it will. The cure is to change the text less often:
|
||||||
the game started, you are re-drawing the same letters from scratch sixty times
|
a timer in whole seconds is a new string once a second, not sixty times.
|
||||||
a second, forever.
|
|
||||||
|
|
||||||
On a desktop with three lines of text you will never notice. With a screen full
|
So why did this lesson use `fontToSprite` at all? Because a sprite can do
|
||||||
of text, or on a Raspberry Pi, or in a cabinet, you will notice a great deal.
|
things printed text cannot:
|
||||||
|
|
||||||
The fix is the call you just met. `fontToSprite` does that work *once*.
|
* *It can be measured*, which is the only way to centre text or line it up
|
||||||
`spriteDraw` afterwards is a plain copy, which is what the hardware is for. So
|
against the right hand side.
|
||||||
the rule is short:
|
* *It can be scaled and turned* with the `sprite*` calls from lesson nine.
|
||||||
|
* *It can outlive its font*, which matters in a moment.
|
||||||
|
|
||||||
* Text that never changes -- titles, labels, instructions, menu entries --
|
Use `fontPrint` for text, and `fontToSprite` when you need one of those.
|
||||||
`fontToSprite` once at startup, `spriteDraw` every frame.
|
|
||||||
* Text that changes every frame and nobody is staring at -- a debug readout, a
|
|
||||||
frame number -- `fontPrint` is fine.
|
|
||||||
* Text that changes sometimes -- a score, a name, a timer in whole seconds --
|
|
||||||
render a new sprite when it changes, and unload the old one.
|
|
||||||
|
|
||||||
That last case is worth writing out, because "unload the old one" is easy to
|
The score in this lesson needs one of them: it sits against the right hand
|
||||||
forget and forgetting it leaks a sprite per change:
|
margin, so it has to be measured, so it has to be a sprite. And a sprite of a
|
||||||
|
score is only right until the score changes. When it does, render a new sprite
|
||||||
|
and unload the old one -- forgetting that leaks a sprite per change:
|
||||||
|
|
||||||
[source,lua]
|
[source,lua]
|
||||||
----
|
----
|
||||||
|
|
@ -240,9 +239,6 @@ tell whether it is still right:
|
||||||
end
|
end
|
||||||
----
|
----
|
||||||
|
|
||||||
A score changes perhaps twice a second. This renders twice a second instead of
|
|
||||||
sixty times, and the code to do it is nine lines.
|
|
||||||
|
|
||||||
`string.format("%06d", score)` is plain Lua, not Singe. It turns a number into
|
`string.format("%06d", score)` is plain Lua, not Singe. It turns a number into
|
||||||
a string, and `%06d` means "as a whole number, at least six digits, padded with
|
a string, and `%06d` means "as a whole number, at least six digits, padded with
|
||||||
zeros", which is why arcade scores have leading zeros. Change it to `%d` and
|
zeros", which is why arcade scores have leading zeros. Change it to `%d` and
|
||||||
|
|
@ -374,10 +370,10 @@ function onShutdown()
|
||||||
end
|
end
|
||||||
----
|
----
|
||||||
|
|
||||||
Run it and hold the fire button. The title sits centred and never re-renders,
|
Run it and hold the fire button. The title and the hint sit centred, the score
|
||||||
the score climbs and re-renders only when it changes, the hint blinks, and the
|
climbs against the right margin and is re-rendered only when it changes, the
|
||||||
clock at the bottom is the one thing honestly drawn with `fontPrint` sixty
|
hint blinks, and the label and the clock are printed with `fontPrint` every
|
||||||
times a second.
|
frame -- the clock in whole seconds, so it is a new string only once a second.
|
||||||
|
|
||||||
=== What Just Happened
|
=== What Just Happened
|
||||||
|
|
||||||
|
|
@ -448,8 +444,14 @@ and it removes a whole category of puzzled evening.
|
||||||
measured. Get it against the right margin anyway. There is only one way, and
|
measured. Get it against the right margin anyway. There is only one way, and
|
||||||
finding it is the point.
|
finding it is the point.
|
||||||
. *Take out the `scoreShown` check*, so `renderScore()` runs every frame
|
. *Take out the `scoreShown` check*, so `renderScore()` runs every frame
|
||||||
instead. It will look identical. Leave it running for a minute and watch the
|
instead. It will look identical, but it now renders the score sixty times a
|
||||||
memory your game is using.
|
second instead of when it changes -- exactly the work `fontPrint` saves you
|
||||||
|
when you let it. Then take out the `spriteUnload` in `renderScore` as well,
|
||||||
|
leave it running for a minute, and watch the memory your game is using.
|
||||||
|
. *Print a clock in milliseconds.* Change the clock line to print
|
||||||
|
`singeGetTicks()` without the `// 1000`. It still works; it is now a string
|
||||||
|
`fontPrint` has never seen, every frame. On a slow machine, that is the kind
|
||||||
|
of text that costs.
|
||||||
. *Use your own font.* Find a `.ttf` on your machine, copy it beside your
|
. *Use your own font.* Find a `.ttf` on your machine, copy it beside your
|
||||||
script, and load it with `DIR .. "thatfont.ttf"`. Some fonts look terrible at
|
script, and load it with `DIR .. "thatfont.ttf"`. Some fonts look terrible at
|
||||||
18 point and fine at 48. That is the font's fault, not yours.
|
18 point and fine at 48. That is the font's fault, not yours.
|
||||||
|
|
@ -484,9 +486,9 @@ thing, several hundred lines away from the `fontUnload` that caused it.
|
||||||
* `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED`
|
* `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED`
|
||||||
is the usual answer.
|
is the usual answer.
|
||||||
* `fontPrint` keeps what it renders, so printing the same text every frame is
|
* `fontPrint` keeps what it renders, so printing the same text every frame is
|
||||||
cheap.
|
cheap; text that is different every frame is what costs.
|
||||||
* `fontToSprite` renders into a sprite, which `spriteGetWidth` measures and the
|
* `fontToSprite` renders into a sprite, which `spriteGetWidth` measures and the
|
||||||
`sprite*` calls can scale and turn. Using it only for speed is deprecated.
|
`sprite*` calls can scale and turn. Use it for that, not for speed.
|
||||||
* Measuring is how you centre or right align text, because nothing else can
|
* Measuring is how you centre or right align text, because nothing else can
|
||||||
tell you how wide a string will be.
|
tell you how wide a string will be.
|
||||||
* A sprite's colour is fixed when it is made, not when it is drawn.
|
* A sprite's colour is fixed when it is made, not when it is drawn.
|
||||||
|
|
|
||||||
4893
forge/Author.singe
4893
forge/Author.singe
File diff suppressed because it is too large
Load diff
|
|
@ -105,6 +105,20 @@ local MODES = { "entities", "types", "rules", "tracks", "dialogues" }
|
||||||
local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true, singe = true }
|
local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true, singe = true }
|
||||||
local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks.
|
local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks.
|
||||||
local FILE_NONE = "(none)" -- The file picker's first entry: no file.
|
local FILE_NONE = "(none)" -- The file picker's first entry: no file.
|
||||||
|
-- Who made the game, as the description's credits table carries it: the field, and the games.dat
|
||||||
|
-- key a release writes it under, which is what the menu's details panel shows. notes (lines of
|
||||||
|
-- credit the fields have no place for) and licenses (files that travel with the release) are the
|
||||||
|
-- two lists beside these.
|
||||||
|
local CREDIT_FIELDS = {
|
||||||
|
{ field = "developer", key = "DEVELOPER" },
|
||||||
|
{ field = "publisher", key = "PUBLISHER" },
|
||||||
|
{ field = "year", key = "YEAR" },
|
||||||
|
{ field = "platform", key = "PLATFORM" },
|
||||||
|
{ field = "genre", key = "GENRE" },
|
||||||
|
{ field = "creator", key = "CREATOR" },
|
||||||
|
{ field = "source", key = "SOURCE" },
|
||||||
|
{ field = "description", key = "DESCRIPTION" }
|
||||||
|
}
|
||||||
local FRAME_STEP = 1
|
local FRAME_STEP = 1
|
||||||
local FRAME_LEAP = 10
|
local FRAME_LEAP = 10
|
||||||
local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it.
|
local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it.
|
||||||
|
|
@ -1308,12 +1322,56 @@ function forgeExport(folder, name)
|
||||||
-- The id travels with the release: it is what the master service keys scores and updates
|
-- 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
|
-- on, and a game without one cannot be published. Every description has one from the moment
|
||||||
-- it is first saved (forgeIdFor).
|
-- it is first saved (forgeIdFor).
|
||||||
|
-- The credits go with it, under the keys the menu's details panel shows, so whoever made the
|
||||||
|
-- game is named wherever it is played; lines the keys have no place for go in CREDITS.txt.
|
||||||
|
local credits = FORGE.game.credits or {}
|
||||||
|
local lines = {
|
||||||
|
string.format("\t\tTITLE = %q,", title),
|
||||||
|
string.format("\t\tGAME_ID = %q,", forgeIdFor(FORGE.game)),
|
||||||
|
string.format("\t\tSCRIPT = %q,", base .. "/" .. script .. ".singe"),
|
||||||
|
string.format("\t\tVIDEO = %q,", video),
|
||||||
|
string.format("\t\tRESOLUTION_X = %d,", overlayGetWidth()),
|
||||||
|
string.format("\t\tRESOLUTION_Y = %d,", overlayGetHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
-- What the game needs of a machine, as far as its layers say (PLAN.md section 57): a machine
|
||||||
|
-- that lacks one is told so in the menu rather than failing at start. --pack adds whatever a
|
||||||
|
-- run of the game used beyond these.
|
||||||
|
local needs = {}
|
||||||
|
|
||||||
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
||||||
|
if (layer.kind == "scene3d") and not needs.SCENE then
|
||||||
|
needs.SCENE = true
|
||||||
|
needs[#needs + 1] = "\"SCENE\""
|
||||||
|
elseif (layer.kind == "hud") and not needs.GUI then
|
||||||
|
needs.GUI = true
|
||||||
|
needs[#needs + 1] = "\"GUI\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #needs > 0 then
|
||||||
|
lines[#lines + 1] = "\t\tREQUIRES = { " .. table.concat(needs, ", ") .. " },"
|
||||||
|
end
|
||||||
|
for _, credit in ipairs(CREDIT_FIELDS) do
|
||||||
|
local value = credits[credit.field]
|
||||||
|
|
||||||
|
if type(value) == "number" then
|
||||||
|
lines[#lines + 1] = string.format("\t\t%s = %d,", credit.key, value)
|
||||||
|
elseif (type(value) == "string") and (value ~= "") then
|
||||||
|
lines[#lines + 1] = string.format("\t\t%s = %q,", credit.key, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
dat = io.open(folder .. "/games.dat", "w")
|
dat = io.open(folder .. "/games.dat", "w")
|
||||||
if dat ~= nil then
|
if dat ~= nil then
|
||||||
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",
|
dat:write("-- Written by Forge.\nGAMES = {\n\t{\n" .. table.concat(lines, "\n") .. "\n\t}\n}\n")
|
||||||
title, forgeIdFor(FORGE.game), base .. "/" .. script .. ".singe", video, overlayGetWidth(), overlayGetHeight()))
|
|
||||||
dat:close()
|
dat:close()
|
||||||
end
|
end
|
||||||
|
if credits.notes and (#credits.notes > 0) then
|
||||||
|
dat = io.open(folder .. "/CREDITS.txt", "w")
|
||||||
|
if dat ~= nil then
|
||||||
|
dat:write(title .. "\n\n" .. table.concat(credits.notes, "\n") .. "\n")
|
||||||
|
dat:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
FORGE.message = "released to " .. folder
|
FORGE.message = "released to " .. folder
|
||||||
forgeRefresh()
|
forgeRefresh()
|
||||||
|
|
@ -1449,6 +1507,9 @@ function forgeFilesNamed(game)
|
||||||
end
|
end
|
||||||
|
|
||||||
take(game.vocabulary)
|
take(game.vocabulary)
|
||||||
|
for _, name in ipairs((game.credits or {}).licenses or {}) do
|
||||||
|
take(name)
|
||||||
|
end
|
||||||
for _, layer in ipairs(game.layers or {}) do
|
for _, layer in ipairs(game.layers or {}) do
|
||||||
fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params)
|
fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params)
|
||||||
end
|
end
|
||||||
|
|
@ -4369,6 +4430,11 @@ end
|
||||||
function forgeRoomFieldNames()
|
function forgeRoomFieldNames()
|
||||||
local names = { "title", "players", "vars", "verbs", "layers", "vocabulary" }
|
local names = { "title", "players", "vars", "verbs", "layers", "vocabulary" }
|
||||||
|
|
||||||
|
for _, credit in ipairs(CREDIT_FIELDS) do
|
||||||
|
names[#names + 1] = "credits." .. credit.field
|
||||||
|
end
|
||||||
|
names[#names + 1] = "credits.notes"
|
||||||
|
names[#names + 1] = "credits.licenses"
|
||||||
for _, layer in ipairs(FORGE.game.layers or {}) do
|
for _, layer in ipairs(FORGE.game.layers or {}) do
|
||||||
local entry = AUTHOR.layers[layer.kind]
|
local entry = AUTHOR.layers[layer.kind]
|
||||||
|
|
||||||
|
|
@ -4412,6 +4478,15 @@ function forgeRoomGet(room, key)
|
||||||
return listText(FORGE.game.layers)
|
return listText(FORGE.game.layers)
|
||||||
elseif key == "vocabulary" then
|
elseif key == "vocabulary" then
|
||||||
return FORGE.game.vocabulary
|
return FORGE.game.vocabulary
|
||||||
|
elseif group == "credits" then
|
||||||
|
local credits = FORGE.game.credits or {}
|
||||||
|
|
||||||
|
if field == "notes" then
|
||||||
|
return table.concat(credits.notes or {}, " | ")
|
||||||
|
elseif field == "licenses" then
|
||||||
|
return table.concat(credits.licenses or {}, ", ")
|
||||||
|
end
|
||||||
|
return (credits[field] ~= nil) and tostring(credits[field]) or nil
|
||||||
elseif group ~= nil then
|
elseif group ~= nil then
|
||||||
local layer = layerOf(group)
|
local layer = layerOf(group)
|
||||||
|
|
||||||
|
|
@ -4450,6 +4525,31 @@ function forgeRoomSet(room, key, value)
|
||||||
|
|
||||||
if key == "title" then
|
if key == "title" then
|
||||||
FORGE.game.title = tostring(value)
|
FORGE.game.title = tostring(value)
|
||||||
|
elseif group == "credits" then
|
||||||
|
local credits = FORGE.game.credits or {}
|
||||||
|
local text = (value ~= nil) and tostring(value) or ""
|
||||||
|
local list = {}
|
||||||
|
|
||||||
|
if field == "notes" then
|
||||||
|
for line in string.gmatch(text, "[^|]+") do
|
||||||
|
line = line:match("^%s*(.-)%s*$")
|
||||||
|
if line ~= "" then
|
||||||
|
list[#list + 1] = line
|
||||||
|
end
|
||||||
|
end
|
||||||
|
credits.notes = (#list > 0) and list or nil
|
||||||
|
elseif field == "licenses" then
|
||||||
|
for name in string.gmatch(text, "[^,]+") do
|
||||||
|
name = name:match("^%s*(.-)%s*$")
|
||||||
|
if name ~= "" then
|
||||||
|
list[#list + 1] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
credits.licenses = (#list > 0) and list or nil
|
||||||
|
else
|
||||||
|
credits[field] = (text ~= "") and (tonumber(text) or text) or nil
|
||||||
|
end
|
||||||
|
FORGE.game.credits = (next(credits) ~= nil) and credits or nil
|
||||||
elseif key == "vocabulary" then
|
elseif key == "vocabulary" then
|
||||||
-- The game's own vocabulary, loaded as it is named so the panels offer what it adds.
|
-- The game's own vocabulary, loaded as it is named so the panels offer what it adds.
|
||||||
FORGE.game.vocabulary = ((value ~= nil) and (value ~= "")) and tostring(value) or nil
|
FORGE.game.vocabulary = ((value ~= nil) and (value ~= "")) and tostring(value) or nil
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ GAMES = {
|
||||||
{
|
{
|
||||||
TITLE = "Forge",
|
TITLE = "Forge",
|
||||||
SCRIPT = "Forge/Forge.singe",
|
SCRIPT = "Forge/Forge.singe",
|
||||||
|
REQUIRES = { "GUI" },
|
||||||
LEGACY_SPRITE_ARGS = false,
|
LEGACY_SPRITE_ARGS = false,
|
||||||
VIDEO = "Singe/menuBackground.mkv",
|
VIDEO = "Singe/menuBackground.mkv",
|
||||||
STRETCH = false,
|
STRETCH = false,
|
||||||
|
|
|
||||||
83
src/main.c
83
src/main.c
|
|
@ -39,6 +39,7 @@
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <time.h>
|
||||||
#ifndef _WIN32
|
#ifndef _WIN32
|
||||||
#include <execinfo.h>
|
#include <execinfo.h>
|
||||||
#include <sys/utsname.h>
|
#include <sys/utsname.h>
|
||||||
|
|
@ -84,6 +85,7 @@
|
||||||
#define USAGE_OPTION_WIDTH 28 // The longest long form is --trigger_threshold=PERCENT plus a space
|
#define USAGE_OPTION_WIDTH 28 // The longest long form is --trigger_threshold=PERCENT plus a space
|
||||||
#define USAGE_LINE_WIDTH 79 // Help text wraps so no line is wider than this
|
#define USAGE_LINE_WIDTH 79 // Help text wraps so no line is wider than this
|
||||||
#define CRASH_FRAMES_MAX 64
|
#define CRASH_FRAMES_MAX 64
|
||||||
|
#define TERMINATE_TWIN_MS 1000 // A second SIGTERM or SIGINT this soon is the first one sent twice
|
||||||
#define SCREEN_MIN 1 // --screen numbers the displays from 1, as Hypseus does
|
#define SCREEN_MIN 1 // --screen numbers the displays from 1, as Hypseus does
|
||||||
#define IDLE_EXIT_MIN 0 // --idleexit seconds; 0 never quits
|
#define IDLE_EXIT_MIN 0 // --idleexit seconds; 0 never quits
|
||||||
#define IDLE_EXIT_MAX 86400 // ... and a day is as long as anyone can mean
|
#define IDLE_EXIT_MAX 86400 // ... and a day is as long as anyone can mean
|
||||||
|
|
@ -333,6 +335,9 @@ static char *_executable = NULL;
|
||||||
|
|
||||||
// SIGTERM or SIGINT arrived: the whole of Singe ends, the menu a game came from included.
|
// SIGTERM or SIGINT arrived: the whole of Singe ends, the menu a game came from included.
|
||||||
static volatile sig_atomic_t _terminated = 0;
|
static volatile sig_atomic_t _terminated = 0;
|
||||||
|
#ifndef _WIN32
|
||||||
|
static struct timespec _terminatedAt; // When the first SIGTERM or SIGINT came.
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
const char *mainExecutablePath(void) {
|
const char *mainExecutablePath(void) {
|
||||||
|
|
@ -1713,7 +1718,7 @@ static void _resolveFiles(const char *exeName, ConfigT *conf) {
|
||||||
static bool _runTool(const ConfigT *conf) {
|
static bool _runTool(const ConfigT *conf) {
|
||||||
switch (conf->toolMode) {
|
switch (conf->toolMode) {
|
||||||
case TOOL_PACK:
|
case TOOL_PACK:
|
||||||
return packGame(conf->toolSource, conf->scriptFile);
|
return packGame(conf->toolSource, conf->scriptFile, conf->dataDirBase);
|
||||||
|
|
||||||
case TOOL_PATCH:
|
case TOOL_PATCH:
|
||||||
return packPatch(conf->toolSource, conf->scriptFile);
|
return packPatch(conf->toolSource, conf->scriptFile);
|
||||||
|
|
@ -1878,13 +1883,30 @@ static void _stripDatabaseNames(char *path) {
|
||||||
|
|
||||||
|
|
||||||
// SIGTERM and SIGINT: noted, for the frame loop to end the script and main() to run nothing after
|
// SIGTERM and SIGINT: noted, for the frame loop to end the script and main() to run nothing after
|
||||||
// it. A second one while that is under way means it is stuck, and ends the process at once.
|
// it. A second one while that is under way means it is stuck, and ends the process at once --
|
||||||
|
// unless it came within TERMINATE_TWIN_MS of the first: timeout, and anything else that signals
|
||||||
|
// the process and then its group, sends one request twice.
|
||||||
static void _terminateHandler(int signalNumber) {
|
static void _terminateHandler(int signalNumber) {
|
||||||
|
#ifndef _WIN32
|
||||||
|
struct timespec now;
|
||||||
|
int64_t elapsedMS = 0;
|
||||||
|
#endif
|
||||||
|
|
||||||
if (_terminated) {
|
if (_terminated) {
|
||||||
|
#ifndef _WIN32
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||||
|
elapsedMS = (int64_t)(now.tv_sec - _terminatedAt.tv_sec) * 1000 + (now.tv_nsec - _terminatedAt.tv_nsec) / 1000000;
|
||||||
|
if (elapsedMS < TERMINATE_TWIN_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
signal(signalNumber, SIG_DFL);
|
signal(signalNumber, SIG_DFL);
|
||||||
raise(signalNumber);
|
raise(signalNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
#ifndef _WIN32
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &_terminatedAt);
|
||||||
|
#endif
|
||||||
_terminated = 1;
|
_terminated = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2252,30 +2274,7 @@ ConfigT *cloneConf(const ConfigT *conf) {
|
||||||
|
|
||||||
// Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
|
// Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
|
||||||
char *createDataDir(const char *dataDirBase, const char *filename) {
|
char *createDataDir(const char *dataDirBase, const char *filename) {
|
||||||
const char separator = utilGetPathSeparator();
|
char *path = dataDirPath(dataDirBase, filename);
|
||||||
char *relative = utilGetUpToLastPathComponent(filename);
|
|
||||||
char *start = relative;
|
|
||||||
char *path = NULL;
|
|
||||||
char *p = NULL;
|
|
||||||
|
|
||||||
// Keep absolute paths inside the base: drop any drive letter and leading separators.
|
|
||||||
if (isalpha((unsigned char)start[0]) && (start[1] == ':')) {
|
|
||||||
start += 2;
|
|
||||||
}
|
|
||||||
while (*start == separator) {
|
|
||||||
start++;
|
|
||||||
}
|
|
||||||
// Neutralize ".." components.
|
|
||||||
for (p = start; *p != 0; p++) {
|
|
||||||
if ((p[0] == '.') && (p[1] == '.') && ((p == start) || (p[-1] == separator)) && ((p[2] == separator) || (p[2] == 0))) {
|
|
||||||
p[0] = '_';
|
|
||||||
p[1] = '_';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_stripDatabaseNames(start);
|
|
||||||
path = utilCreateString("%s%s", dataDirBase, start);
|
|
||||||
free(relative);
|
|
||||||
utilFixPathSeparators(&path, true);
|
|
||||||
|
|
||||||
// Try to create data directory to ensure it exists.
|
// Try to create data directory to ensure it exists.
|
||||||
if (!utilMkDirP(path, DIRECTORY_MODE)) {
|
if (!utilMkDirP(path, DIRECTORY_MODE)) {
|
||||||
|
|
@ -2308,6 +2307,38 @@ char *createDataDirFor(const ConfigT *conf) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// dataDirBase + directory of filename, the data directory createDataDir makes, without making it:
|
||||||
|
// what --pack needs to find the record a game's runs left there. Returns a new string.
|
||||||
|
char *dataDirPath(const char *dataDirBase, const char *filename) {
|
||||||
|
const char separator = utilGetPathSeparator();
|
||||||
|
char *relative = utilGetUpToLastPathComponent(filename);
|
||||||
|
char *start = relative;
|
||||||
|
char *path = NULL;
|
||||||
|
char *p = NULL;
|
||||||
|
|
||||||
|
// Keep absolute paths inside the base: drop any drive letter and leading separators.
|
||||||
|
if (isalpha((unsigned char)start[0]) && (start[1] == ':')) {
|
||||||
|
start += 2;
|
||||||
|
}
|
||||||
|
while (*start == separator) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
// Neutralize ".." components.
|
||||||
|
for (p = start; *p != 0; p++) {
|
||||||
|
if ((p[0] == '.') && (p[1] == '.') && ((p == start) || (p[-1] == separator)) && ((p[2] == separator) || (p[2] == 0))) {
|
||||||
|
p[0] = '_';
|
||||||
|
p[1] = '_';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_stripDatabaseNames(start);
|
||||||
|
path = utilCreateString("%s%s", dataDirBase, start);
|
||||||
|
free(relative);
|
||||||
|
utilFixPathSeparators(&path, true);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void destroyConf(ConfigT **confPointer) {
|
void destroyConf(ConfigT **confPointer) {
|
||||||
ConfigT *conf = *confPointer;
|
ConfigT *conf = *confPointer;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
ConfigT *cloneConf(const ConfigT *conf);
|
ConfigT *cloneConf(const ConfigT *conf);
|
||||||
char *createDataDir(const char *dataDirBase, const char *filename);
|
char *createDataDir(const char *dataDirBase, const char *filename);
|
||||||
char *createDataDirFor(const ConfigT *conf);
|
char *createDataDirFor(const ConfigT *conf);
|
||||||
|
char *dataDirPath(const char *dataDirBase, const char *filename);
|
||||||
void destroyConf(ConfigT **confPointer);
|
void destroyConf(ConfigT **confPointer);
|
||||||
bool isFrameFileName(const char *filename);
|
bool isFrameFileName(const char *filename);
|
||||||
// What the trace header says about this machine, for the service tools to show as well. Each one
|
// What the trace header says about this machine, for the service tools to show as well. Each one
|
||||||
|
|
|
||||||
|
|
@ -1715,6 +1715,12 @@ int32_t modelInstance(int32_t model, int32_t parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether a loaded model carries a skin: a game that instances one needs skinning (PLAN.md 57).
|
||||||
|
bool modelIsSkinned(int32_t model) {
|
||||||
|
return modelValid(model) && (_models[model].data != NULL) && (_models[model].data->skins_count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const char *modelLastError(void) {
|
const char *modelLastError(void) {
|
||||||
return _error;
|
return _error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ bool modelDelete(int32_t model);
|
||||||
int32_t modelGetAnimationCount(int32_t model);
|
int32_t modelGetAnimationCount(int32_t model);
|
||||||
const char *modelGetAnimationName(int32_t model, int32_t index);
|
const char *modelGetAnimationName(int32_t model, int32_t index);
|
||||||
int32_t modelInstance(int32_t model, int32_t parent);
|
int32_t modelInstance(int32_t model, int32_t parent);
|
||||||
|
bool modelIsSkinned(int32_t model);
|
||||||
const char *modelLastError(void);
|
const char *modelLastError(void);
|
||||||
int32_t modelLoad(const char *name);
|
int32_t modelLoad(const char *name);
|
||||||
void modelQuit(void);
|
void modelQuit(void);
|
||||||
|
|
|
||||||
230
src/netTrust.c
Normal file
230
src/netTrust.c
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* Singe 3
|
||||||
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 3
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
||||||
|
* 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
// Singe: the machine's own trusted roots, added to a TLS context beside the bundle Singe ships.
|
||||||
|
// OpenSSL reads PEM files and hashed directories and nothing else, so each platform's store is
|
||||||
|
// read here into the context's X509_STORE: the distribution's file on Linux, the ROOT system
|
||||||
|
// store on Windows, the system anchors on macOS. A root present in two lists is added twice,
|
||||||
|
// which OpenSSL takes quietly.
|
||||||
|
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <openssl/err.h>
|
||||||
|
#include <openssl/x509.h>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <windows.h>
|
||||||
|
#include <wincrypt.h>
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
#include <Security/Security.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "netTrust.h"
|
||||||
|
|
||||||
|
|
||||||
|
#if !defined(_WIN32) && !defined(__APPLE__)
|
||||||
|
// Where Linux distributions keep their combined list, first match wins: Debian, Ubuntu, Arch,
|
||||||
|
// and Gentoo; Fedora and RHEL; openSUSE; OpenELEC; CentOS and RHEL 7 and later; Alpine.
|
||||||
|
static const char *const LINUX_BUNDLES[] = {
|
||||||
|
"/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||||
|
"/etc/ssl/ca-bundle.pem",
|
||||||
|
"/etc/pki/tls/cacert.pem",
|
||||||
|
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
||||||
|
"/etc/ssl/cert.pem"
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__)
|
||||||
|
static bool _addDer(X509_STORE *store, const uint8_t *der, long length);
|
||||||
|
#endif
|
||||||
|
static bool _addDirectory(X509_STORE *store, const char *path);
|
||||||
|
static bool _addFile(X509_STORE *store, const char *path);
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
static int32_t _addMac(X509_STORE *store);
|
||||||
|
#endif
|
||||||
|
#if defined(_WIN32)
|
||||||
|
static int32_t _addWindows(X509_STORE *store);
|
||||||
|
#endif
|
||||||
|
static void _note(char *report, size_t reportSize, const char *what, const char *path);
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__)
|
||||||
|
// One certificate in DER form, as both platform stores hand them out.
|
||||||
|
static bool _addDer(X509_STORE *store, const uint8_t *der, long length) {
|
||||||
|
const unsigned char *cursor = der;
|
||||||
|
X509 *cert = d2i_X509(NULL, &cursor, length);
|
||||||
|
bool added = false;
|
||||||
|
|
||||||
|
if (cert != NULL) {
|
||||||
|
added = (X509_STORE_add_cert(store, cert) == 1);
|
||||||
|
X509_free(cert);
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// A hashed directory of roots (SSL_CERT_DIR), looked up as a connection needs them.
|
||||||
|
static bool _addDirectory(X509_STORE *store, const char *path) {
|
||||||
|
return (path != NULL) && (path[0] != 0) && (X509_STORE_load_path(store, path) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A PEM file of roots. A path that is not there is simply not added.
|
||||||
|
static bool _addFile(X509_STORE *store, const char *path) {
|
||||||
|
return (path != NULL) && (path[0] != 0) && (X509_STORE_load_file(store, path) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
// The system's anchor certificates. Only the anchors: the admin and user trust settings can also
|
||||||
|
// mark a certificate as distrusted, which a plain list of roots cannot express.
|
||||||
|
static int32_t _addMac(X509_STORE *store) {
|
||||||
|
CFArrayRef anchors = NULL;
|
||||||
|
CFIndex count = 0;
|
||||||
|
CFIndex x = 0;
|
||||||
|
int32_t added = 0;
|
||||||
|
|
||||||
|
if (SecTrustCopyAnchorCertificates(&anchors) != errSecSuccess) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
count = CFArrayGetCount(anchors);
|
||||||
|
for (x = 0; x < count; x++) {
|
||||||
|
SecCertificateRef cert = (SecCertificateRef)CFArrayGetValueAtIndex(anchors, x);
|
||||||
|
CFDataRef data = SecCertificateCopyData(cert);
|
||||||
|
|
||||||
|
if (data != NULL) {
|
||||||
|
if (_addDer(store, CFDataGetBytePtr(data), (long)CFDataGetLength(data))) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
CFRelease(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CFRelease(anchors);
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
// The ROOT system store. Windows fetches roots on demand, so a root it would trust can be absent
|
||||||
|
// here until something on the machine has needed it; the shipped bundle covers that gap.
|
||||||
|
static int32_t _addWindows(X509_STORE *store) {
|
||||||
|
HCERTSTORE system = CertOpenSystemStoreW(0, L"ROOT");
|
||||||
|
PCCERT_CONTEXT cert = NULL;
|
||||||
|
int32_t added = 0;
|
||||||
|
|
||||||
|
if (system == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
while ((cert = CertEnumCertificatesInStore(system, cert)) != NULL) {
|
||||||
|
if ((cert->dwCertEncodingType & X509_ASN_ENCODING) && _addDer(store, cert->pbCertEncoded, (long)cert->cbCertEncoded)) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CertCloseStore(system, 0);
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// One line of the report: what was consulted, and where.
|
||||||
|
static void _note(char *report, size_t reportSize, const char *what, const char *path) {
|
||||||
|
size_t used = strlen(report);
|
||||||
|
|
||||||
|
if (used < reportSize) {
|
||||||
|
snprintf(report + used, reportSize - used, "%s%s %s", (used > 0) ? "\n" : "", what, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int32_t netTrustSystem(SSL_CTX *context, const char *dataDir, char *report, size_t reportSize) {
|
||||||
|
X509_STORE *store = SSL_CTX_get_cert_store(context);
|
||||||
|
const char *file = getenv("SSL_CERT_FILE");
|
||||||
|
const char *dir = getenv("SSL_CERT_DIR");
|
||||||
|
char *local = NULL;
|
||||||
|
size_t length = 0;
|
||||||
|
int32_t lists = 0;
|
||||||
|
bool system = false;
|
||||||
|
|
||||||
|
report[0] = 0;
|
||||||
|
// An operator can name a list outright, and it counts as the machine's.
|
||||||
|
if (_addFile(store, file)) {
|
||||||
|
_note(report, reportSize, "system", file);
|
||||||
|
lists++;
|
||||||
|
system = true;
|
||||||
|
}
|
||||||
|
if (_addDirectory(store, dir)) {
|
||||||
|
_note(report, reportSize, "system", dir);
|
||||||
|
lists++;
|
||||||
|
system = true;
|
||||||
|
}
|
||||||
|
if (!system) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
if (_addWindows(store) > 0) {
|
||||||
|
_note(report, reportSize, "system", "the Windows root store");
|
||||||
|
lists++;
|
||||||
|
}
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
if (_addMac(store) > 0) {
|
||||||
|
_note(report, reportSize, "system", "the macOS system anchors");
|
||||||
|
lists++;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
size_t x = 0;
|
||||||
|
|
||||||
|
for (x = 0; x < sizeof(LINUX_BUNDLES) / sizeof(LINUX_BUNDLES[0]); x++) {
|
||||||
|
if (_addFile(store, LINUX_BUNDLES[x])) {
|
||||||
|
_note(report, reportSize, "system", LINUX_BUNDLES[x]);
|
||||||
|
lists++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
// The data directory's own file, which the engine never writes.
|
||||||
|
if (dataDir != NULL) {
|
||||||
|
length = strlen(dataDir) + strlen(NET_TRUST_LOCAL_FILE) + 1;
|
||||||
|
local = malloc(length);
|
||||||
|
if (local != NULL) {
|
||||||
|
snprintf(local, length, "%s%s", dataDir, NET_TRUST_LOCAL_FILE);
|
||||||
|
if (_addFile(store, local)) {
|
||||||
|
_note(report, reportSize, "local", local);
|
||||||
|
lists++;
|
||||||
|
}
|
||||||
|
free(local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A list that was not there leaves an error on the queue; it is not one.
|
||||||
|
ERR_clear_error();
|
||||||
|
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
51
src/netTrust.h
Normal file
51
src/netTrust.h
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* Singe 3
|
||||||
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 3
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
||||||
|
* 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
#ifndef NETTRUST_H
|
||||||
|
#define NETTRUST_H
|
||||||
|
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include <openssl/ssl.h>
|
||||||
|
|
||||||
|
|
||||||
|
// The name of the file of extra roots in the data directory that the engine never writes, so a
|
||||||
|
// self-hosted server's own CA survives every run and every update.
|
||||||
|
#define NET_TRUST_LOCAL_FILE "cacerts.local.pem"
|
||||||
|
// Room for the report: a few lines of a path each.
|
||||||
|
#define NET_TRUST_REPORT_SIZE 2048
|
||||||
|
|
||||||
|
|
||||||
|
// Adds the machine's own trusted roots, and the data directory's local file when there is one,
|
||||||
|
// to a TLS context that already holds the shipped bundle: a connection then verifies when either
|
||||||
|
// list knows the server's root, so a cabinet whose bundle has gone stale can still reach the
|
||||||
|
// server that would update it (PLAN.md section 61). report receives what was consulted, one
|
||||||
|
// "what path" entry per line, for the message a failed verification gives. Returns the number of
|
||||||
|
// lists that added anything.
|
||||||
|
int32_t netTrustSystem(SSL_CTX *context, const char *dataDir, char *report, size_t reportSize);
|
||||||
|
|
||||||
|
|
||||||
|
#endif // NETTRUST_H
|
||||||
156
src/pack.c
156
src/pack.c
|
|
@ -37,6 +37,7 @@
|
||||||
#include "lauxlib.h"
|
#include "lauxlib.h"
|
||||||
#include "lualib.h"
|
#include "lualib.h"
|
||||||
|
|
||||||
|
#include "main.h"
|
||||||
#include "pack.h"
|
#include "pack.h"
|
||||||
#include "singe.h"
|
#include "singe.h"
|
||||||
#include "util.h"
|
#include "util.h"
|
||||||
|
|
@ -90,6 +91,7 @@ static bool _isPatchName(const char *database);
|
||||||
static bool _isSafeName(const char *name);
|
static bool _isSafeName(const char *name);
|
||||||
static char *_keyFor(const char *relative);
|
static char *_keyFor(const char *relative);
|
||||||
static int64_t _readMetaInt(sqlite3 *db, const char *schema, const char *key, int64_t fallback);
|
static int64_t _readMetaInt(sqlite3 *db, const char *schema, const char *key, int64_t fallback);
|
||||||
|
static bool _requiresFromRuns(WriterT *writer, const char *dataDirBase);
|
||||||
static bool _scanEscapes(const EntryT *list);
|
static bool _scanEscapes(const EntryT *list);
|
||||||
static bool _validateGamesDat(WriterT *writer);
|
static bool _validateGamesDat(WriterT *writer);
|
||||||
static bool _writeBlob(FILE *file, const void *data, size_t bytes);
|
static bool _writeBlob(FILE *file, const void *data, size_t bytes);
|
||||||
|
|
@ -418,6 +420,157 @@ static int64_t _readMetaInt(sqlite3 *db, const char *schema, const char *key, in
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// REQUIRES from what a game used (PLAN.md section 57). Each games.dat entry that does not declare
|
||||||
|
// REQUIRES gets the list its runs recorded in their data directory (requires.txt), written into the
|
||||||
|
// packed games.dat just above its SCRIPT line; an entry that does declare it is only checked, and
|
||||||
|
// told about anything its runs used that the list leaves out. An entry never run is left alone: no
|
||||||
|
// REQUIRES means 2D, and a game that has not been run has nothing to say otherwise.
|
||||||
|
static bool _requiresFromRuns(WriterT *writer, const char *dataDirBase) {
|
||||||
|
sqlite3_stmt *stmt = NULL;
|
||||||
|
lua_State *L = NULL;
|
||||||
|
char *text = NULL;
|
||||||
|
char *grown = NULL;
|
||||||
|
char *dir = NULL;
|
||||||
|
char *path = NULL;
|
||||||
|
char *record = NULL;
|
||||||
|
char *line = NULL;
|
||||||
|
char *quoted = NULL;
|
||||||
|
char *name = NULL;
|
||||||
|
const char *script = NULL;
|
||||||
|
const char *title = NULL;
|
||||||
|
const char *at = NULL;
|
||||||
|
const char *lineStart = NULL;
|
||||||
|
const char *indentEnd = NULL;
|
||||||
|
size_t length = 0;
|
||||||
|
size_t bytes = 0;
|
||||||
|
bool declared = false;
|
||||||
|
bool listed = false;
|
||||||
|
bool changed = false;
|
||||||
|
bool ok = true;
|
||||||
|
|
||||||
|
if (dataDirBase == NULL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sqlite3_prepare_v2(writer->db, "SELECT data FROM assets WHERE path = ?", -1, &stmt, NULL) != SQLITE_OK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, VFS_GAMES_DAT, -1, SQLITE_STATIC);
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_ROW) {
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
length = (size_t)sqlite3_column_bytes(stmt, 0);
|
||||||
|
text = malloc(length + 1);
|
||||||
|
if (text == NULL) {
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
utilSay("!!! Out of memory reading %s.", VFS_GAMES_DAT);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memcpy(text, sqlite3_column_blob(stmt, 0), length);
|
||||||
|
text[length] = 0;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
L = luaL_newstate();
|
||||||
|
if ((luaL_loadbuffer(L, text, length, VFS_GAMES_DAT) != LUA_OK) || (lua_pcall(L, 0, 0, 0) != LUA_OK)) {
|
||||||
|
// _validateGamesDat says what is wrong with it.
|
||||||
|
lua_close(L);
|
||||||
|
free(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
lua_getglobal(L, "GAMES");
|
||||||
|
if (lua_istable(L, -1)) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
while (lua_next(L, -2)) {
|
||||||
|
if (lua_istable(L, -1)) {
|
||||||
|
lua_getfield(L, -1, "TITLE");
|
||||||
|
title = lua_tostring(L, -1) ? lua_tostring(L, -1) : "(untitled)";
|
||||||
|
lua_getfield(L, -2, "SCRIPT");
|
||||||
|
script = lua_tostring(L, -1);
|
||||||
|
lua_getfield(L, -3, "REQUIRES");
|
||||||
|
declared = lua_istable(L, -1);
|
||||||
|
if (script != NULL) {
|
||||||
|
dir = dataDirPath(dataDirBase, script);
|
||||||
|
path = utilCreateString("%s%s", dir, REQUIRES_RECORD_FILE);
|
||||||
|
record = utilReadFile(path, &bytes);
|
||||||
|
free(path);
|
||||||
|
free(dir);
|
||||||
|
}
|
||||||
|
if (record != NULL) {
|
||||||
|
line = NULL;
|
||||||
|
for (name = strtok(record, "\r\n"); name != NULL; name = strtok(NULL, "\r\n")) {
|
||||||
|
if (declared) {
|
||||||
|
// Declared: only said when a run used something the list leaves out.
|
||||||
|
listed = false;
|
||||||
|
lua_pushnil(L);
|
||||||
|
while (lua_next(L, -2)) {
|
||||||
|
if ((lua_tostring(L, -1) != NULL) && (strcmp(lua_tostring(L, -1), name) == 0)) {
|
||||||
|
listed = true;
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
if (!listed) {
|
||||||
|
utilSay("!!! %s: its runs used %s, which its REQUIRES does not list.", title, name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quoted = utilCreateString("%s%s\"%s\"", (line != NULL) ? line : "", (line != NULL) ? ", " : "", name);
|
||||||
|
free(line);
|
||||||
|
line = quoted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(record);
|
||||||
|
record = NULL;
|
||||||
|
if (!declared && (line != NULL)) {
|
||||||
|
// Above the entry's SCRIPT line, indented as it is.
|
||||||
|
quoted = utilCreateString("\"%s\"", script);
|
||||||
|
at = strstr(text, quoted);
|
||||||
|
free(quoted);
|
||||||
|
if (at == NULL) {
|
||||||
|
utilSay("!!! %s: could not find its SCRIPT in %s to add REQUIRES = { %s }.", title, VFS_GAMES_DAT, line);
|
||||||
|
} else {
|
||||||
|
lineStart = at;
|
||||||
|
while ((lineStart > text) && (lineStart[-1] != '\n')) {
|
||||||
|
lineStart--;
|
||||||
|
}
|
||||||
|
indentEnd = lineStart;
|
||||||
|
while ((*indentEnd == ' ') || (*indentEnd == '\t')) {
|
||||||
|
indentEnd++;
|
||||||
|
}
|
||||||
|
grown = utilCreateString("%.*s%.*sREQUIRES = { %s },\n%s", (int)(lineStart - text), text, (int)(indentEnd - lineStart), lineStart, line, lineStart);
|
||||||
|
free(text);
|
||||||
|
text = grown;
|
||||||
|
changed = true;
|
||||||
|
utilSay(">>> %s: REQUIRES = { %s }, from what its runs used", title, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(line);
|
||||||
|
line = NULL;
|
||||||
|
}
|
||||||
|
lua_pop(L, 3);
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_close(L);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
ok = (sqlite3_prepare_v2(writer->db, "UPDATE assets SET data = ?, size = ? WHERE path = ?", -1, &stmt, NULL) == SQLITE_OK);
|
||||||
|
if (ok) {
|
||||||
|
sqlite3_bind_blob(stmt, 1, text, (int)strlen(text), SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int64(stmt, 2, (sqlite3_int64)strlen(text));
|
||||||
|
sqlite3_bind_text(stmt, 3, VFS_GAMES_DAT, -1, SQLITE_STATIC);
|
||||||
|
ok = (sqlite3_step(stmt) == SQLITE_DONE);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
utilSay("!!! Could not write REQUIRES into %s: %s", VFS_GAMES_DAT, sqlite3_errmsg(writer->db));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(text);
|
||||||
|
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// A packed game may only reach its own contents: ".." in a script or data file is refused, by line.
|
// A packed game may only reach its own contents: ".." in a script or data file is refused, by line.
|
||||||
static bool _scanEscapes(const EntryT *list) {
|
static bool _scanEscapes(const EntryT *list) {
|
||||||
const EntryT *entry = NULL;
|
const EntryT *entry = NULL;
|
||||||
|
|
@ -736,7 +889,7 @@ char *packForbiddenReason(const char *path, bool isPatch) {
|
||||||
|
|
||||||
|
|
||||||
// Packs a game directory into a new database, or a directory of changed files into a .patch.
|
// Packs a game directory into a new database, or a directory of changed files into a .patch.
|
||||||
bool packGame(const char *directory, const char *database) {
|
bool packGame(const char *directory, const char *database, const char *dataDirBase) {
|
||||||
WriterT writer;
|
WriterT writer;
|
||||||
char *root = strdup(directory);
|
char *root = strdup(directory);
|
||||||
char *gamesDat = NULL;
|
char *gamesDat = NULL;
|
||||||
|
|
@ -775,6 +928,7 @@ bool packGame(const char *directory, const char *database) {
|
||||||
free(chunk);
|
free(chunk);
|
||||||
free(version);
|
free(version);
|
||||||
ok = ok && _applyDirectory(&writer, root, !isPatch);
|
ok = ok && _applyDirectory(&writer, root, !isPatch);
|
||||||
|
ok = ok && (isPatch || _requiresFromRuns(&writer, dataDirBase));
|
||||||
ok = ok && (isPatch || _validateGamesDat(&writer));
|
ok = ok && (isPatch || _validateGamesDat(&writer));
|
||||||
if (ok) {
|
if (ok) {
|
||||||
utilSay(">>> Packed %" PRId64 " files (%" PRId64 " bytes) from %s into %s", writer.files, writer.bytes, root, database);
|
utilSay(">>> Packed %" PRId64 " files (%" PRId64 " bytes) from %s into %s", writer.files, writer.bytes, root, database);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
|
|
||||||
|
|
||||||
char *packForbiddenReason(const char *path, bool isPatch);
|
char *packForbiddenReason(const char *path, bool isPatch);
|
||||||
bool packGame(const char *directory, const char *database);
|
bool packGame(const char *directory, const char *database, const char *dataDirBase);
|
||||||
bool packIsDatabase(const char *database);
|
bool packIsDatabase(const char *database);
|
||||||
bool packPatch(const char *database, const char *source);
|
bool packPatch(const char *database, const char *source);
|
||||||
bool packUnpack(const char *database, const char *directory);
|
bool packUnpack(const char *database, const char *directory);
|
||||||
|
|
|
||||||
26
src/scene.c
26
src/scene.c
|
|
@ -6229,6 +6229,32 @@ void sceneSetTonemap(SceneTonemapE tonemap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether this machine can render the scene into 16-bit float targets, which bloom and the other
|
||||||
|
// post effects want (PLAN.md section 57, POST).
|
||||||
|
bool sceneSupportsPost(void) {
|
||||||
|
return (_scene.device != NULL) && rgpuTextureSupportsFormat(_scene.device, SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether this machine has a depth format a shadow map can be rendered into and sampled from
|
||||||
|
// (PLAN.md section 57, SHADOWS).
|
||||||
|
bool sceneSupportsShadows(void) {
|
||||||
|
static const SDL_GPUTextureFormat wanted[] = { SDL_GPU_TEXTUREFORMAT_D32_FLOAT, SDL_GPU_TEXTUREFORMAT_D16_UNORM, SDL_GPU_TEXTUREFORMAT_D24_UNORM };
|
||||||
|
int32_t x = 0;
|
||||||
|
|
||||||
|
if (_scene.device == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (x = 0; x < (int32_t)SDL_arraysize(wanted); x++) {
|
||||||
|
if (rgpuTextureSupportsFormat(_scene.device, wanted[x], SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Overlay coordinates back to a world point: the point on the ray through that pixel at the given
|
// Overlay coordinates back to a world point: the point on the ray through that pixel at the given
|
||||||
// distance from the camera's near plane, using the last rendered frame's camera. Two distances
|
// distance from the camera's near plane, using the last rendered frame's camera. Two distances
|
||||||
// give a ray for picking.
|
// give a ray for picking.
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ void sceneSetShadowSize(int32_t size);
|
||||||
bool sceneSetSky(const float *rgb, int32_t width, int32_t height);
|
bool sceneSetSky(const float *rgb, int32_t width, int32_t height);
|
||||||
void sceneSetSkyIntensity(float intensity);
|
void sceneSetSkyIntensity(float intensity);
|
||||||
void sceneSetTonemap(SceneTonemapE tonemap);
|
void sceneSetTonemap(SceneTonemapE tonemap);
|
||||||
|
bool sceneSupportsPost(void);
|
||||||
|
bool sceneSupportsShadows(void);
|
||||||
Vec3T sceneUnproject(float x, float y, float distance);
|
Vec3T sceneUnproject(float x, float y, float distance);
|
||||||
void sceneUpdateTransforms(void);
|
void sceneUpdateTransforms(void);
|
||||||
void sceneUpdateVideo(SceneVideoSourceFn source);
|
void sceneUpdateVideo(SceneVideoSourceFn source);
|
||||||
|
|
|
||||||
204
src/scriptWatch.c
Normal file
204
src/scriptWatch.c
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* Singe 3
|
||||||
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 3
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
||||||
|
* 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
// Singe: the watchdog over a running script. The frame loop hears the window close and SIGTERM
|
||||||
|
// only when the script returns to it, so a script caught in a loop froze the window and ignored
|
||||||
|
// both. A thread notes how long the script has run; past WATCH_ARM_MS it sets a Lua count hook,
|
||||||
|
// which runs on the main thread inside the script, pumps the events, and raises an error when a
|
||||||
|
// quit is pending and the call has run WATCH_STOP_MS. Setting a hook from outside the running thread is how Lua's own interpreter
|
||||||
|
// stops a script at Ctrl+C; the hook is set and cleared under a lock the main thread also takes
|
||||||
|
// on entry and return, so it never touches a state that has returned or closed. A script that
|
||||||
|
// returns sooner never has a hook, and plays exactly as it did.
|
||||||
|
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include "../thirdparty/lua/src/lauxlib.h"
|
||||||
|
|
||||||
|
#include "main.h"
|
||||||
|
#include "scriptWatch.h"
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
|
|
||||||
|
#define WATCH_ARM_MS 500 // How long a script runs before its events are pumped for it
|
||||||
|
#define WATCH_HOOK_COUNT 10000 // Instructions between the hook's looks at the events
|
||||||
|
#define WATCH_POLL_MS 100 // How often the watchdog looks at the script
|
||||||
|
#define WATCH_STOP_MS 3000 // How long a call runs before a pending quit stops it
|
||||||
|
|
||||||
|
#define WATCH_STOP_MESSAGE "Stopped: the script did not return, and Singe was asked to quit."
|
||||||
|
|
||||||
|
|
||||||
|
static void _enter(lua_State *L);
|
||||||
|
static void _hook(lua_State *L, lua_Debug *ar);
|
||||||
|
static void _leave(lua_State *L);
|
||||||
|
static void _stopIfAsked(lua_State *L);
|
||||||
|
static int _watchThread(void *data);
|
||||||
|
|
||||||
|
|
||||||
|
static bool _armed = false; // Under _lock: the hook is set on _state.
|
||||||
|
static int32_t _depth = 0; // Under _lock: engine-to-script calls under way.
|
||||||
|
static uint64_t _enteredMS = 0; // Under _lock: when the outermost call began.
|
||||||
|
static SDL_Mutex *_lock = NULL;
|
||||||
|
static SDL_AtomicInt _running;
|
||||||
|
static lua_State *_state = NULL; // Under _lock: the state the outermost call runs on.
|
||||||
|
static bool _stopping = false; // Main thread only: a stop was raised in this call.
|
||||||
|
static SDL_Thread *_thread = NULL;
|
||||||
|
|
||||||
|
|
||||||
|
static void _enter(lua_State *L) {
|
||||||
|
SDL_LockMutex(_lock);
|
||||||
|
if (_depth == 0) {
|
||||||
|
_state = L;
|
||||||
|
_enteredMS = SDL_GetTicks();
|
||||||
|
}
|
||||||
|
_depth++;
|
||||||
|
SDL_UnlockMutex(_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The count hook, on the main thread inside the script: the events are pumped so the window
|
||||||
|
// answers, and a pending quit stops the script. Once stopping it runs at every instruction and
|
||||||
|
// raises the stop again, so a script that catches it with a pcall of its own meets it at once.
|
||||||
|
static void _hook(lua_State *L, lua_Debug *ar) {
|
||||||
|
(void)ar;
|
||||||
|
if (_stopping) {
|
||||||
|
luaL_error(L, WATCH_STOP_MESSAGE);
|
||||||
|
}
|
||||||
|
SDL_PumpEvents();
|
||||||
|
_stopIfAsked(L);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void _leave(lua_State *L) {
|
||||||
|
SDL_LockMutex(_lock);
|
||||||
|
_depth--;
|
||||||
|
if (_depth == 0) {
|
||||||
|
if (_armed) {
|
||||||
|
lua_sethook(L, NULL, 0, 0);
|
||||||
|
_armed = false;
|
||||||
|
}
|
||||||
|
_state = NULL;
|
||||||
|
}
|
||||||
|
SDL_UnlockMutex(_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Raises the stop when Singe has been asked to quit (a signal, or the window closed) and the call
|
||||||
|
// has run WATCH_STOP_MS: a script stuck in a loop has run far longer, and one the engine calls
|
||||||
|
// on its way out (an onShutdown that saves) gets that long to finish. The close stays in the
|
||||||
|
// queue for the frame loop, which ends the game from it as it always has.
|
||||||
|
static void _stopIfAsked(lua_State *L) {
|
||||||
|
if (SDL_GetTicks() - _enteredMS < WATCH_STOP_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mainTerminated() || SDL_HasEvent(SDL_EVENT_QUIT) || SDL_HasEvent(SDL_EVENT_WINDOW_CLOSE_REQUESTED)) {
|
||||||
|
// Where it was, for whoever wrote it: a stop is not an error, but it is news.
|
||||||
|
luaL_traceback(L, L, WATCH_STOP_MESSAGE, 0);
|
||||||
|
utilSay("%s", lua_tostring(L, -1));
|
||||||
|
lua_pop(L, 1);
|
||||||
|
_stopping = true;
|
||||||
|
lua_sethook(L, _hook, LUA_MASKCOUNT, 1);
|
||||||
|
luaL_error(L, WATCH_STOP_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static int _watchThread(void *data) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
sigset_t all;
|
||||||
|
|
||||||
|
// Signals are the main thread's: one here would count apart from its twin there (timeout
|
||||||
|
// signals the process and its group), and a second SIGTERM ends Singe at once.
|
||||||
|
sigfillset(&all);
|
||||||
|
pthread_sigmask(SIG_BLOCK, &all, NULL);
|
||||||
|
#endif
|
||||||
|
(void)data;
|
||||||
|
while (SDL_GetAtomicInt(&_running)) {
|
||||||
|
SDL_Delay(WATCH_POLL_MS);
|
||||||
|
SDL_LockMutex(_lock);
|
||||||
|
if ((_depth > 0) && !_armed && (SDL_GetTicks() - _enteredMS >= WATCH_ARM_MS)) {
|
||||||
|
lua_sethook(_state, _hook, LUA_MASKCOUNT, WATCH_HOOK_COUNT);
|
||||||
|
_armed = true;
|
||||||
|
}
|
||||||
|
SDL_UnlockMutex(_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int32_t scriptWatchCall(lua_State *L, int32_t nargs, int32_t nresults, int32_t handler) {
|
||||||
|
int32_t status = LUA_OK;
|
||||||
|
int32_t i = 0;
|
||||||
|
|
||||||
|
// Without the watchdog (a tool run, or before the game starts) this is lua_pcall itself.
|
||||||
|
if (_lock == NULL) {
|
||||||
|
return lua_pcall(L, nargs, nresults, handler);
|
||||||
|
}
|
||||||
|
_enter(L);
|
||||||
|
status = lua_pcall(L, nargs, nresults, handler);
|
||||||
|
_leave(L);
|
||||||
|
if ((status != LUA_OK) && _stopping) {
|
||||||
|
lua_pop(L, 1);
|
||||||
|
for (i = 0; i < nresults; i++) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
}
|
||||||
|
status = LUA_OK;
|
||||||
|
}
|
||||||
|
if (_depth == 0) {
|
||||||
|
_stopping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void scriptWatchStart(void) {
|
||||||
|
_lock = SDL_CreateMutex();
|
||||||
|
if (_lock == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SDL_SetAtomicInt(&_running, 1);
|
||||||
|
_thread = SDL_CreateThread(_watchThread, "scriptWatch", NULL);
|
||||||
|
if (_thread == NULL) {
|
||||||
|
SDL_DestroyMutex(_lock);
|
||||||
|
_lock = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void scriptWatchStop(void) {
|
||||||
|
if (_lock == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SDL_SetAtomicInt(&_running, 0);
|
||||||
|
SDL_WaitThread(_thread, NULL);
|
||||||
|
_thread = NULL;
|
||||||
|
SDL_DestroyMutex(_lock);
|
||||||
|
_lock = NULL;
|
||||||
|
}
|
||||||
43
src/scriptWatch.h
Normal file
43
src/scriptWatch.h
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* Singe 3
|
||||||
|
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 3
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
||||||
|
* 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
#ifndef SCRIPTWATCH_H
|
||||||
|
#define SCRIPTWATCH_H
|
||||||
|
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "../thirdparty/lua/src/lua.h"
|
||||||
|
|
||||||
|
|
||||||
|
// Runs a script function as lua_pcall does, watched: a script that runs this long without
|
||||||
|
// returning to the engine has its events pumped for it, and stops when Singe is asked to quit (a
|
||||||
|
// signal, or the window closed). A stop reads as a return with its results nil, so the frame loop
|
||||||
|
// goes on to end the game as it would have; any other error is the caller's, as lua_pcall's is.
|
||||||
|
int32_t scriptWatchCall(lua_State *L, int32_t nargs, int32_t nresults, int32_t handler);
|
||||||
|
void scriptWatchStart(void);
|
||||||
|
void scriptWatchStop(void);
|
||||||
|
|
||||||
|
|
||||||
|
#endif // SCRIPTWATCH_H
|
||||||
256
src/singe.c
256
src/singe.c
|
|
@ -65,12 +65,15 @@ int luaopen_cjson(lua_State *L);
|
||||||
int luaopen_lpeg(lua_State *L);
|
int luaopen_lpeg(lua_State *L);
|
||||||
// There is no header for ssl.config binding. Make our own.
|
// There is no header for ssl.config binding. Make our own.
|
||||||
LSEC_API int luaopen_ssl_config(lua_State *L);
|
LSEC_API int luaopen_ssl_config(lua_State *L);
|
||||||
|
// Nor for reaching the SSL_CTX inside a LuaSec context, which netTrustSystem needs.
|
||||||
|
SSL_CTX *lsec_checkcontext(lua_State *L, int idx);
|
||||||
|
|
||||||
#include "collide.h"
|
#include "collide.h"
|
||||||
#include "decode.h"
|
#include "decode.h"
|
||||||
#include "main.h"
|
#include "main.h"
|
||||||
#include "midi.h"
|
#include "midi.h"
|
||||||
#include "midiIo.h"
|
#include "midiIo.h"
|
||||||
|
#include "netTrust.h"
|
||||||
#include "pack.h"
|
#include "pack.h"
|
||||||
#include "util.h"
|
#include "util.h"
|
||||||
#include "frameFile.h"
|
#include "frameFile.h"
|
||||||
|
|
@ -79,6 +82,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L);
|
||||||
#include "scene.h"
|
#include "scene.h"
|
||||||
#include "render.h"
|
#include "render.h"
|
||||||
#include "scheduler.h"
|
#include "scheduler.h"
|
||||||
|
#include "scriptWatch.h"
|
||||||
#include "stats.h"
|
#include "stats.h"
|
||||||
#include "hdr.h"
|
#include "hdr.h"
|
||||||
#include "ktx2.h"
|
#include "ktx2.h"
|
||||||
|
|
@ -773,6 +777,11 @@ typedef struct GlobalS {
|
||||||
FontT *fontList;
|
FontT *fontList;
|
||||||
FontT *fontCurrent;
|
FontT *fontCurrent;
|
||||||
MappingT controlMappings[INPUT_COUNT];
|
MappingT controlMappings[INPUT_COUNT];
|
||||||
|
uint32_t capabilitiesUsed; // CapabilityE bits this game has used so far
|
||||||
|
int32_t machineTier; // Measured performance class from machine.cfg, or TIER_UNMEASURED
|
||||||
|
bool sceneMeasuring; // sceneMeasure: each frame waits for the scene to finish and adds its time
|
||||||
|
uint64_t sceneMeasureNS;
|
||||||
|
uint64_t sceneMeasureFrames;
|
||||||
ConfigT *conf; // Local copy of command line options
|
ConfigT *conf; // Local copy of command line options
|
||||||
} GlobalT;
|
} GlobalT;
|
||||||
|
|
||||||
|
|
@ -817,6 +826,21 @@ typedef struct PerfCallsS {
|
||||||
|
|
||||||
|
|
||||||
static GlobalT _global;
|
static GlobalT _global;
|
||||||
|
|
||||||
|
// The capability names games.dat's REQUIRES and singeCapabilities use, one per CapabilityE bit.
|
||||||
|
typedef struct CapabilityNameS {
|
||||||
|
uint32_t bit;
|
||||||
|
const char *name;
|
||||||
|
} CapabilityNameT;
|
||||||
|
|
||||||
|
static const CapabilityNameT CAPABILITY_NAMES[] = {
|
||||||
|
{ CAPABILITY_SCENE, "SCENE" },
|
||||||
|
{ CAPABILITY_GUI, "GUI" },
|
||||||
|
{ CAPABILITY_SHADOWS, "SHADOWS" },
|
||||||
|
{ CAPABILITY_POST, "POST" },
|
||||||
|
{ CAPABILITY_SKIN, "SKIN" },
|
||||||
|
{ CAPABILITY_MORPH, "MORPH" }
|
||||||
|
};
|
||||||
static PerfT _perf;
|
static PerfT _perf;
|
||||||
static PerfCallsT _perfCalls;
|
static PerfCallsT _perfCalls;
|
||||||
static MIX_Track *_effectTracks[EFFECT_TRACKS];
|
static MIX_Track *_effectTracks[EFFECT_TRACKS];
|
||||||
|
|
@ -900,6 +924,8 @@ static void _bezelLoad(void);
|
||||||
static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout);
|
static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout);
|
||||||
static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const char *method);
|
static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const char *method);
|
||||||
static void _callLua(const char *func, const char *sig, ...);
|
static void _callLua(const char *func, const char *sig, ...);
|
||||||
|
static uint32_t _capabilitiesFromList(lua_State *L, int32_t index, const char *method);
|
||||||
|
static void _capabilityUsed(uint32_t bit);
|
||||||
static bool _chordChanged(ChordT *chord, bool down, int32_t scancode);
|
static bool _chordChanged(ChordT *chord, bool down, int32_t scancode);
|
||||||
static bool _chordsUpdate(bool down, int32_t scancode);
|
static bool _chordsUpdate(bool down, int32_t scancode);
|
||||||
static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2);
|
static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2);
|
||||||
|
|
@ -1383,6 +1409,7 @@ static int32_t apiNavPath(lua_State *L);
|
||||||
static int32_t apiNavRandomPoint(lua_State *L);
|
static int32_t apiNavRandomPoint(lua_State *L);
|
||||||
static int32_t apiNavRaycast(lua_State *L);
|
static int32_t apiNavRaycast(lua_State *L);
|
||||||
static int32_t apiNavSave(lua_State *L);
|
static int32_t apiNavSave(lua_State *L);
|
||||||
|
static int32_t apiNetTrustSystem(lua_State *L);
|
||||||
static int32_t apiNodeDelete(lua_State *L);
|
static int32_t apiNodeDelete(lua_State *L);
|
||||||
static int32_t apiNodeFind(lua_State *L);
|
static int32_t apiNodeFind(lua_State *L);
|
||||||
static int32_t apiNodeGetChildren(lua_State *L);
|
static int32_t apiNodeGetChildren(lua_State *L);
|
||||||
|
|
@ -1473,6 +1500,7 @@ static int32_t apiSaveSetAll(lua_State *L);
|
||||||
static int32_t apiSceneEnable(lua_State *L);
|
static int32_t apiSceneEnable(lua_State *L);
|
||||||
static int32_t apiSceneGetSize(lua_State *L);
|
static int32_t apiSceneGetSize(lua_State *L);
|
||||||
static int32_t apiSceneGetStats(lua_State *L);
|
static int32_t apiSceneGetStats(lua_State *L);
|
||||||
|
static int32_t apiSceneMeasure(lua_State *L);
|
||||||
static int32_t apiSceneProbeGui(lua_State *L);
|
static int32_t apiSceneProbeGui(lua_State *L);
|
||||||
static int32_t apiSceneProject(lua_State *L);
|
static int32_t apiSceneProject(lua_State *L);
|
||||||
static int32_t apiSceneSetAmbient(lua_State *L);
|
static int32_t apiSceneSetAmbient(lua_State *L);
|
||||||
|
|
@ -1502,12 +1530,14 @@ static int32_t apiSetOverlayFullAlpha(lua_State *L);
|
||||||
static int32_t apiSetOverlayLinearScale(lua_State *L);
|
static int32_t apiSetOverlayLinearScale(lua_State *L);
|
||||||
static int32_t apiSetOverlayOnTop(lua_State *L);
|
static int32_t apiSetOverlayOnTop(lua_State *L);
|
||||||
static int32_t apiSetOverlayOpacity(lua_State *L);
|
static int32_t apiSetOverlayOpacity(lua_State *L);
|
||||||
|
static int32_t apiSingeCapabilities(lua_State *L);
|
||||||
static int32_t apiSingeGetAudioCalibration(lua_State *L);
|
static int32_t apiSingeGetAudioCalibration(lua_State *L);
|
||||||
static int32_t apiSingeGetAudioDelay(lua_State *L);
|
static int32_t apiSingeGetAudioDelay(lua_State *L);
|
||||||
static int32_t apiSingeGetAudioLatency(lua_State *L);
|
static int32_t apiSingeGetAudioLatency(lua_State *L);
|
||||||
static int32_t apiSingeGetDataPath(lua_State *L);
|
static int32_t apiSingeGetDataPath(lua_State *L);
|
||||||
static int32_t apiSingeGetSystemInfo(lua_State *L);
|
static int32_t apiSingeGetSystemInfo(lua_State *L);
|
||||||
static int32_t apiSingeSaveGeometry(lua_State *L);
|
static int32_t apiSingeSaveGeometry(lua_State *L);
|
||||||
|
static int32_t apiSingeSaveTier(lua_State *L);
|
||||||
static int32_t apiSingeGetGameId(lua_State *L);
|
static int32_t apiSingeGetGameId(lua_State *L);
|
||||||
static int32_t apiSingeGetHeight(lua_State *L);
|
static int32_t apiSingeGetHeight(lua_State *L);
|
||||||
static int32_t apiSingeGetPauseFlag(lua_State *L);
|
static int32_t apiSingeGetPauseFlag(lua_State *L);
|
||||||
|
|
@ -2347,6 +2377,8 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const cha
|
||||||
c->videoFile = NULL;
|
c->videoFile = NULL;
|
||||||
free(c->dataDir);
|
free(c->dataDir);
|
||||||
c->dataDir = NULL;
|
c->dataDir = NULL;
|
||||||
|
c->requires = 0;
|
||||||
|
c->tier = 0;
|
||||||
|
|
||||||
// Update with data in the table on the top of the Lua stack.
|
// Update with data in the table on the top of the Lua stack.
|
||||||
lua_pushnil(L);
|
lua_pushnil(L);
|
||||||
|
|
@ -2436,6 +2468,16 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const cha
|
||||||
c->audioSuffix = strdup(valueString);
|
c->audioSuffix = strdup(valueString);
|
||||||
} else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) {
|
} else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) {
|
||||||
c->legacySpriteArgs = valueBoolean;
|
c->legacySpriteArgs = valueBoolean;
|
||||||
|
} else if (strcmp(confKey, "REQUIRES") == 0) {
|
||||||
|
if (!lua_istable(L, -1)) {
|
||||||
|
_confFail(L, method, "REQUIRES must be a list of capability names.");
|
||||||
|
}
|
||||||
|
c->requires = _capabilitiesFromList(L, -1, method);
|
||||||
|
} else if (strcmp(confKey, "TIER") == 0) {
|
||||||
|
if ((valueNumber < 0) || (valueNumber > TIER_MAX)) {
|
||||||
|
_confFail(L, method, "TIER must be 0 to %d.", TIER_MAX);
|
||||||
|
}
|
||||||
|
c->tier = (int32_t)valueNumber;
|
||||||
} else if (strcmp(confKey, "CANVAS_X") == 0) {
|
} else if (strcmp(confKey, "CANVAS_X") == 0) {
|
||||||
c->canvasWidth = (int32_t)valueNumber;
|
c->canvasWidth = (int32_t)valueNumber;
|
||||||
} else if (strcmp(confKey, "CANVAS_Y") == 0) {
|
} else if (strcmp(confKey, "CANVAS_Y") == 0) {
|
||||||
|
|
@ -2535,7 +2577,7 @@ static void _callLua(const char *func, const char *sig, ...) {
|
||||||
|
|
||||||
// Do the call. Script errors are fatal, like every other error in Singe.
|
// Do the call. Script errors are fatal, like every other error in Singe.
|
||||||
nres = (int32_t)strlen(sig);
|
nres = (int32_t)strlen(sig);
|
||||||
if (lua_pcall(_global.luaContext, narg, nres, handler) != 0) {
|
if (scriptWatchCall(_global.luaContext, narg, nres, handler) != 0) {
|
||||||
utilDie("Error executing function '%s': %s", func, lua_tostring(_global.luaContext, -1));
|
utilDie("Error executing function '%s': %s", func, lua_tostring(_global.luaContext, -1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2563,6 +2605,73 @@ static void _callLua(const char *func, const char *sig, ...) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A REQUIRES list from games.dat: capability names as strings, turned into CapabilityE bits. A
|
||||||
|
// name that is not a capability is an error, as any other bad games.dat value is.
|
||||||
|
static uint32_t _capabilitiesFromList(lua_State *L, int32_t index, const char *method) {
|
||||||
|
const char *name = NULL;
|
||||||
|
uint32_t bits = 0;
|
||||||
|
int32_t at = lua_absindex(L, index);
|
||||||
|
size_t x = 0;
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
|
lua_pushnil(L);
|
||||||
|
while (lua_next(L, at)) {
|
||||||
|
name = lua_tostring(L, -1);
|
||||||
|
found = false;
|
||||||
|
for (x = 0; (name != NULL) && (x < SDL_arraysize(CAPABILITY_NAMES)); x++) {
|
||||||
|
if (strcmp(name, CAPABILITY_NAMES[x].name) == 0) {
|
||||||
|
bits |= CAPABILITY_NAMES[x].bit;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
_confFail(L, method, "REQUIRES names %s, which is not a capability (SCENE, GUI, SHADOWS, POST, SKIN, MORPH).", (name != NULL) ? name : "something that is not a name");
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A game has used a capability. The first use is recorded in the game's data directory, where
|
||||||
|
// --pack reads it to write REQUIRES, and a game that did not declare it is told so in the trace:
|
||||||
|
// a hand-kept list is wrong within two releases, so the engine is what knows (PLAN.md section 57).
|
||||||
|
static void _capabilityUsed(uint32_t bit) {
|
||||||
|
char *path = NULL;
|
||||||
|
FILE *out = NULL;
|
||||||
|
size_t x = 0;
|
||||||
|
|
||||||
|
if ((_global.capabilitiesUsed & bit) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_global.capabilitiesUsed |= bit;
|
||||||
|
// The engine's own scripts (the menu, the service tools) are not games: nothing packs them.
|
||||||
|
if ((_global.conf->scriptFile != NULL) && (strncmp(_global.conf->scriptFile, VFS_ENGINE_DIRECTORY, strlen(VFS_ENGINE_DIRECTORY)) == 0) && ((_global.conf->scriptFile[strlen(VFS_ENGINE_DIRECTORY)] == '/') || (_global.conf->scriptFile[strlen(VFS_ENGINE_DIRECTORY)] == '\\'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (x = 0; x < SDL_arraysize(CAPABILITY_NAMES); x++) {
|
||||||
|
if ((CAPABILITY_NAMES[x].bit == bit) && ((_global.conf->requires & bit) == 0)) {
|
||||||
|
utilSay("Warning: this game uses %s, which its games.dat does not list in REQUIRES.", CAPABILITY_NAMES[x].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_global.conf->dataDir == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
path = utilCreateString("%s%s", _global.conf->dataDir, REQUIRES_RECORD_FILE);
|
||||||
|
out = fopen(path, "w");
|
||||||
|
if (out != NULL) {
|
||||||
|
for (x = 0; x < SDL_arraysize(CAPABILITY_NAMES); x++) {
|
||||||
|
if ((_global.capabilitiesUsed & CAPABILITY_NAMES[x].bit) != 0) {
|
||||||
|
fprintf(out, "%s\n", CAPABILITY_NAMES[x].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(out);
|
||||||
|
}
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Whether a press or a release changes a chord: a press that completes it, or the release of one of
|
// Whether a press or a release changes a chord: a press that completes it, or the release of one of
|
||||||
// its buttons while it is down. The chord is updated to match.
|
// its buttons while it is down. The chord is updated to match.
|
||||||
static bool _chordChanged(ChordT *chord, bool down, int32_t scancode) {
|
static bool _chordChanged(ChordT *chord, bool down, int32_t scancode) {
|
||||||
|
|
@ -3715,7 +3824,7 @@ static void _guiEvent(int32_t gui, int32_t document, const char *id, const char
|
||||||
lua_pushstring(L, id);
|
lua_pushstring(L, id);
|
||||||
lua_pushstring(L, event);
|
lua_pushstring(L, event);
|
||||||
lua_pushstring(L, value);
|
lua_pushstring(L, value);
|
||||||
if (lua_pcall(L, 5, 0, handler) != LUA_OK) {
|
if (scriptWatchCall(L, 5, 0, handler) != LUA_OK) {
|
||||||
utilDie("Error in GUI handler for \"%s\" %s: %s", id, event, lua_tostring(L, -1));
|
utilDie("Error in GUI handler for \"%s\" %s: %s", id, event, lua_tostring(L, -1));
|
||||||
}
|
}
|
||||||
lua_settop(L, handler - 1);
|
lua_settop(L, handler - 1);
|
||||||
|
|
@ -4548,7 +4657,12 @@ static void _loadMachineSettings(void) {
|
||||||
int32_t value = 0;
|
int32_t value = 0;
|
||||||
|
|
||||||
free(path);
|
free(path);
|
||||||
|
_global.machineTier = TIER_UNMEASURED;
|
||||||
if (data != NULL) {
|
if (data != NULL) {
|
||||||
|
at = strstr(data, "tier = ");
|
||||||
|
if ((at != NULL) && (sscanf(at, "tier = %d", &value) == 1) && (value >= 0) && (value <= TIER_MAX)) {
|
||||||
|
_global.machineTier = value;
|
||||||
|
}
|
||||||
at = strstr(data, "delay = ");
|
at = strstr(data, "delay = ");
|
||||||
if ((at != NULL) && (sscanf(at, "delay = %d", &value) == 1) && (value >= -VIDEO_AUDIO_DELAY_MAX) && (value <= VIDEO_AUDIO_DELAY_MAX)) {
|
if ((at != NULL) && (sscanf(at, "delay = %d", &value) == 1) && (value >= -VIDEO_AUDIO_DELAY_MAX) && (value <= VIDEO_AUDIO_DELAY_MAX)) {
|
||||||
delay = value;
|
delay = value;
|
||||||
|
|
@ -6829,6 +6943,7 @@ static void _registerApi(lua_State *L) {
|
||||||
lua_register(L, "navRandomPoint", apiNavRandomPoint); // 3.00
|
lua_register(L, "navRandomPoint", apiNavRandomPoint); // 3.00
|
||||||
lua_register(L, "navRaycast", apiNavRaycast); // 3.00
|
lua_register(L, "navRaycast", apiNavRaycast); // 3.00
|
||||||
lua_register(L, "navSave", apiNavSave); // 3.00
|
lua_register(L, "navSave", apiNavSave); // 3.00
|
||||||
|
lua_register(L, "netTrustSystem", apiNetTrustSystem); // 3.00
|
||||||
lua_register(L, "nodeDelete", apiNodeDelete); // 3.00
|
lua_register(L, "nodeDelete", apiNodeDelete); // 3.00
|
||||||
lua_register(L, "nodeFind", apiNodeFind); // 3.00
|
lua_register(L, "nodeFind", apiNodeFind); // 3.00
|
||||||
lua_register(L, "nodeGetChildren", apiNodeGetChildren); // 3.00
|
lua_register(L, "nodeGetChildren", apiNodeGetChildren); // 3.00
|
||||||
|
|
@ -6918,6 +7033,7 @@ static void _registerApi(lua_State *L) {
|
||||||
lua_register(L, "sceneEnable", apiSceneEnable); // 3.00
|
lua_register(L, "sceneEnable", apiSceneEnable); // 3.00
|
||||||
lua_register(L, "sceneGetSize", apiSceneGetSize); // 3.00
|
lua_register(L, "sceneGetSize", apiSceneGetSize); // 3.00
|
||||||
lua_register(L, "sceneGetStats", apiSceneGetStats); // 3.00
|
lua_register(L, "sceneGetStats", apiSceneGetStats); // 3.00
|
||||||
|
lua_register(L, "sceneMeasure", apiSceneMeasure); // 3.00
|
||||||
lua_register(L, "sceneProbeGui", apiSceneProbeGui); // 3.00
|
lua_register(L, "sceneProbeGui", apiSceneProbeGui); // 3.00
|
||||||
lua_register(L, "sceneProject", apiSceneProject); // 3.00
|
lua_register(L, "sceneProject", apiSceneProject); // 3.00
|
||||||
lua_register(L, "sceneSetAmbient", apiSceneSetAmbient); // 3.00
|
lua_register(L, "sceneSetAmbient", apiSceneSetAmbient); // 3.00
|
||||||
|
|
@ -6951,6 +7067,7 @@ static void _registerApi(lua_State *L) {
|
||||||
lua_register(L, "singeGetAudioCalibration", apiSingeGetAudioCalibration); // 3.00
|
lua_register(L, "singeGetAudioCalibration", apiSingeGetAudioCalibration); // 3.00
|
||||||
lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00
|
lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00
|
||||||
lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00
|
lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00
|
||||||
|
lua_register(L, "singeCapabilities", apiSingeCapabilities); // 3.00
|
||||||
lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00
|
lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00
|
||||||
lua_register(L, "singeGetGameId", apiSingeGetGameId); // 3.00
|
lua_register(L, "singeGetGameId", apiSingeGetGameId); // 3.00
|
||||||
lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx
|
lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx
|
||||||
|
|
@ -6965,6 +7082,7 @@ static void _registerApi(lua_State *L) {
|
||||||
lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00
|
lua_register(L, "singeRelaunch", apiSingeRelaunch); // 3.00
|
||||||
lua_register(L, "singeReload", apiSingeReload); // 3.00
|
lua_register(L, "singeReload", apiSingeReload); // 3.00
|
||||||
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00
|
||||||
|
lua_register(L, "singeSaveTier", apiSingeSaveTier); // 3.00
|
||||||
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
|
lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx
|
||||||
lua_register(L, "singeSetAudioCalibration", apiSingeSetAudioCalibration); // 3.00
|
lua_register(L, "singeSetAudioCalibration", apiSingeSetAudioCalibration); // 3.00
|
||||||
lua_register(L, "singeSetAudioDelay", apiSingeSetAudioDelay); // 3.00
|
lua_register(L, "singeSetAudioDelay", apiSingeSetAudioDelay); // 3.00
|
||||||
|
|
@ -7393,7 +7511,7 @@ static void _rmlEscape(char *out, size_t size, const char *text) {
|
||||||
static void _runScript(bool fatal) {
|
static void _runScript(bool fatal) {
|
||||||
_progTrace("Running %s", _global.conf->scriptFile);
|
_progTrace("Running %s", _global.conf->scriptFile);
|
||||||
lua_pushcfunction(_global.luaContext, _luaTraceback);
|
lua_pushcfunction(_global.luaContext, _luaTraceback);
|
||||||
if (_luaLoadFile(_global.luaContext, _global.conf->scriptFile, NULL, _global.conf->reload) || lua_pcall(_global.luaContext, 0, 0, -2)) {
|
if (_luaLoadFile(_global.luaContext, _global.conf->scriptFile, NULL, _global.conf->reload) || scriptWatchCall(_global.luaContext, 0, 0, -2)) {
|
||||||
if (fatal) {
|
if (fatal) {
|
||||||
utilDie("Error running script: %s", lua_tostring(_global.luaContext, -1));
|
utilDie("Error running script: %s", lua_tostring(_global.luaContext, -1));
|
||||||
}
|
}
|
||||||
|
|
@ -7492,6 +7610,9 @@ static void _saveMachineSettings(int32_t milliseconds) {
|
||||||
fprintf(out, "shiftx = %d\n", _global.videoShiftX);
|
fprintf(out, "shiftx = %d\n", _global.videoShiftX);
|
||||||
fprintf(out, "shifty = %d\n", _global.videoShiftY);
|
fprintf(out, "shifty = %d\n", _global.videoShiftY);
|
||||||
fprintf(out, "rotate = %d\n", _global.videoRotate);
|
fprintf(out, "rotate = %d\n", _global.videoRotate);
|
||||||
|
if (_global.machineTier != TIER_UNMEASURED) {
|
||||||
|
fprintf(out, "tier = %d\n", _global.machineTier);
|
||||||
|
}
|
||||||
fclose(out);
|
fclose(out);
|
||||||
} else {
|
} else {
|
||||||
utilSay("Unable to write %s", path);
|
utilSay("Unable to write %s", path);
|
||||||
|
|
@ -7642,7 +7763,7 @@ static void _scheduleFired(void *context, int32_t handle, SchedulerKindE kind, d
|
||||||
handler = lua_gettop(L) - 1;
|
handler = lua_gettop(L) - 1;
|
||||||
lua_pushnumber(L, value);
|
lua_pushnumber(L, value);
|
||||||
lua_pushnumber(L, progress);
|
lua_pushnumber(L, progress);
|
||||||
if (lua_pcall(L, 2, 0, handler) != LUA_OK) {
|
if (scriptWatchCall(L, 2, 0, handler) != LUA_OK) {
|
||||||
utilDie("Error in a tween: %s", lua_tostring(L, -1));
|
utilDie("Error in a tween: %s", lua_tostring(L, -1));
|
||||||
}
|
}
|
||||||
lua_remove(L, handler);
|
lua_remove(L, handler);
|
||||||
|
|
@ -7661,12 +7782,12 @@ static void _scheduleFired(void *context, int32_t handle, SchedulerKindE kind, d
|
||||||
if (kind == SCHEDULER_TWEEN) {
|
if (kind == SCHEDULER_TWEEN) {
|
||||||
lua_pushnumber(L, value);
|
lua_pushnumber(L, value);
|
||||||
lua_pushnumber(L, progress);
|
lua_pushnumber(L, progress);
|
||||||
if (lua_pcall(L, 2, 0, handler) != LUA_OK) {
|
if (scriptWatchCall(L, 2, 0, handler) != LUA_OK) {
|
||||||
utilDie("Error in a tween: %s", lua_tostring(L, -1));
|
utilDie("Error in a tween: %s", lua_tostring(L, -1));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lua_pushinteger(L, handle);
|
lua_pushinteger(L, handle);
|
||||||
if (lua_pcall(L, 1, 0, handler) != LUA_OK) {
|
if (scriptWatchCall(L, 1, 0, handler) != LUA_OK) {
|
||||||
utilDie("Error in a timer: %s", lua_tostring(L, -1));
|
utilDie("Error in a timer: %s", lua_tostring(L, -1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10912,6 +11033,7 @@ static int32_t apiGuiNew(lua_State *L) {
|
||||||
if ((width <= 0) || (height <= 0)) {
|
if ((width <= 0) || (height <= 0)) {
|
||||||
_luaDie(L, "guiNew", "The size must be positive.");
|
_luaDie(L, "guiNew", "The size must be positive.");
|
||||||
}
|
}
|
||||||
|
_capabilityUsed(CAPABILITY_GUI);
|
||||||
gui = guiNew(width, height);
|
gui = guiNew(width, height);
|
||||||
if (gui < 0) {
|
if (gui < 0) {
|
||||||
_luaDie(L, "guiNew", "No GUI available (is a GPU device present, and are fewer than %d in use?).", GUI_MAX_CONTEXTS);
|
_luaDie(L, "guiNew", "No GUI available (is a GPU device present, and are fewer than %d in use?).", GUI_MAX_CONTEXTS);
|
||||||
|
|
@ -11263,6 +11385,9 @@ static int32_t apiLightSetShadow(lua_State *L) {
|
||||||
|
|
||||||
_argCheck(L, "lightSetShadow", 2, 2);
|
_argCheck(L, "lightSetShadow", 2, 2);
|
||||||
node = _argNode(L, "lightSetShadow", 1);
|
node = _argNode(L, "lightSetShadow", 1);
|
||||||
|
if (_argBoolean(L, "lightSetShadow", 2)) {
|
||||||
|
_capabilityUsed(CAPABILITY_SHADOWS);
|
||||||
|
}
|
||||||
if (!lightSetShadow(node, _argBoolean(L, "lightSetShadow", 2))) {
|
if (!lightSetShadow(node, _argBoolean(L, "lightSetShadow", 2))) {
|
||||||
_luaDie(L, "lightSetShadow", "Node %d is not a light.", node);
|
_luaDie(L, "lightSetShadow", "Node %d is not a light.", node);
|
||||||
}
|
}
|
||||||
|
|
@ -12052,6 +12177,9 @@ static int32_t apiModelInstance(lua_State *L) {
|
||||||
if (lua_gettop(L) >= 2) {
|
if (lua_gettop(L) >= 2) {
|
||||||
parent = _argNode(L, "modelInstance", 2);
|
parent = _argNode(L, "modelInstance", 2);
|
||||||
}
|
}
|
||||||
|
if (modelIsSkinned(model)) {
|
||||||
|
_capabilityUsed(CAPABILITY_SKIN);
|
||||||
|
}
|
||||||
root = modelInstance(model, parent);
|
root = modelInstance(model, parent);
|
||||||
if (root < 0) {
|
if (root < 0) {
|
||||||
_luaDie(L, "modelInstance", "Unable to instance model %d.", model);
|
_luaDie(L, "modelInstance", "Unable to instance model %d.", model);
|
||||||
|
|
@ -12644,6 +12772,26 @@ static int32_t apiNavSave(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// lists, report = netTrustSystem(context) Adds the machine's own trusted roots, and the data
|
||||||
|
// directory's cacerts.local.pem when there is one, to a LuaSec client context that already holds
|
||||||
|
// the shipped bundle, so a connection verifies when either knows the server's root. lists is how
|
||||||
|
// many were added; report names what was consulted, one "what path" per line (PLAN.md section 61).
|
||||||
|
static int32_t apiNetTrustSystem(lua_State *L) {
|
||||||
|
SSL_CTX *context = NULL;
|
||||||
|
int32_t lists = 0;
|
||||||
|
char report[NET_TRUST_REPORT_SIZE];
|
||||||
|
|
||||||
|
_argCheck(L, "netTrustSystem", 1, 1);
|
||||||
|
context = lsec_checkcontext(L, 1);
|
||||||
|
lists = netTrustSystem(context, _global.conf->dataDir, report, sizeof(report));
|
||||||
|
_luaTrace(L, "netTrustSystem", "%d", lists);
|
||||||
|
lua_pushinteger(L, lists);
|
||||||
|
lua_pushstring(L, report);
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// nodeDelete(node): the node and everything under it
|
// nodeDelete(node): the node and everything under it
|
||||||
static int32_t apiNodeDelete(lua_State *L) {
|
static int32_t apiNodeDelete(lua_State *L) {
|
||||||
int32_t node;
|
int32_t node;
|
||||||
|
|
@ -12898,6 +13046,7 @@ static int32_t apiNodeSetMorph(lua_State *L) {
|
||||||
_argCheck(L, "nodeSetMorph", 3, 3);
|
_argCheck(L, "nodeSetMorph", 3, 3);
|
||||||
node = _argNode(L, "nodeSetMorph", 1);
|
node = _argNode(L, "nodeSetMorph", 1);
|
||||||
target = _argMorph(L, "nodeSetMorph", node, 2);
|
target = _argMorph(L, "nodeSetMorph", node, 2);
|
||||||
|
_capabilityUsed(CAPABILITY_MORPH);
|
||||||
nodeSetMorphWeight(node, target, (float)_argNumber(L, "nodeSetMorph", 3));
|
nodeSetMorphWeight(node, target, (float)_argNumber(L, "nodeSetMorph", 3));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -13935,6 +14084,9 @@ static int32_t apiSceneEnable(lua_State *L) {
|
||||||
if (!sceneEnable(enabled)) {
|
if (!sceneEnable(enabled)) {
|
||||||
_luaDie(L, "sceneEnable", "3D is not available on this machine.");
|
_luaDie(L, "sceneEnable", "3D is not available on this machine.");
|
||||||
}
|
}
|
||||||
|
if (enabled) {
|
||||||
|
_capabilityUsed(CAPABILITY_SCENE);
|
||||||
|
}
|
||||||
_global.refreshDisplay = true;
|
_global.refreshDisplay = true;
|
||||||
_luaTrace(L, "sceneEnable", "%s", enabled ? "on" : "off");
|
_luaTrace(L, "sceneEnable", "%s", enabled ? "on" : "off");
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -13969,6 +14121,35 @@ static int32_t apiSceneGetStats(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// sceneMeasure(true) starts timing the scene: every frame after it waits for the GPU to finish the
|
||||||
|
// scene and adds that time up, so what is measured is the work and not the display's pacing.
|
||||||
|
// sceneMeasure(false) stops and returns the milliseconds a frame averaged, or nil when no frame was
|
||||||
|
// drawn. The menu times its own backdrop this way to find the machine's tier (PLAN.md section 57);
|
||||||
|
// the wait costs frame rate, so it is for a few seconds, not for a whole game.
|
||||||
|
static int32_t apiSceneMeasure(lua_State *L) {
|
||||||
|
bool on = false;
|
||||||
|
|
||||||
|
_argCheck(L, "sceneMeasure", 1, 1);
|
||||||
|
on = _argBoolean(L, "sceneMeasure", 1);
|
||||||
|
if (on) {
|
||||||
|
_global.sceneMeasuring = true;
|
||||||
|
_global.sceneMeasureNS = 0;
|
||||||
|
_global.sceneMeasureFrames = 0;
|
||||||
|
_luaTrace(L, "sceneMeasure", "on");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
_global.sceneMeasuring = false;
|
||||||
|
if (_global.sceneMeasureFrames == 0) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
} else {
|
||||||
|
lua_pushnumber(L, (lua_Number)_global.sceneMeasureNS / (lua_Number)_global.sceneMeasureFrames / (lua_Number)SDL_NS_PER_MS);
|
||||||
|
}
|
||||||
|
_luaTrace(L, "sceneMeasure", "off after %" PRIu64 " frames", _global.sceneMeasureFrames);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// x, y, depth, inFront = sceneProject(wx, wy, wz): a world point in overlay coordinates
|
// x, y, depth, inFront = sceneProject(wx, wy, wz): a world point in overlay coordinates
|
||||||
// gui, x, y = sceneProbeGui(sx, sy): the GUI on the 3D surface under an overlay point and the pixel of it
|
// gui, x, y = sceneProbeGui(sx, sy): the GUI on the 3D surface under an overlay point and the pixel of it
|
||||||
// there (a light gun aimed at a screen in the scene); nil when the point is off every such surface
|
// there (a light gun aimed at a screen in the scene); nil when the point is off every such surface
|
||||||
|
|
@ -14042,6 +14223,9 @@ static int32_t apiSceneSetBackground(lua_State *L) {
|
||||||
// sceneSetBloom(threshold, strength): the glow of everything brighter than the threshold; strength 0 for none
|
// sceneSetBloom(threshold, strength): the glow of everything brighter than the threshold; strength 0 for none
|
||||||
static int32_t apiSceneSetBloom(lua_State *L) {
|
static int32_t apiSceneSetBloom(lua_State *L) {
|
||||||
_argCheck(L, "sceneSetBloom", 2, 2);
|
_argCheck(L, "sceneSetBloom", 2, 2);
|
||||||
|
if (_argNumber(L, "sceneSetBloom", 2) > 0.0) {
|
||||||
|
_capabilityUsed(CAPABILITY_POST);
|
||||||
|
}
|
||||||
sceneSetBloom((float)_argNumber(L, "sceneSetBloom", 1), (float)_argNumber(L, "sceneSetBloom", 2));
|
sceneSetBloom((float)_argNumber(L, "sceneSetBloom", 1), (float)_argNumber(L, "sceneSetBloom", 2));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -14344,6 +14528,38 @@ static int32_t apiSetOverlayOpacity(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// capabilities = singeCapabilities() What this machine can run: a table of the capability names
|
||||||
|
// (SCENE, GUI, SHADOWS, POST, SKIN, MORPH) each true or false, and tier, the measured performance
|
||||||
|
// class from 0 to 3, or nil before the menu has measured it. A script asks so it can degrade itself
|
||||||
|
// rather than be refused (PLAN.md section 57).
|
||||||
|
static int32_t apiSingeCapabilities(lua_State *L) {
|
||||||
|
bool scene = sceneAvailable();
|
||||||
|
|
||||||
|
_argCheck(L, "singeCapabilities", 0, 0);
|
||||||
|
lua_newtable(L);
|
||||||
|
lua_pushboolean(L, scene);
|
||||||
|
lua_setfield(L, -2, "SCENE");
|
||||||
|
lua_pushboolean(L, guiAvailable());
|
||||||
|
lua_setfield(L, -2, "GUI");
|
||||||
|
lua_pushboolean(L, sceneSupportsShadows());
|
||||||
|
lua_setfield(L, -2, "SHADOWS");
|
||||||
|
lua_pushboolean(L, sceneSupportsPost());
|
||||||
|
lua_setfield(L, -2, "POST");
|
||||||
|
// Skinning and morph targets run in the scene's own shaders: where there is a scene, there are both.
|
||||||
|
lua_pushboolean(L, scene);
|
||||||
|
lua_setfield(L, -2, "SKIN");
|
||||||
|
lua_pushboolean(L, scene);
|
||||||
|
lua_setfield(L, -2, "MORPH");
|
||||||
|
if (_global.machineTier != TIER_UNMEASURED) {
|
||||||
|
lua_pushinteger(L, _global.machineTier);
|
||||||
|
lua_setfield(L, -2, "tier");
|
||||||
|
}
|
||||||
|
_luaTrace(L, "singeCapabilities", "tier %d", _global.machineTier);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// milliseconds = singeGetAudioCalibration() The per-machine value saved by the menu's calibration screen.
|
// milliseconds = singeGetAudioCalibration() The per-machine value saved by the menu's calibration screen.
|
||||||
static int32_t apiSingeGetAudioCalibration(lua_State *L) {
|
static int32_t apiSingeGetAudioCalibration(lua_State *L) {
|
||||||
int32_t value = videoGetAudioCalibration();
|
int32_t value = videoGetAudioCalibration();
|
||||||
|
|
@ -14428,6 +14644,24 @@ static int32_t apiSingeSaveGeometry(lua_State *L) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// singeSaveTier(tier) Keeps this machine's measured performance class, 0 to 3, beside the audio
|
||||||
|
// delay and the geometry. The menu measures it from its own backdrop; nothing asks the player.
|
||||||
|
static int32_t apiSingeSaveTier(lua_State *L) {
|
||||||
|
int32_t tier = 0;
|
||||||
|
|
||||||
|
_argCheck(L, "singeSaveTier", 1, 1);
|
||||||
|
tier = (int32_t)_argInteger(L, "singeSaveTier", 1);
|
||||||
|
if ((tier < 0) || (tier > TIER_MAX)) {
|
||||||
|
_luaDie(L, "singeSaveTier", "The tier must be 0 to %d.", TIER_MAX);
|
||||||
|
}
|
||||||
|
_global.machineTier = tier;
|
||||||
|
_saveMachineSettings(videoGetAudioCalibration());
|
||||||
|
_luaTrace(L, "singeSaveTier", "%d", tier);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// path = singeGetDataPath()
|
// path = singeGetDataPath()
|
||||||
static int32_t apiSingeGetDataPath(lua_State *L) {
|
static int32_t apiSingeGetDataPath(lua_State *L) {
|
||||||
_luaTrace(L, "singeGetDataPath", "%s", _global.conf->dataDir);
|
_luaTrace(L, "singeGetDataPath", "%s", _global.conf->dataDir);
|
||||||
|
|
@ -14532,7 +14766,7 @@ static int32_t apiSingePack(lua_State *L) {
|
||||||
_argCheck(L, "singePack", 2, 2);
|
_argCheck(L, "singePack", 2, 2);
|
||||||
directory = _argString(L, "singePack", 1);
|
directory = _argString(L, "singePack", 1);
|
||||||
database = _argString(L, "singePack", 2);
|
database = _argString(L, "singePack", 2);
|
||||||
ok = packGame(directory, database);
|
ok = packGame(directory, database, _global.conf->dataDirBase);
|
||||||
_luaTrace(L, "singePack", "%s %s %s", directory, database, ok ? "ok" : "failed");
|
_luaTrace(L, "singePack", "%s %s %s", directory, database, ok ? "ok" : "failed");
|
||||||
lua_pushboolean(L, ok);
|
lua_pushboolean(L, ok);
|
||||||
|
|
||||||
|
|
@ -17624,6 +17858,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
|
||||||
// Start script
|
// Start script
|
||||||
memset(&_perf, 0, sizeof(_perf));
|
memset(&_perf, 0, sizeof(_perf));
|
||||||
_perf.scriptStartNS = SDL_GetTicksNS();
|
_perf.scriptStartNS = SDL_GetTicksNS();
|
||||||
|
scriptWatchStart();
|
||||||
_runScript(true);
|
_runScript(true);
|
||||||
_perf.windowStartNS = SDL_GetTicksNS();
|
_perf.windowStartNS = SDL_GetTicksNS();
|
||||||
|
|
||||||
|
|
@ -18040,6 +18275,12 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
|
||||||
sceneTexture = sceneRender();
|
sceneTexture = sceneRender();
|
||||||
_perfSync();
|
_perfSync();
|
||||||
_perf.sceneNS += SDL_GetTicksNS() - stageStartNS;
|
_perf.sceneNS += SDL_GetTicksNS() - stageStartNS;
|
||||||
|
if (_global.sceneMeasuring && (sceneTexture != NULL)) {
|
||||||
|
// The GPU's share as well: a frame paced by the display says nothing about the machine.
|
||||||
|
renderFinish(_global.device);
|
||||||
|
_global.sceneMeasureNS += SDL_GetTicksNS() - stageStartNS;
|
||||||
|
_global.sceneMeasureFrames++;
|
||||||
|
}
|
||||||
_updateSounds();
|
_updateSounds();
|
||||||
if (sceneTexture != NULL) {
|
if (sceneTexture != NULL) {
|
||||||
SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &_global.videoRect);
|
SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &_global.videoRect);
|
||||||
|
|
@ -18172,6 +18413,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
|
||||||
|
|
||||||
// Stop Lua
|
// Stop Lua
|
||||||
_progTrace("Stopping Lua");
|
_progTrace("Stopping Lua");
|
||||||
|
scriptWatchStop();
|
||||||
guiDetachLua();
|
guiDetachLua();
|
||||||
lua_close(_global.luaContext);
|
lua_close(_global.luaContext);
|
||||||
_global.luaContext = NULL;
|
_global.luaContext = NULL;
|
||||||
|
|
|
||||||
19
src/singe.h
19
src/singe.h
|
|
@ -76,6 +76,23 @@ typedef enum GivenE {
|
||||||
GIVEN_ROTATE = 1 << 11
|
GIVEN_ROTATE = 1 << 11
|
||||||
} GivenE;
|
} GivenE;
|
||||||
|
|
||||||
|
// What a game can need that not every machine has (PLAN.md section 57). Missing one means the game
|
||||||
|
// will not start; a game's games.dat names the ones it uses in REQUIRES, and no REQUIRES means 2D.
|
||||||
|
typedef enum CapabilityE {
|
||||||
|
CAPABILITY_SCENE = 1 << 0, // The 3D scene
|
||||||
|
CAPABILITY_GUI = 1 << 1, // GUI documents
|
||||||
|
CAPABILITY_SHADOWS = 1 << 2, // Shadow maps
|
||||||
|
CAPABILITY_POST = 1 << 3, // Float render targets: bloom and the other post effects
|
||||||
|
CAPABILITY_SKIN = 1 << 4, // Skinned models
|
||||||
|
CAPABILITY_MORPH = 1 << 5 // Morph targets
|
||||||
|
} CapabilityE;
|
||||||
|
|
||||||
|
// The measured performance class of this machine (machine.cfg), before it has been measured.
|
||||||
|
#define TIER_UNMEASURED (-1)
|
||||||
|
#define TIER_MAX 3
|
||||||
|
// The capabilities a game has used, one name a line, in its data directory: --pack reads it.
|
||||||
|
#define REQUIRES_RECORD_FILE "requires.txt"
|
||||||
|
|
||||||
typedef struct ConfigS {
|
typedef struct ConfigS {
|
||||||
char *videoFile;
|
char *videoFile;
|
||||||
char *scriptFile;
|
char *scriptFile;
|
||||||
|
|
@ -135,6 +152,8 @@ typedef struct ConfigS {
|
||||||
int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down
|
int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down
|
||||||
int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees
|
int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees
|
||||||
char *gameId; // GAME_ID from games.dat: the master service's stable id for this game
|
char *gameId; // GAME_ID from games.dat: the master service's stable id for this game
|
||||||
|
uint32_t requires; // REQUIRES from games.dat: the CapabilityE bits the game declares
|
||||||
|
int32_t tier; // TIER from games.dat: the performance class the game wants, 0 for any
|
||||||
int32_t xResolution;
|
int32_t xResolution;
|
||||||
int32_t yResolution;
|
int32_t yResolution;
|
||||||
int32_t canvasWidth; // World size when there is no disc
|
int32_t canvasWidth; // World size when there is no disc
|
||||||
|
|
|
||||||
36
src/util.c
36
src/util.c
|
|
@ -48,6 +48,9 @@
|
||||||
#include "util.h"
|
#include "util.h"
|
||||||
|
|
||||||
|
|
||||||
|
#define PRINT_LINE_BUFFER 1024 // A log line this long or shorter is formatted without the heap
|
||||||
|
|
||||||
|
|
||||||
static bool _consoleEnabled = true;
|
static bool _consoleEnabled = true;
|
||||||
static bool _outputHappened = false;
|
static bool _outputHappened = false;
|
||||||
static FILE *_utilTraceFile = NULL;
|
static FILE *_utilTraceFile = NULL;
|
||||||
|
|
@ -81,15 +84,42 @@ static bool _ensureDirectory(const char *path, const mode_t mode) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The line and its newline go out in one call: stdio holds the stream's lock for a call, so a
|
||||||
|
// line another thread prints (the video thread's, say) lands before or after it, never inside it.
|
||||||
static void _printLine(FILE *stream, const char *fmt, va_list args) {
|
static void _printLine(FILE *stream, const char *fmt, va_list args) {
|
||||||
// No caller passes a null format, but glibc's fortified vfprintf is inlined here and the
|
char buffer[PRINT_LINE_BUFFER];
|
||||||
|
char *line = buffer;
|
||||||
|
va_list again;
|
||||||
|
int length = 0;
|
||||||
|
|
||||||
|
// No caller passes a null format, but glibc's fortified vsnprintf is inlined here and the
|
||||||
// compiler cannot see that, so say it plainly rather than carry the warning.
|
// compiler cannot see that, so say it plainly rather than carry the warning.
|
||||||
if (fmt == NULL) {
|
if (fmt == NULL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vfprintf(stream, fmt, args);
|
va_copy(again, args);
|
||||||
fputc('\n', stream);
|
length = vsnprintf(buffer, sizeof(buffer) - 1, fmt, args);
|
||||||
|
if (length < 0) {
|
||||||
|
va_end(again);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Room for the newline too; a longer line is formatted again into one of its own size.
|
||||||
|
if ((size_t)length >= sizeof(buffer) - 1) {
|
||||||
|
line = malloc((size_t)length + 2);
|
||||||
|
if (line == NULL) {
|
||||||
|
va_end(again);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vsnprintf(line, (size_t)length + 1, fmt, again);
|
||||||
|
}
|
||||||
|
va_end(again);
|
||||||
|
line[length] = '\n';
|
||||||
|
line[length + 1] = 0;
|
||||||
|
fputs(line, stream);
|
||||||
fflush(stream);
|
fflush(stream);
|
||||||
|
if (line != buffer) {
|
||||||
|
free(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,13 +97,18 @@ case "$family" in
|
||||||
# An American Laser Games title from the Hypseus library: the same layout as the
|
# An American Laser Games title from the Hypseus library: the same layout as the
|
||||||
# map-mode ones, its own drivers.
|
# map-mode ones, its own drivers.
|
||||||
library=${SINGE_LIBRARY:-$HOME/claude/singetest/hypseus}
|
library=${SINGE_LIBRARY:-$HOME/claude/singetest/hypseus}
|
||||||
if [[ -d "$library/$game/Script" ]]; then
|
# The Hypseus layout wins when singe/ holds the description, even beside a Script/ of the
|
||||||
|
# game's own files (the one-player Johnny Rock HD edition keeps both).
|
||||||
|
hypseusDesc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
|
||||||
|
if [[ -z "$hypseusDesc" && -d "$library/$game/Script" ]]; then
|
||||||
# The library's own copy (Script/, Cfg/, Video/ with the framefile beside the video;
|
# The library's own copy (Script/, Cfg/, Video/ with the framefile beside the video;
|
||||||
# Platoon keeps its Video/ under Script/): the script is the description's namesake,
|
# Platoon keeps its Video/ under Script/): the script is the description's namesake,
|
||||||
# the video the framefile in Video/.
|
# the video the framefile in Video/.
|
||||||
desc=$(ls "$library/$game/Script"/*.forge 2>/dev/null | head -1)
|
desc=$(ls "$library/$game/Script"/*.forge 2>/dev/null | head -1)
|
||||||
export PORT_SCRIPT=Script/$(basename "${desc%.forge}").singe
|
export PORT_SCRIPT=Script/$(basename "${desc%.forge}").singe
|
||||||
video=$(ls "$library/$game/Video"/*.txt "$library/$game/Script/Video"/*.txt 2>/dev/null | head -1)
|
video=$(ls "$library/$game/Video"/*.txt "$library/$game/Script/Video"/*.txt 2>/dev/null | head -1)
|
||||||
|
# No framefile: the video itself, when the title keeps one there.
|
||||||
|
[[ -n "$video" ]] || video=$(ls "$library/$game/Video"/*.m2v "$library/$game/Video"/*.mp4 "$library/$game/Video"/*.mkv "$library/$game/Script/Video"/*.m2v 2>/dev/null | head -1)
|
||||||
else
|
else
|
||||||
desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
|
desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
|
||||||
if [[ -n "$desc" ]]; then
|
if [[ -n "$desc" ]]; then
|
||||||
|
|
@ -141,19 +146,35 @@ case "$family" in
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
if [[ -z "${video:-}" ]]; then
|
||||||
|
echo "compare.sh: no video for $family $game in the library; nothing to compare" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
export SINGE_LIBRARY=$library PORT_GAME=$game SDL_VIDEODRIVER=${SDL_VIDEODRIVER:-offscreen} SDL_AUDIO_DRIVER=dummy
|
export SINGE_LIBRARY=$library PORT_GAME=$game SDL_VIDEODRIVER=${SDL_VIDEODRIVER:-offscreen} SDL_AUDIO_DRIVER=dummy
|
||||||
trace() {
|
trace() {
|
||||||
local name=$1 script=$2 work
|
local name=$1 script=$2 work
|
||||||
work=$(mktemp -d "$run/$name.XXXXXX")
|
work=$(mktemp -d "$run/$name.XXXXXX")
|
||||||
( cd "$run" && timeout 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run.log" 2>&1
|
local pass passes=1
|
||||||
grep -a '^TRACE' "$work/run.log" | grep -v 'state 2 from' > "$work/trace"
|
# PORT_TWICE=1 runs each side twice in the same data directory (the original's own files are
|
||||||
|
# put back only after both), so the second run shows what the first one saved: the
|
||||||
|
# Persistence row of FORGE.md 14.18. The trace is both runs', the drive told which it is.
|
||||||
|
[[ "${PORT_TWICE:-}" == "1" ]] && passes=2
|
||||||
|
: > "$work/trace"
|
||||||
|
for (( pass = 1; pass <= passes; pass++ )); do
|
||||||
|
# An hour a run; a run spinning in Lua never sees the quit SIGTERM asks for, so -k kills it.
|
||||||
|
( cd "$run" && PORT_PASS=$pass timeout -k 30 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run$pass.log" 2>&1
|
||||||
|
(( pass > 1 )) && echo "TRACE run $pass" >> "$work/trace"
|
||||||
|
grep -a '^TRACE' "$work/run$pass.log" | grep -v 'state 2 from' >> "$work/trace"
|
||||||
|
done
|
||||||
|
cp "$work/run1.log" "$work/run.log"
|
||||||
grep -a 'rror\|Author:' "$work/run.log" | head -3 >&2
|
grep -a 'rror\|Author:' "$work/run.log" | head -3 >&2
|
||||||
# The screenshots a drive took (PORT_SHOTS=1) land in the run's data directory; with
|
# The screenshots a drive took (PORT_SHOTS=1) land in the run's data directory; with
|
||||||
# SINGE_SHOTS set they are gathered there, named for the run.
|
# SINGE_SHOTS set they are gathered there, named for the run.
|
||||||
if [[ -n "${SINGE_SHOTS:-}" ]]; then
|
if [[ -n "${SINGE_SHOTS:-}" ]]; then
|
||||||
mkdir -p "$SINGE_SHOTS"
|
mkdir -p "$SINGE_SHOTS"
|
||||||
for png in "$work"/*.png; do
|
# The engine saves under the game's own folder in the data directory, wherever that is.
|
||||||
[[ -f "$png" ]] && cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")"
|
find "$work" -name "*.png" -print0 | while IFS= read -r -d '' png; do
|
||||||
|
cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
echo "$work/trace"
|
echo "$work/trace"
|
||||||
|
|
@ -161,6 +182,10 @@ trace() {
|
||||||
# The originals autosave into their own Cfg folder as they play, which would give the port's
|
# The originals autosave into their own Cfg folder as they play, which would give the port's
|
||||||
# snapshot and the next run different saves to read: the folder is put back as it was.
|
# snapshot and the next run different saves to read: the folder is put back as it was.
|
||||||
cfgDir=""
|
cfgDir=""
|
||||||
|
# One comparison of a title at a time: two would each keep the original's files, then put them
|
||||||
|
# back over each other. A second waits for the first.
|
||||||
|
exec 9> "${TMPDIR:-/tmp}/compare-$family-${game//\//_}.lock"
|
||||||
|
flock 9
|
||||||
if [[ -n "${PORT_SCRIPT:-}" ]]; then
|
if [[ -n "${PORT_SCRIPT:-}" ]]; then
|
||||||
cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg
|
cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg
|
||||||
[[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg
|
[[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg
|
||||||
|
|
@ -170,10 +195,11 @@ if [[ -n "$cfgDir" ]]; then
|
||||||
cfgKeep=$(mktemp -d "$run/cfg.XXXXXX")
|
cfgKeep=$(mktemp -d "$run/cfg.XXXXXX")
|
||||||
cp -r "$cfgDir/." "$cfgKeep/"
|
cp -r "$cfgDir/." "$cfgKeep/"
|
||||||
fi
|
fi
|
||||||
# An American Laser Games title keeps its dips and board in a .cfg beside its script, which
|
# A title that keeps its dips and board in a .cfg beside its script (the gun games, the map-mode
|
||||||
# the original writes at every board: kept and put back the same way.
|
# games) has the original write it at every board: kept and put back the same way, whatever the
|
||||||
|
# family, or a run leaves its scores in the library for the next description to snapshot.
|
||||||
cfgFiles=""
|
cfgFiles=""
|
||||||
if [[ "$family" == "alg" ]]; then
|
if [[ -n "${PORT_SCRIPT:-}" ]]; then
|
||||||
cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null)
|
cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null)
|
||||||
if [[ -n "$cfgFiles" ]]; then
|
if [[ -n "$cfgFiles" ]]; then
|
||||||
cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX")
|
cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
-- The trace both print: every disc seek, and every change of the framework's screen, step,
|
-- The trace both print: every disc seek, and every change of the framework's screen, step,
|
||||||
-- level, scene, move, score, and lives, in the framework's numbers.
|
-- level, scene, move, score, and lives, in the framework's numbers.
|
||||||
KARIS_SEED = 20260914
|
KARIS_SEED = 20260914
|
||||||
|
AUTHOR_SEED = KARIS_SEED -- The port's runtime seeds its generator from this, as the original's os.time does.
|
||||||
|
|
||||||
local lastLine = nil
|
local lastLine = nil
|
||||||
local pressed = nil -- The switch held down this frame, released the next.
|
local pressed = nil -- The switch held down this frame, released the next.
|
||||||
|
|
@ -23,6 +24,9 @@ local tried = {} -- Moves met before, by "level/scene/move": a re
|
||||||
-- met again after a death is answered right. PORT_WRONG_EVERY and PORT_MISS_EVERY set the
|
-- met again after a death is answered right. PORT_WRONG_EVERY and PORT_MISS_EVERY set the
|
||||||
-- two counts, for a run that goes deeper into a game with few lives.
|
-- two counts, for a run that goes deeper into a game with few lives.
|
||||||
local WRONG_EVERY = tonumber(os.getenv("PORT_WRONG_EVERY")) or 5
|
local WRONG_EVERY = tonumber(os.getenv("PORT_WRONG_EVERY")) or 5
|
||||||
|
-- PORT_ATTRACT=N lets the attract loop run N frames before the coin and START, so its own screens
|
||||||
|
-- (the rankings, the best percent) are played and traced too.
|
||||||
|
local ATTRACT = tonumber(os.getenv("PORT_ATTRACT")) or 0
|
||||||
local MISS_EVERY = tonumber(os.getenv("PORT_MISS_EVERY")) or 7
|
local MISS_EVERY = tonumber(os.getenv("PORT_MISS_EVERY")) or 7
|
||||||
local function wrongMove(view)
|
local function wrongMove(view)
|
||||||
return (view.move % WRONG_EVERY == 0) and not view.retry
|
return (view.move % WRONG_EVERY == 0) and not view.retry
|
||||||
|
|
@ -35,6 +39,25 @@ local plan = nil -- Kimmy: the presses planned for the window bei
|
||||||
local held = {} -- Kimmy: the switches the plan holds down, by switch.
|
local held = {} -- Kimmy: the switches the plan holds down, by switch.
|
||||||
local menuAt = 0 -- Kimmy: the frame a menu was last answered on.
|
local menuAt = 0 -- Kimmy: the frame a menu was last answered on.
|
||||||
KARIS_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
|
KARIS_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
|
||||||
|
-- A map-mode game's pair kinds, by the name of the game's constant for each: what is held with
|
||||||
|
-- what. A game that does not define a constant (one with no diagonals) simply has no such pair.
|
||||||
|
KARIS_PAIR_KINDS = { { "ACTUP", 5, 1 }, { "ACTDOWN", 5, 2 }, { "ACTLEFT", 5, 3 }, { "ACTRIGHT", 5, 4 }, { "UPLEFT", 1, 3 }, { "UPRIGHT", 1, 4 }, { "DOWNLEFT", 2, 3 }, { "DOWNRIGHT", 2, 4 } }
|
||||||
|
|
||||||
|
|
||||||
|
-- The pair kinds a game defines, keyed by its own numbers; constants maps a name to the number.
|
||||||
|
function karisPairs(constants)
|
||||||
|
local found = {}
|
||||||
|
|
||||||
|
for _, kind in ipairs(KARIS_PAIR_KINDS) do
|
||||||
|
local number = constants[kind[1]]
|
||||||
|
|
||||||
|
if number ~= nil then
|
||||||
|
found[number] = { kind[2], kind[3] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
|
||||||
-- The framework's move numbers as switches; Kimmy's diagonals are two of them.
|
-- The framework's move numbers as switches; Kimmy's diagonals are two of them.
|
||||||
local SWITCH_OF = { [1] = SWITCH_UP, [2] = SWITCH_DOWN, [3] = SWITCH_LEFT, [4] = SWITCH_RIGHT, [5] = SWITCH_BUTTON1, [6] = SWITCH_BUTTON2, [7] = SWITCH_BUTTON3 }
|
local SWITCH_OF = { [1] = SWITCH_UP, [2] = SWITCH_DOWN, [3] = SWITCH_LEFT, [4] = SWITCH_RIGHT, [5] = SWITCH_BUTTON1, [6] = SWITCH_BUTTON2, [7] = SWITCH_BUTTON3 }
|
||||||
|
|
@ -384,6 +407,235 @@ local function followPlan(frame)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_SERVICE=1 walks the service screens from the attract loop before the game: SERVICE, then
|
||||||
|
-- the options' first row on to the graphics screen, its rows each changed once (RIGHT) and
|
||||||
|
-- "Default", round to its first row and on to the performance screen, the same there, on to the
|
||||||
|
-- options, the same, and EXIT; a press every WALK_EVERY frames, clear of the screens' quarter
|
||||||
|
-- second between moves. While a screen shows, the trace carries its row and the dips.
|
||||||
|
local walkService = (os.getenv("PORT_SERVICE") == "1")
|
||||||
|
local WALK_EVERY = 10
|
||||||
|
local WALK = { SWITCH_SERVICE }
|
||||||
|
local walkAt = nil
|
||||||
|
local walkEvents = {}
|
||||||
|
|
||||||
|
|
||||||
|
-- A screen's rows walked from its first: each one's value changed, the second from last
|
||||||
|
-- ("Default") used, and, unless it is the last screen, round to the first row and on.
|
||||||
|
local function walkScreen(rows, leave)
|
||||||
|
WALK[#WALK + 1] = SWITCH_DOWN
|
||||||
|
for _ = 2, rows - 2 do
|
||||||
|
WALK[#WALK + 1] = SWITCH_RIGHT
|
||||||
|
WALK[#WALK + 1] = SWITCH_DOWN
|
||||||
|
end
|
||||||
|
WALK[#WALK + 1] = SWITCH_RIGHT -- Default
|
||||||
|
WALK[#WALK + 1] = SWITCH_DOWN
|
||||||
|
if leave then
|
||||||
|
WALK[#WALK + 1] = SWITCH_RIGHT -- EXIT
|
||||||
|
else
|
||||||
|
WALK[#WALK + 1] = SWITCH_DOWN -- Round to the first row ...
|
||||||
|
WALK[#WALK + 1] = SWITCH_RIGHT -- ... and on to the next screen.
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
WALK[#WALK + 1] = SWITCH_RIGHT -- The options' first row, on to the graphics screen.
|
||||||
|
walkScreen(15, false) -- The graphics screen, on to the performance screen.
|
||||||
|
walkScreen(10, false) -- The performance screen, on to the options.
|
||||||
|
walkScreen(15, true) -- The options, and EXIT.
|
||||||
|
local SERVICE_DIPS = { "dip_GameType", "dip_Diffshow", "dip_PlayStyle", "dip_StartLevel", "dip_StartScene", "dip_Rewind", "dip_CoinsPerCredit", "dip_LivesPerCredit", "dip_LimitContinue", "dip_ShowAction", "dip_Hints", "dip_MashtoRun",
|
||||||
|
"dip_Extravid", "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop", "ScorePos", "LivPos", "CoinPos", "fontType", "fontSize", "mycolor1", "mycolor2",
|
||||||
|
"dip_Model", "dip_Res", "dip_Display", "dip_Scanlines", "dip_ScType", "dip_LivType", "dip_FontQ" }
|
||||||
|
|
||||||
|
|
||||||
|
-- The options screen's row and dips, or the save or exit screen's row and choices, from a reader of
|
||||||
|
-- the loop's variables, while it shows.
|
||||||
|
function karisServiceState(screen, read)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
if (screen == 108) or (screen == 112) then
|
||||||
|
return "row " .. tostring(read("optSel")) .. " Loadsave " .. tostring(read("dip_Loadsave")) .. " Slot " .. tostring(read("dip_Slot")) .. " InPlayExit " .. tostring(read("bInPlayExit"))
|
||||||
|
end
|
||||||
|
if (screen ~= 104) and (screen ~= 113) and (screen ~= 114) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
parts[1] = "rows " .. tostring(read("optSel")) .. " " .. tostring(read("optSelG")) .. " " .. tostring(read("optSelP"))
|
||||||
|
for _, name in ipairs(SERVICE_DIPS) do
|
||||||
|
parts[#parts + 1] = name:sub(5) .. " " .. tostring(read(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(parts, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Switches tapped, as a walk's events: each a press, then its release.
|
||||||
|
local function tapsOf(switches)
|
||||||
|
local events = {}
|
||||||
|
|
||||||
|
for _, switch in ipairs(switches) do
|
||||||
|
events[#events + 1] = { switch, true }
|
||||||
|
events[#events + 1] = { switch, false }
|
||||||
|
end
|
||||||
|
|
||||||
|
return events
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_TITLETAPS=1 taps the switches that do something beyond a move while the title plays,
|
||||||
|
-- before any service walk: the original pauses through the title, so none should count; the
|
||||||
|
-- trace carries the credits.
|
||||||
|
local titleTaps = (os.getenv("PORT_TITLETAPS") == "1")
|
||||||
|
|
||||||
|
if titleTaps then
|
||||||
|
walkEvents = tapsOf({ SWITCH_COIN1, SWITCH_START1, SWITCH_SERVICE, SWITCH_TEST, SWITCH_CONSOLE, SWITCH_SKILL1 })
|
||||||
|
end
|
||||||
|
if walkService then
|
||||||
|
table.move(tapsOf(WALK), 1, #WALK * 2, #walkEvents + 1, walkEvents)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_PLAYWALK=1 walks the in-play switches, each part once the game has answered so many moves
|
||||||
|
-- and out of a move's window: a quick save to slot 1 (SKILL1); TEST to the save screen, saving
|
||||||
|
-- to slot 2 and back to the game; CONSOLE to the exit screen and BACK; START held with SKILL1,
|
||||||
|
-- loading slot 1; COIN held with START, to the exit screen and BACK.
|
||||||
|
local PLAY_WALK = (os.getenv("PORT_PLAYWALK") == "1") and {
|
||||||
|
{ after = 2, events = tapsOf({ SWITCH_SKILL1 }) },
|
||||||
|
{ after = 3, events = tapsOf({ SWITCH_TEST, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT }) },
|
||||||
|
{ after = 5, events = tapsOf({ SWITCH_CONSOLE, SWITCH_DOWN, SWITCH_BUTTON1 }) },
|
||||||
|
{ after = 6, events = { { SWITCH_START1, true }, { SWITCH_SKILL1, true }, { SWITCH_SKILL1, false }, { SWITCH_START1, false } } },
|
||||||
|
{ after = 8, events = { { SWITCH_COIN1, true }, { SWITCH_START1, true }, { SWITCH_START1, false }, { SWITCH_COIN1, false }, { SWITCH_DOWN, true }, { SWITCH_DOWN, false }, { SWITCH_BUTTON1, true }, { SWITCH_BUTTON1, false } } }
|
||||||
|
} or {}
|
||||||
|
local playPart = 1
|
||||||
|
|
||||||
|
-- The second of PORT_TWICE's runs first loads slot 2 (START held with SKILL2), which the first
|
||||||
|
-- run saved from the save screen: the slots' part of the Persistence row.
|
||||||
|
if (#PLAY_WALK > 0) and (os.getenv("PORT_PASS") == "2") then
|
||||||
|
table.insert(PLAY_WALK, 1, { after = 1, events = { { SWITCH_START1, true }, { SWITCH_SKILL2, true }, { SWITCH_SKILL2, false }, { SWITCH_START1, false } } })
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The next part of the play walk, when the game has answered enough moves and no window is near.
|
||||||
|
local function karisPlayWalk(view, moves)
|
||||||
|
local part = PLAY_WALK[playPart]
|
||||||
|
|
||||||
|
if (#walkEvents > 0) or (part == nil) or (view.screen ~= 103) or (moves < part.after) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if view.window and (view.frame >= view.window.from - 30) and (view.frame <= view.window.to + 5) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
playPart = playPart + 1
|
||||||
|
walkEvents = { table.unpack(part.events) }
|
||||||
|
walkAt = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- One event of the walk each WALK_EVERY frames. True while walking.
|
||||||
|
local function walk(view)
|
||||||
|
if #walkEvents == 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
walkAt = walkAt or view.frames
|
||||||
|
if view.frames - walkAt < WALK_EVERY then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
walkAt = view.frames
|
||||||
|
local event = table.remove(walkEvents, 1)
|
||||||
|
|
||||||
|
karisPress(event[1], event[2])
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_HUD=1 adds what the HUD shows to each trace line, a letter a flag the loop sets, so an
|
||||||
|
-- original and its port are compared on what a player sees as well as on the game's state.
|
||||||
|
local HUD_FLAGS = {
|
||||||
|
{ "S", "bShowScore" }, { "L", "bShowLives" }, { "V", "bShowLvl" }, { "T", "bShowTop" },
|
||||||
|
{ "C", "bShowCredits" }, { "A", "bShowAction" }, { "K", "bShowSkip" }, { "G", "bShowGet" },
|
||||||
|
{ "H", "bShowChoices" }, { "D", "bShowLCD" }, { "P", "bPause" }, { "1", "bShowDisk1" },
|
||||||
|
{ "2", "bShowDisk2" }, { "3", "bShowDisk3" }, { "4", "bShowDiskA" }
|
||||||
|
}
|
||||||
|
local traceHud = (os.getenv("PORT_HUD") == "1")
|
||||||
|
|
||||||
|
|
||||||
|
-- The HUD flags as letters, from a reader of the loop's variables (the original's globals, or
|
||||||
|
-- the port's q).
|
||||||
|
function karisHudFlags(read)
|
||||||
|
local letters = {}
|
||||||
|
|
||||||
|
if not traceHud then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
for _, flag in ipairs(HUD_FLAGS) do
|
||||||
|
if read(flag[2]) then
|
||||||
|
letters[#letters + 1] = flag[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(letters)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_SOUND=1 traces each sound played or stopped, by its file's name, and the disc's audio
|
||||||
|
-- turned off or on, with the disc frame: the Sound row of FORGE.md 14.18. The engine's own calls
|
||||||
|
-- are wrapped before the game loads, so the original's sounds and the port's (which Forge plays
|
||||||
|
-- through the same calls) are caught the same way.
|
||||||
|
if os.getenv("PORT_SOUND") == "1" then
|
||||||
|
local names = {}
|
||||||
|
local realSoundLoad = soundLoad
|
||||||
|
local realSoundPlay = soundPlay
|
||||||
|
|
||||||
|
soundLoad = function(file, ...)
|
||||||
|
local handle = realSoundLoad(file, ...)
|
||||||
|
|
||||||
|
if handle ~= nil then
|
||||||
|
names[handle] = tostring(file):match("([^/\\]+)$")
|
||||||
|
end
|
||||||
|
|
||||||
|
return handle
|
||||||
|
end
|
||||||
|
soundPlay = function(handle, ...)
|
||||||
|
debugPrint(string.format("TRACE sound %s disc %d", tostring(names[handle]), discGetFrame()))
|
||||||
|
|
||||||
|
return realSoundPlay(handle, ...)
|
||||||
|
end
|
||||||
|
-- A sound stopped, and the disc's own audio turned off or on, change what is heard too.
|
||||||
|
local realSoundStop = soundStop
|
||||||
|
local realDiscAudio = discAudio
|
||||||
|
|
||||||
|
soundStop = function(handle, ...)
|
||||||
|
debugPrint(string.format("TRACE soundStop %s disc %d", tostring(names[handle]), discGetFrame()))
|
||||||
|
|
||||||
|
return realSoundStop(handle, ...)
|
||||||
|
end
|
||||||
|
discAudio = function(channel, enabled, ...)
|
||||||
|
debugPrint(string.format("TRACE discAudio %s %s disc %d", tostring(channel), tostring(enabled), discGetFrame()))
|
||||||
|
|
||||||
|
return realDiscAudio(channel, enabled, ...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- PORT_SHOTS=N takes up to N screenshots, numbered in order: one at each change the trace
|
||||||
|
-- prints, and one a frame into each move's window (the drive answers within a few). The traces
|
||||||
|
-- of an original and its port agree, so the same number is the same moment in both, and the
|
||||||
|
-- pictures can be diffed.
|
||||||
|
local SHOT_INTO_WINDOW = 1
|
||||||
|
-- A second run of PORT_TWICE names its pictures apart from the first's.
|
||||||
|
local SHOT_PREFIX = ((os.getenv("PORT_PASS") or "1") == "1") and "shot-" or ("shot-run" .. os.getenv("PORT_PASS") .. "-")
|
||||||
|
local shotsLeft = tonumber(os.getenv("PORT_SHOTS") or "0") or 0
|
||||||
|
local shotCount = 0
|
||||||
|
|
||||||
|
|
||||||
|
function karisShot(why)
|
||||||
|
if shotsLeft > 0 then
|
||||||
|
shotsLeft = shotsLeft - 1
|
||||||
|
shotCount = shotCount + 1
|
||||||
|
singeScreenshot(string.format("%s%04d-%s", SHOT_PREFIX, shotCount, why))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
function karisTraceSeek(frame)
|
function karisTraceSeek(frame)
|
||||||
seeks = seeks + 1
|
seeks = seeks + 1
|
||||||
debugPrint(string.format("TRACE seek %d", frame))
|
debugPrint(string.format("TRACE seek %d", frame))
|
||||||
|
|
@ -399,19 +651,45 @@ function karisDrive(view)
|
||||||
end
|
end
|
||||||
local line = string.format("TRACE screen %s step %s level %s scene %s move %s score %s lives %s", tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.scene), tostring(view.move), tostring(view.score), tostring(view.lives))
|
local line = string.format("TRACE screen %s step %s level %s scene %s move %s score %s lives %s", tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.scene), tostring(view.move), tostring(view.score), tostring(view.lives))
|
||||||
|
|
||||||
|
if view.hud then
|
||||||
|
line = line .. " hud " .. view.hud
|
||||||
|
end
|
||||||
|
if view.service then
|
||||||
|
line = line .. " " .. view.service
|
||||||
|
end
|
||||||
|
if titleTaps then
|
||||||
|
line = line .. " credits " .. tostring(view.credits)
|
||||||
|
end
|
||||||
|
|
||||||
if line ~= lastLine then
|
if line ~= lastLine then
|
||||||
lastLine = line
|
lastLine = line
|
||||||
debugPrint(line .. " disc " .. tostring(view.frame))
|
debugPrint(line .. " disc " .. tostring(view.frame))
|
||||||
|
karisShot("state")
|
||||||
|
end
|
||||||
|
-- A few frames into a move's window, where its prompt shows.
|
||||||
|
if view.window and (view.frame == view.window.from + SHOT_INTO_WINDOW) then
|
||||||
|
karisShot("window")
|
||||||
|
end
|
||||||
|
-- The options screen's walk, on the attract loop before anything else, and the play walk's
|
||||||
|
-- parts.
|
||||||
|
local moves = 0
|
||||||
|
|
||||||
|
for _ in pairs(answered) do
|
||||||
|
moves = moves + 1
|
||||||
|
end
|
||||||
|
karisPlayWalk(view, moves)
|
||||||
|
if ((playPart > 1) or (view.screen == 0) or (view.screen == 104) or (view.screen == 113) or (view.screen == 114)) and walk(view) then
|
||||||
|
return
|
||||||
end
|
end
|
||||||
-- A coin on the attract loop when the game is not free play, then START once it has begun
|
-- A coin on the attract loop when the game is not free play, then START once it has begun
|
||||||
-- playing.
|
-- playing.
|
||||||
if (view.screen == 0) and not started and (view.frame >= 150) and (not view.freeplay) and (view.credits == 0) and not coined then
|
if (view.screen == 0) and not started and (view.frame >= 150) and (view.frames >= ATTRACT) and (not view.freeplay) and (view.credits == 0) and not coined then
|
||||||
coined = true
|
coined = true
|
||||||
pressed = SWITCH_COIN1
|
pressed = SWITCH_COIN1
|
||||||
karisPress(pressed, true)
|
karisPress(pressed, true)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if (view.screen == 0) and not started and (view.frame >= 160) and (view.freeplay or (view.credits > 0)) then
|
if (view.screen == 0) and not started and (view.frame >= 160) and (view.frames >= ATTRACT) and (view.freeplay or (view.credits > 0)) then
|
||||||
started = true
|
started = true
|
||||||
pressed = SWITCH_START1
|
pressed = SWITCH_START1
|
||||||
karisPress(pressed, true)
|
karisPress(pressed, true)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ local function view()
|
||||||
local pairs = nil
|
local pairs = nil
|
||||||
|
|
||||||
if rdg and ACTUP then
|
if rdg and ACTUP then
|
||||||
pairs = { [ACTUP] = { 5, 1 }, [ACTDOWN] = { 5, 2 }, [ACTLEFT] = { 5, 3 }, [ACTRIGHT] = { 5, 4 }, [UPLEFT] = { 1, 3 }, [UPRIGHT] = { 1, 4 }, [DOWNLEFT] = { 2, 3 }, [DOWNRIGHT] = { 2, 4 } }
|
pairs = karisPairs(_G)
|
||||||
end
|
end
|
||||||
-- A choice: the framework's at branch10, the later map-mode copies' at branch02.
|
-- A choice: the framework's at branch10, the later map-mode copies' at branch02.
|
||||||
if ((lvlState == branch10) or (rdg and (lvlState == branch02))) and choice then
|
if ((lvlState == branch10) or (rdg and (lvlState == branch02))) and choice then
|
||||||
|
|
@ -86,7 +86,8 @@ local function view()
|
||||||
|
|
||||||
return { screen = currentLevel, step = lvlState, level = iLevel or iCurPos or iStageIndex, scene = iScene or iSegPointer, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives, window = window, choices = chosen, order = optorder, choice = iChoice,
|
return { screen = currentLevel, step = lvlState, level = iLevel or iCurPos or iStageIndex, scene = iScene or iSegPointer, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives, window = window, choices = chosen, order = optorder, choice = iChoice,
|
||||||
kimmy = kimmy, rdg = rdg, timegal = timegal, multi = kimmy and multi and multi[currentMove] or nil, path = kimmy and path and path[currentMove] or nil, timed = kimmy and timed or nil,
|
kimmy = kimmy, rdg = rdg, timegal = timegal, multi = kimmy and multi and multi[currentMove] or nil, path = kimmy and path and path[currentMove] or nil, timed = kimmy and timed or nil,
|
||||||
freeplay = (dip_CoinsPerCredit == DOPT_FREEPLAY), credits = iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = rdg and MASH or nil }
|
freeplay = (dip_CoinsPerCredit == DOPT_FREEPLAY), credits = iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = rdg and MASH or nil,
|
||||||
|
hud = karisHudFlags(function(name) return _G[name] end), service = karisServiceState(currentLevel, function(name) return _G[name] end) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,15 @@ end
|
||||||
|
|
||||||
local function view()
|
local function view()
|
||||||
local q = authorEntity("game").q
|
local q = authorEntity("game").q
|
||||||
|
-- The style the qte behaviour plays, as the flags the view reports.
|
||||||
|
local held = (q.qteStyle == "held")
|
||||||
|
local checkpoints = (q.qteStyle == "checkpoints")
|
||||||
|
local segmented = (q.qteStyle == "segments") or checkpoints or (q.qteStyle == "trophies")
|
||||||
local m = q.move[q.currentMove]
|
local m = q.move[q.currentMove]
|
||||||
local window = nil
|
local window = nil
|
||||||
local chosen = nil
|
local chosen = nil
|
||||||
|
|
||||||
if q.timegal then
|
if checkpoints then
|
||||||
if (q.currentLevel == 103) and m and (m[2] ~= nil) then
|
if (q.currentLevel == 103) and m and (m[2] ~= nil) then
|
||||||
window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 }
|
window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 }
|
||||||
end
|
end
|
||||||
|
|
@ -51,16 +55,16 @@ local function view()
|
||||||
local mouse = q.ratiox and { q.ratiox, q.ratioy, q.ratioxOffset, q.ratioyOffset } or nil
|
local mouse = q.ratiox and { q.ratiox, q.ratioy, q.ratioxOffset, q.ratioyOffset } or nil
|
||||||
local pairs = nil
|
local pairs = nil
|
||||||
|
|
||||||
if q.rdg and q.ACTUP then
|
if segmented and q.ACTUP then
|
||||||
pairs = { [q.ACTUP] = { 5, 1 }, [q.ACTDOWN] = { 5, 2 }, [q.ACTLEFT] = { 5, 3 }, [q.ACTRIGHT] = { 5, 4 }, [q.UPLEFT] = { 1, 3 }, [q.UPRIGHT] = { 1, 4 }, [q.DOWNLEFT] = { 2, 3 }, [q.DOWNRIGHT] = { 2, 4 } }
|
pairs = karisPairs(q)
|
||||||
end
|
end
|
||||||
if ((q.lvlState == 19) or (q.rdg and (q.lvlState == 11))) and q.choice then
|
if ((q.lvlState == 19) or (segmented and (q.lvlState == 11))) and q.choice then
|
||||||
chosen = {}
|
chosen = {}
|
||||||
for index, c in ipairs(q.choice) do
|
for index, c in ipairs(q.choice) do
|
||||||
chosen[index] = { right = c[2] }
|
chosen[index] = { right = c[2] }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if q.timegal and (q.lvlState == 12) and q.opt then
|
if checkpoints and (q.lvlState == 12) and q.opt then
|
||||||
chosen = {}
|
chosen = {}
|
||||||
for index, c in ipairs(q.opt) do
|
for index, c in ipairs(q.opt) do
|
||||||
chosen[index] = { right = c[2] }
|
chosen[index] = { right = c[2] }
|
||||||
|
|
@ -71,17 +75,18 @@ local function view()
|
||||||
-- numbers it, and reports it as the original does.
|
-- numbers it, and reports it as the original does.
|
||||||
local screen = q.currentLevel
|
local screen = q.currentLevel
|
||||||
|
|
||||||
if q.kimmy and (screen == q.levelNG) then
|
if held and (screen == q.levelNG) then
|
||||||
screen = nil
|
screen = nil
|
||||||
end
|
end
|
||||||
-- The map-mode lineage never numbered its finish screen either.
|
-- The map-mode lineage never numbered its finish screen either.
|
||||||
if q.rdg and (screen == QTE_LEVEL_FINISH) then
|
if segmented and (screen == QTE_LEVEL_FINISH) then
|
||||||
screen = nil
|
screen = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return { screen = screen, step = q.lvlState, level = q.iLevel or q.iCurPos or q.iStageIndex, scene = q.iScene or q.iSegPointer, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives, window = window, choices = chosen, order = q.optorder, choice = q.iChoice,
|
return { screen = screen, step = q.lvlState, level = q.iLevel or q.iCurPos or q.iStageIndex, scene = q.iScene or q.iSegPointer, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives, window = window, choices = chosen, order = q.optorder, choice = q.iChoice,
|
||||||
kimmy = q.kimmy, rdg = q.rdg, timegal = q.timegal, multi = q.kimmy and q.multi[q.currentMove] or nil, path = q.kimmy and q.path[q.currentMove] or nil, timed = q.kimmy and q.timed or nil,
|
kimmy = held, rdg = segmented, timegal = checkpoints, multi = held and q.multi[q.currentMove] or nil, path = held and q.path[q.currentMove] or nil, timed = held and q.timed or nil,
|
||||||
freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = q.rdg and q.MASH or nil }
|
freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = segmented and q.MASH or nil,
|
||||||
|
hud = karisHudFlags(function(name) return q[name] end), service = karisServiceState(screen, function(name) return q[name] end) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
242
testScripts/ports/ledger.md
Normal file
242
testScripts/ports/ledger.md
Normal file
File diff suppressed because one or more lines are too long
57
testScripts/ports/shotDiff.py
Executable file
57
testScripts/ports/shotDiff.py
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# The Picture row's check (FORGE.md 14.18): the screenshots an original and its port took at the
|
||||||
|
# same moments (compare.sh with PORT_SHOTS=N and SINGE_SHOTS=<dir>), paired by number, and how much
|
||||||
|
# of each picture differs. A pixel differs when any channel is more than TOLERANCE apart, since
|
||||||
|
# the two runs decode the same video but may round a scaled frame differently.
|
||||||
|
#
|
||||||
|
# testScripts/ports/shotDiff.py <shots dir> [<out dir>]
|
||||||
|
#
|
||||||
|
# prints one line a pair and a summary; with <out dir>, writes each pair side by side with the
|
||||||
|
# differing pixels marked in the third panel, for a person to look at.
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
TOLERANCE = 24 # Per channel, out of 255: video rounding, not a missing prompt
|
||||||
|
MARK = (255, 0, 255)
|
||||||
|
PAIR = re.compile(r"^(original|port)-shot-((?:run\d+-)?\d+)-(\w+)\.png$")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
shots = sys.argv[1] if len(sys.argv) > 1 else sys.exit("usage: shotDiff.py <shots dir> [<out dir>]")
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
found = {}
|
||||||
|
|
||||||
|
for name in os.listdir(shots):
|
||||||
|
match = PAIR.match(name)
|
||||||
|
if match:
|
||||||
|
found.setdefault((match.group(2), match.group(3)), {})[match.group(1)] = os.path.join(shots, name)
|
||||||
|
if out:
|
||||||
|
os.makedirs(out, exist_ok=True)
|
||||||
|
worst = 0.0
|
||||||
|
for (number, why), pair in sorted(found.items()):
|
||||||
|
if "original" not in pair or "port" not in pair:
|
||||||
|
print("%s %-6s only the %s took it" % (number, why, "original" if "original" in pair else "port"))
|
||||||
|
continue
|
||||||
|
a = numpy.asarray(Image.open(pair["original"]).convert("RGB"), dtype=numpy.int16)
|
||||||
|
b = numpy.asarray(Image.open(pair["port"]).convert("RGB"), dtype=numpy.int16)
|
||||||
|
if a.shape != b.shape:
|
||||||
|
print("%s %-6s sizes differ: %s and %s" % (number, why, a.shape, b.shape))
|
||||||
|
continue
|
||||||
|
differs = (numpy.abs(a - b) > TOLERANCE).any(axis=2)
|
||||||
|
percent = 100.0 * differs.mean()
|
||||||
|
worst = max(worst, percent)
|
||||||
|
print("%s %-6s %6.2f%% of the picture differs" % (number, why, percent))
|
||||||
|
if out:
|
||||||
|
marked = a.copy()
|
||||||
|
marked[differs] = MARK
|
||||||
|
side = numpy.concatenate([a, b, marked], axis=1).astype(numpy.uint8)
|
||||||
|
Image.fromarray(side).save(os.path.join(out, "%s-%s.png" % (number, why)))
|
||||||
|
print("%d pairs; the worst differs in %.2f%% of its picture" % (len(found), worst))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -31,7 +31,7 @@ pair() {
|
||||||
familyOf() {
|
familyOf() {
|
||||||
local desc=$1
|
local desc=$1
|
||||||
|
|
||||||
if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then
|
if grep -a -q 'style = "segments"\|style = "checkpoints"\|style = "trophies"' "$desc"; then
|
||||||
echo rdg
|
echo rdg
|
||||||
elif grep -a -q 'kind = "alg"' "$desc"; then
|
elif grep -a -q 'kind = "alg"' "$desc"; then
|
||||||
echo alg
|
echo alg
|
||||||
|
|
@ -54,6 +54,11 @@ libraryPair() {
|
||||||
alg|timetraveler|videoland)
|
alg|timetraveler|videoland)
|
||||||
pair "$family" "$folder" "$lib"
|
pair "$family" "$folder" "$lib"
|
||||||
;;
|
;;
|
||||||
|
rdg)
|
||||||
|
# A segmented style in the library's own layout: the rdg family knows only the
|
||||||
|
# Hypseus one, and the karis driver plays every style.
|
||||||
|
pair karis "$script" "$lib"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
pair "$family" "$script" "$lib"
|
pair "$family" "$script" "$lib"
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@ overlaySetResolution(720, 480)
|
||||||
local source = assert(io.open("testScripts/author/platformer.forge", "r"))
|
local source = assert(io.open("testScripts/author/platformer.forge", "r"))
|
||||||
local copy = assert(io.open(work, "w"))
|
local copy = assert(io.open(work, "w"))
|
||||||
|
|
||||||
copy:write(source:read("a"))
|
-- With credits, so the release can be checked for them: the fields go to games.dat, the notes to
|
||||||
|
-- CREDITS.txt, and the licence file travels with the game.
|
||||||
|
copy:write((source:read("a"):gsub("(\n\ttitle%s*=[^\n]*\n)", "%1\tcredits = { developer = \"Test Developer\", year = 1983, notes = { \"Original game: Test Author\", \"Converted by: Test Porter\" }, licenses = { \"LICENSE.txt\" } },\n", 1)))
|
||||||
source:close()
|
source:close()
|
||||||
copy:close()
|
copy:close()
|
||||||
|
copy = assert(io.open(out .. "LICENSE.txt", "w"))
|
||||||
|
copy:write("A licence the release must carry.\n")
|
||||||
|
copy:close()
|
||||||
|
|
||||||
if not forgeBegin(work) then
|
if not forgeBegin(work) then
|
||||||
debugPrint("RELEASE FAIL could not open " .. work)
|
debugPrint("RELEASE FAIL could not open " .. work)
|
||||||
|
|
@ -64,6 +69,26 @@ local function step()
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif stage == 4 then
|
elseif stage == 4 then
|
||||||
|
-- The original author's credits: in games.dat for the menu, the notes in CREDITS.txt, and
|
||||||
|
-- the licence beside the game.
|
||||||
|
local dat = assert(io.open(dest .. "/games.dat", "r")):read("a")
|
||||||
|
local credits = io.open(dest .. "/CREDITS.txt", "r")
|
||||||
|
local licence = io.open(dest .. "/LICENSE.txt", "r")
|
||||||
|
local notes = credits and credits:read("a") or ""
|
||||||
|
|
||||||
|
if credits then
|
||||||
|
credits:close()
|
||||||
|
end
|
||||||
|
if licence then
|
||||||
|
licence:close()
|
||||||
|
end
|
||||||
|
if dat:find('DEVELOPER = "Test Developer"', 1, true) and dat:find("YEAR = 1983", 1, true) and notes:find("Original game: Test Author", 1, true) and (licence ~= nil) then
|
||||||
|
debugPrint("RELEASE the credits travel: games.dat, CREDITS.txt, and the licence")
|
||||||
|
else
|
||||||
|
debugPrint("RELEASE FAIL credits missing from the release")
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif stage == 5 then
|
||||||
-- Whether it truly stands alone cannot be answered from in here: DIR is the directory of
|
-- Whether it truly stands alone cannot be answered from in here: DIR is the directory of
|
||||||
-- the script the engine was *launched* with, so a game reached by dofile would look for the
|
-- the script the engine was *launched* with, so a game reached by dofile would look for the
|
||||||
-- runtime beside this scene instead of beside itself. A released game is the main script,
|
-- runtime beside this scene instead of beside itself. A released game is the main script,
|
||||||
|
|
@ -76,7 +101,7 @@ end
|
||||||
|
|
||||||
function onOverlayUpdate()
|
function onOverlayUpdate()
|
||||||
frames = frames + 1
|
frames = frames + 1
|
||||||
if frames % 14 == 0 and stage < 5 then
|
if frames % 14 == 0 and stage < 6 then
|
||||||
stage = stage + 1
|
stage = stage + 1
|
||||||
step()
|
step()
|
||||||
end
|
end
|
||||||
|
|
|
||||||
191
util/forgeCredits.lua
Normal file
191
util/forgeCredits.lua
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
-- The original author's credits for a Forge port of an existing game: the fields of the game's own
|
||||||
|
-- games.dat entry (developer, publisher, year, platform, genre, creator, source, description), the
|
||||||
|
-- "KEY: value" lines of its main script's header comment as notes, and the licence files beside
|
||||||
|
-- the script, which a release carries. Nothing is invented: a field the original does not state
|
||||||
|
-- is left out.
|
||||||
|
--
|
||||||
|
-- lua5.4 util/forgeCredits.lua <description.forge> <game directory> <main script>
|
||||||
|
--
|
||||||
|
-- writes or replaces the description's credits block, just after its title, and touches nothing
|
||||||
|
-- else in it. util/forgePortKaris.lua loads this file for creditsFor, so a converted description
|
||||||
|
-- carries its credits from the start.
|
||||||
|
|
||||||
|
local CREDIT_KEYS = {
|
||||||
|
{ field = "developer", key = "DEVELOPER" },
|
||||||
|
{ field = "publisher", key = "PUBLISHER" },
|
||||||
|
{ field = "year", key = "YEAR" },
|
||||||
|
{ field = "platform", key = "PLATFORM" },
|
||||||
|
{ field = "genre", key = "GENRE" },
|
||||||
|
{ field = "creator", key = "CREATOR" },
|
||||||
|
{ field = "source", key = "SOURCE" },
|
||||||
|
{ field = "description", key = "DESCRIPTION" }
|
||||||
|
}
|
||||||
|
local LICENCE_PATTERNS = { "^copying", "^licen[cs]e", "^gpl" }
|
||||||
|
local GAMES_DAT_DEPTH = 2 -- How many folders above the script games.dat may be.
|
||||||
|
|
||||||
|
|
||||||
|
local function readFile(path)
|
||||||
|
local handle = io.open(path, "rb")
|
||||||
|
local text = nil
|
||||||
|
|
||||||
|
if handle then
|
||||||
|
text = handle:read("a")
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The games.dat entry for the script: beside it or a folder or two above, the entry whose SCRIPT
|
||||||
|
-- names this script, or the only entry there is.
|
||||||
|
local function gamesEntry(dir, main)
|
||||||
|
local folder = dir
|
||||||
|
|
||||||
|
for _ = 0, GAMES_DAT_DEPTH do
|
||||||
|
local text = readFile(folder .. "/games.dat")
|
||||||
|
|
||||||
|
if text then
|
||||||
|
local sandbox = {}
|
||||||
|
local chunk = load(text, "games.dat", "t", sandbox)
|
||||||
|
|
||||||
|
if chunk and pcall(chunk) and (type(sandbox.GAMES) == "table") then
|
||||||
|
for _, entry in ipairs(sandbox.GAMES) do
|
||||||
|
local script = tostring(entry.SCRIPT or "")
|
||||||
|
|
||||||
|
if script:lower():sub(-#main) == main:lower() then
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #sandbox.GAMES == 1 then
|
||||||
|
return sandbox.GAMES[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
folder = folder:match("^(.*)[/\\][^/\\]+$") or folder
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The header comment's "KEY: value" lines, as the author wrote them: program name, author, who
|
||||||
|
-- converted it and for what.
|
||||||
|
local function headerNotes(dir, main)
|
||||||
|
local text = readFile(dir .. "/" .. main) or ""
|
||||||
|
local block = text:match("%-%-%[=*%[(.-)%]=*%]")
|
||||||
|
local notes = {}
|
||||||
|
|
||||||
|
for line in (block or ""):gmatch("[^\r\n]+") do
|
||||||
|
local key, value = line:match("^%s*([A-Z][A-Z0-9 /%-]*[A-Z0-9])%s*:%s*(.-)%s*$")
|
||||||
|
|
||||||
|
if key and (value ~= "") then
|
||||||
|
notes[#notes + 1] = key:sub(1, 1) .. key:sub(2):lower() .. ": " .. value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return notes
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The licence files beside the script, by name.
|
||||||
|
local function licences(dir)
|
||||||
|
local found = {}
|
||||||
|
local handle = io.popen('ls -1 "' .. dir .. '" 2>/dev/null')
|
||||||
|
|
||||||
|
if handle then
|
||||||
|
for name in handle:lines() do
|
||||||
|
for _, pattern in ipairs(LICENCE_PATTERNS) do
|
||||||
|
if name:lower():match(pattern) then
|
||||||
|
found[#found + 1] = name
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
table.sort(found)
|
||||||
|
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The credits block as description source, one line, or nil when there is nothing to credit.
|
||||||
|
function creditsFor(dir, main)
|
||||||
|
local entry = gamesEntry(dir, main) or {}
|
||||||
|
local parts = {}
|
||||||
|
local notes = headerNotes(dir, main)
|
||||||
|
local files = licences(dir)
|
||||||
|
|
||||||
|
for _, credit in ipairs(CREDIT_KEYS) do
|
||||||
|
local value = entry[credit.key]
|
||||||
|
|
||||||
|
if type(value) == "number" then
|
||||||
|
parts[#parts + 1] = string.format("%s = %d", credit.field, value)
|
||||||
|
elseif (type(value) == "string") and (value ~= "") then
|
||||||
|
parts[#parts + 1] = string.format("%s = %q", credit.field, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #notes > 0 then
|
||||||
|
local quoted = {}
|
||||||
|
|
||||||
|
for _, note in ipairs(notes) do
|
||||||
|
quoted[#quoted + 1] = string.format("%q", note)
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = "notes = { " .. table.concat(quoted, ", ") .. " }"
|
||||||
|
end
|
||||||
|
if #files > 0 then
|
||||||
|
local quoted = {}
|
||||||
|
|
||||||
|
for _, name in ipairs(files) do
|
||||||
|
quoted[#quoted + 1] = string.format("%q", name)
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = "licenses = { " .. table.concat(quoted, ", ") .. " }"
|
||||||
|
end
|
||||||
|
if #parts == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return "\tcredits = { " .. table.concat(parts, ", ") .. " },"
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- The description with its credits line put just after the title, replacing one already there.
|
||||||
|
local function withCredits(text, line)
|
||||||
|
local out = {}
|
||||||
|
local after = false
|
||||||
|
|
||||||
|
for row in (text .. "\n"):gmatch("(.-)\n") do
|
||||||
|
if not row:match("^\tcredits = {") then
|
||||||
|
out[#out + 1] = row
|
||||||
|
if (not after) and line and row:match("^\ttitle%s*=") then
|
||||||
|
out[#out + 1] = line
|
||||||
|
after = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
while (#out > 0) and (out[#out] == "") do
|
||||||
|
out[#out] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n") .. "\n", after
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
if arg and arg[0] and arg[0]:match("forgeCredits%.lua$") then
|
||||||
|
local description = arg[1] or error("a description")
|
||||||
|
local dir = arg[2] or error("the game's directory")
|
||||||
|
local main = arg[3] or error("its main script")
|
||||||
|
local text = readFile(description) or error("cannot read " .. description)
|
||||||
|
local line = creditsFor(dir, main)
|
||||||
|
local result, put = withCredits(text, line)
|
||||||
|
|
||||||
|
if line and not put then
|
||||||
|
error("no title line in " .. description)
|
||||||
|
end
|
||||||
|
local handle = io.open(description, "wb")
|
||||||
|
|
||||||
|
handle:write(result)
|
||||||
|
handle:close()
|
||||||
|
print(description .. ": " .. (line and line:gsub("^\t", "") or "nothing to credit"))
|
||||||
|
end
|
||||||
182
util/forgeLedger.py
Normal file
182
util/forgeLedger.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# The ledger of FORGE.md section 17: every capability the library's converted titles use, which
|
||||||
|
# titles use it, and how the converter maps it, gathered from util/forgePortKaris.lua's report
|
||||||
|
# (FORGE_LEDGER) for each description it made. Nothing is run but the converter; no game plays.
|
||||||
|
#
|
||||||
|
# python3 util/forgeLedger.py [<out>] (default: testScripts/ports/ledger.md)
|
||||||
|
#
|
||||||
|
# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported)
|
||||||
|
#
|
||||||
|
# A status is the converter's own: "forge" is a field of Forge's model, "framework" something
|
||||||
|
# Forge plays under the framework's names or numbers, "lua" something the game's own Lua still does.
|
||||||
|
# The runs-without-its-Lua column is testScripts/ports/standalone.txt as last proved, with its date.
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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"))
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "testScripts", "ports", "ledger.md")
|
||||||
|
STANDING = os.path.join(REPO, "testScripts", "ports", "standalone.txt")
|
||||||
|
MADE_BY = "by the port converter"
|
||||||
|
SCRIPT = re.compile(r'^\s*script\s*=\s*"([^"]+)"', re.M)
|
||||||
|
STATUSES = ("lua", "framework", "forge")
|
||||||
|
EXAMPLES = 6
|
||||||
|
|
||||||
|
|
||||||
|
def descriptions():
|
||||||
|
"""Every description the converter made, as (title, directory, main script, name)."""
|
||||||
|
found = []
|
||||||
|
for root, dirs, files in os.walk(LIBRARY):
|
||||||
|
dirs.sort()
|
||||||
|
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()
|
||||||
|
if MADE_BY not in text:
|
||||||
|
continue
|
||||||
|
script = SCRIPT.search(text)
|
||||||
|
if not script:
|
||||||
|
continue
|
||||||
|
title = os.path.relpath(path, LIBRARY)[:-len(".forge")]
|
||||||
|
found.append((title, root, script.group(1), name[:-len(".forge")]))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def others():
|
||||||
|
"""The descriptions another tool made, named so the ledger says what it does not cover."""
|
||||||
|
missing = []
|
||||||
|
for root, dirs, files in os.walk(LIBRARY):
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
if name.endswith(".forge") and MADE_BY not in open(path, errors="replace").read():
|
||||||
|
missing.append(os.path.relpath(path, LIBRARY))
|
||||||
|
return sorted(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def addonsHash(directory):
|
||||||
|
path = os.path.join(directory, "Script", "addons.singe")
|
||||||
|
return hashlib.md5(open(path, "rb").read()).hexdigest() if os.path.exists(path) else None
|
||||||
|
|
||||||
|
|
||||||
|
def report(directory, script, name):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".ledger", delete=False) as handle:
|
||||||
|
path = handle.name
|
||||||
|
try:
|
||||||
|
done = subprocess.run(["lua5.4", os.path.join("util", "forgePortKaris.lua"), directory, script, name],
|
||||||
|
cwd=REPO, env=dict(os.environ, FORGE_LEDGER=path), capture_output=True, text=True, timeout=120)
|
||||||
|
lines = [line.rstrip("\n").split("\t") for line in open(path) if line.strip()]
|
||||||
|
return lines, (done.stderr.strip().splitlines() or [""])[-1] if done.returncode else None
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
standing = set()
|
||||||
|
proved = "never"
|
||||||
|
if os.path.exists(STANDING):
|
||||||
|
standing = {line.strip() for line in open(STANDING) if line.strip() and not line.startswith("#")}
|
||||||
|
proved = datetime.date.fromtimestamp(os.path.getmtime(STANDING)).isoformat()
|
||||||
|
|
||||||
|
uses = collections.defaultdict(set) # (status, capability) -> titles
|
||||||
|
details = collections.defaultdict(set) # (status, capability) -> details
|
||||||
|
perTitle = {}
|
||||||
|
failed = []
|
||||||
|
titles = descriptions()
|
||||||
|
# The add-ons most titles ship unchanged are the framework's own: their hooks are the stock
|
||||||
|
# ones, not the game's Lua.
|
||||||
|
hashes = collections.Counter(h for h in (addonsHash(row[1]) for row in titles) if h)
|
||||||
|
stock = hashes.most_common(1)[0][0] if hashes else None
|
||||||
|
for title, directory, script, name in titles:
|
||||||
|
lines, error = report(directory, script, name)
|
||||||
|
if error is not None:
|
||||||
|
failed.append((title, error))
|
||||||
|
continue
|
||||||
|
if stock and addonsHash(directory) == stock:
|
||||||
|
lines = [["framework", capability, "stock add-ons"] if capability.startswith("hook.") else [status, capability, detail] for status, capability, detail in lines]
|
||||||
|
counts = collections.Counter()
|
||||||
|
family = "?"
|
||||||
|
for status, capability, detail in lines:
|
||||||
|
uses[(status, capability)].add(title)
|
||||||
|
if detail:
|
||||||
|
details[(status, capability)].add(detail)
|
||||||
|
counts[status] += 1
|
||||||
|
if capability.startswith("family."):
|
||||||
|
family = capability[len("family."):]
|
||||||
|
folder = title.split("/")[1] if title.startswith("hypseus/") else title.split("/")[0]
|
||||||
|
perTitle[title] = (family, counts, "yes" if folder in standing else "not on the list")
|
||||||
|
|
||||||
|
out = []
|
||||||
|
out.append("# The ledger (FORGE.md section 17)")
|
||||||
|
out.append("")
|
||||||
|
out.append("Generated by `util/forgeLedger.py` on %s from the converter's report for each" % datetime.date.today().isoformat())
|
||||||
|
out.append("description it made; do not edit by hand. **forge** is a field of Forge's own model,")
|
||||||
|
out.append("**framework** something Forge plays under the framework's names or numbers, **lua**")
|
||||||
|
out.append("something the game's own Lua still does. Rows are sorted by status, then by how many")
|
||||||
|
out.append("titles use them: the top of the table is what most blocks titles from being Forge's own.")
|
||||||
|
out.append("A hook from the add-ons file most titles ship unchanged (%d of them) is the framework's" % (hashes[stock] if stock else 0))
|
||||||
|
out.append("stock one, and counts as framework. The globals are every global the game's script left")
|
||||||
|
out.append("that nothing names: most are the framework's own state and picture numbers, not data.")
|
||||||
|
out.append("")
|
||||||
|
out.append("%d titles reported; %d failed to report; %d descriptions made by another tool (not covered)." % (len(perTitle), len(failed), len(others())))
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# The capabilities, with the globals and settings folded into one row each per status: there
|
||||||
|
# are hundreds, and their names are listed in the section after.
|
||||||
|
folded = collections.defaultdict(set)
|
||||||
|
for (status, capability), titles in uses.items():
|
||||||
|
if capability.startswith("global.") or capability.startswith("setting."):
|
||||||
|
folded[(status, capability.split(".")[0] + ".*")] |= titles
|
||||||
|
else:
|
||||||
|
folded[(status, capability)] |= titles
|
||||||
|
out.append("## Capabilities")
|
||||||
|
out.append("")
|
||||||
|
out.append("| Status | Capability | Titles | Some of them | Details |")
|
||||||
|
out.append("|---|---|---|---|---|")
|
||||||
|
for (status, capability), titles in sorted(folded.items(), key=lambda item: (STATUSES.index(item[0][0]), -len(item[1]), item[0][1])):
|
||||||
|
names = sorted(titles)
|
||||||
|
more = ", ..." if len(names) > EXAMPLES else ""
|
||||||
|
shown = ", ".join(title.split("/")[-1] for title in names[:EXAMPLES]) + more
|
||||||
|
found = sorted(details.get((status, capability), []))
|
||||||
|
note = "; ".join(found[:8]) + ("; ..." if len(found) > 8 else "")
|
||||||
|
out.append("| %s | %s | %d | %s | %s |" % (status, capability, len(names), shown, note))
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
for prefix, heading in (("setting.", "Settings under the framework's names"), ("global.", "Globals nothing names yet")):
|
||||||
|
rows = sorted(((capability, len(titles)) for (status, capability), titles in uses.items() if capability.startswith(prefix)), key=lambda row: (-row[1], row[0]))
|
||||||
|
out.append("## %s (%d)" % (heading, len(rows)))
|
||||||
|
out.append("")
|
||||||
|
out.append("By how many titles use each: " + ", ".join("%s %d" % (capability[len(prefix):], count) for capability, count in rows) + ".")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
out.append("## Titles")
|
||||||
|
out.append("")
|
||||||
|
out.append("Runs without its Lua: `standalone.txt`, last proved %s." % proved)
|
||||||
|
out.append("")
|
||||||
|
out.append("| Title | Family | forge | framework | lua | Runs without its Lua |")
|
||||||
|
out.append("|---|---|---|---|---|---|")
|
||||||
|
for title, (family, counts, standalone) in sorted(perTitle.items(), key=lambda item: (item[1][1]["lua"], item[1][1]["framework"], item[0])):
|
||||||
|
out.append("| %s | %s | %d | %d | %d | %s |" % (title, family, counts["forge"], counts["framework"], counts["lua"], standalone))
|
||||||
|
out.append("")
|
||||||
|
if failed:
|
||||||
|
out.append("## Failed to report")
|
||||||
|
out.append("")
|
||||||
|
for title, error in failed:
|
||||||
|
out.append("* %s: %s" % (title, error))
|
||||||
|
out.append("")
|
||||||
|
out.append("## Made by another tool")
|
||||||
|
out.append("")
|
||||||
|
out.append(", ".join(others()) + ".")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
with open(OUT, "w") as handle:
|
||||||
|
handle.write("\n".join(out))
|
||||||
|
print("wrote %s: %d titles, %d capabilities, %d failed" % (OUT, len(perTitle), len(folded), len(failed)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -25,6 +25,12 @@ local dir = arg[1] or error("a game directory")
|
||||||
local main = arg[2] or error("the main script")
|
local main = arg[2] or error("the main script")
|
||||||
local name = arg[3] or main:gsub("%.singe$", "")
|
local name = arg[3] or main:gsub("%.singe$", "")
|
||||||
|
|
||||||
|
-- A game that draws at random as it loads (a scene swap, a level's variant) draws the same every
|
||||||
|
-- time, so a description made twice is made the same.
|
||||||
|
local CONVERT_SEED = 12345
|
||||||
|
|
||||||
|
math.randomseed(CONVERT_SEED)
|
||||||
|
|
||||||
-- ===== Loading the game's script ==============================================================
|
-- ===== Loading the game's script ==============================================================
|
||||||
|
|
||||||
local env = setmetatable({}, { __index = _G })
|
local env = setmetatable({}, { __index = _G })
|
||||||
|
|
@ -221,13 +227,12 @@ assert(loadfile(dir .. "/" .. main, "t", env))()
|
||||||
|
|
||||||
-- ===== The settings ===========================================================================
|
-- ===== The settings ===========================================================================
|
||||||
|
|
||||||
-- The dips, by name, from what the game was last saved with or shipped with: the framework
|
-- The dips, by name, from the game's config files in Cfg/, the first to name one winning.
|
||||||
-- reads Cfg/game.cfg and falls back to Cfg/default.cfg.
|
local function configFiles(names)
|
||||||
local function settings()
|
|
||||||
local found = {}
|
local found = {}
|
||||||
|
|
||||||
for _, file in ipairs({ dir .. "/Cfg/game.cfg", dir .. "/Cfg/default.cfg" }) do
|
for _, name in ipairs(names) do
|
||||||
local handle = io.open(file, "r")
|
local handle = io.open(dir .. "/Cfg/" .. name, "r")
|
||||||
|
|
||||||
if handle then
|
if handle then
|
||||||
for line in handle:lines() do
|
for line in handle:lines() do
|
||||||
|
|
@ -244,6 +249,17 @@ local function settings()
|
||||||
return found
|
return found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- What the game was last saved with or shipped with: the framework reads Cfg/game.cfg and falls
|
||||||
|
-- back to Cfg/default.cfg.
|
||||||
|
local function settings()
|
||||||
|
return configFiles({ "game.cfg", "default.cfg" })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What the service screen's "Default" restores: Cfg/default.cfg alone.
|
||||||
|
local function defaults()
|
||||||
|
return configFiles({ "default.cfg" })
|
||||||
|
end
|
||||||
|
|
||||||
-- ===== The moves ==============================================================================
|
-- ===== The moves ==============================================================================
|
||||||
|
|
||||||
local function copy(t)
|
local function copy(t)
|
||||||
|
|
@ -380,10 +396,24 @@ end
|
||||||
|
|
||||||
local out = {}
|
local out = {}
|
||||||
|
|
||||||
|
-- The original author's credits, from the game's own games.dat and script header (forgeCredits.lua
|
||||||
|
-- beside this file), so a port names whoever made the game.
|
||||||
|
dofile((arg[0]:match("^(.*[/\\])") or "") .. "forgeCredits.lua")
|
||||||
|
|
||||||
|
|
||||||
local function put(line)
|
local function put(line)
|
||||||
out[#out + 1] = line
|
out[#out + 1] = line
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function putCredits()
|
||||||
|
local line = creditsFor(dir, main)
|
||||||
|
|
||||||
|
if line then
|
||||||
|
put(line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function lua(value, indent)
|
local function lua(value, indent)
|
||||||
indent = indent or ""
|
indent = indent or ""
|
||||||
if type(value) == "table" then
|
if type(value) == "table" then
|
||||||
|
|
@ -426,19 +456,35 @@ end
|
||||||
|
|
||||||
local levels = {}
|
local levels = {}
|
||||||
|
|
||||||
-- A map-mode game's levels are functions of its main.singe, run at play; nothing to read here.
|
-- A level row as the description keeps it: the one place its fields are written down, the
|
||||||
for index = 1, (rdg and 0 or env.finalstage) do
|
-- runtime building the loops' positional rows from it. play and playBy are its scenes, for a
|
||||||
local level = env.Level[index]
|
-- level the game can reach.
|
||||||
|
local function levelOf(level, play, playBy)
|
||||||
local play, playBy = scenesOf(index)
|
return { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], selectStill = level[8], play = play, playBy = playBy }
|
||||||
|
|
||||||
levels[index] = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy }
|
|
||||||
end
|
end
|
||||||
if (not rdg) and env.AllowSecret and env.Level[env.levelSecret] then
|
|
||||||
local level = env.Level[env.levelSecret]
|
|
||||||
local play, playBy = scenesOf(env.levelSecret)
|
|
||||||
|
|
||||||
levels.secret = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy }
|
-- Every row the game declares, those past its last stage and a secret one it cannot reach
|
||||||
|
-- included, since the game's own Lua may read them. A map-mode game's levels are functions of
|
||||||
|
-- its main.singe, run at play; nothing to read here.
|
||||||
|
if not rdg then
|
||||||
|
for index, level in pairs(env.Level) do
|
||||||
|
if (type(index) == "number") and (index ~= env.levelSecret) then
|
||||||
|
local play, playBy = nil, nil
|
||||||
|
|
||||||
|
if index <= env.finalstage then
|
||||||
|
play, playBy = scenesOf(index)
|
||||||
|
end
|
||||||
|
levels[index] = levelOf(level, play, playBy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if env.Level[env.levelSecret] then
|
||||||
|
local play, playBy = nil, nil
|
||||||
|
|
||||||
|
if env.AllowSecret then
|
||||||
|
play, playBy = scenesOf(env.levelSecret)
|
||||||
|
end
|
||||||
|
levels.secret = levelOf(env.Level[env.levelSecret], play, playBy)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local tiers = {}
|
local tiers = {}
|
||||||
|
|
@ -548,16 +594,29 @@ end
|
||||||
|
|
||||||
-- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score
|
-- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score
|
||||||
-- that reaches it goes to the board's screens at the end.
|
-- that reaches it goes to the board's screens at the end.
|
||||||
local function highScores()
|
-- The game's boards from Cfg/hscore.cfg, as the framework reads them: ten lines each, a blank
|
||||||
local found = {}
|
-- line between, for the normal game, the life-bar game, and the survivor game; then, after the
|
||||||
|
-- trophy counts, each level's best percent ("name,percent!difficulty").
|
||||||
|
local function boards()
|
||||||
|
local found = { normal = {}, lifeBar = {}, survivor = {}, percent = {} }
|
||||||
|
local order = { "normal", "lifeBar", "survivor" }
|
||||||
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
|
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
|
||||||
|
local which = 1
|
||||||
|
|
||||||
if handle then
|
if handle then
|
||||||
for line in handle:lines() do
|
for line in handle:lines() do
|
||||||
local who, score = line:match("^%s*([^,]+),(%d+)")
|
local who, score = line:match("^%s*([^,]+),(%d+)%s*$")
|
||||||
|
local board = found[order[which]]
|
||||||
|
|
||||||
if who and (#found < 10) then
|
local best, percent, difficulty = line:match("^%s*([^,]+),(%d+)!(%a)%s*$")
|
||||||
found[#found + 1] = { who, tonumber(score) }
|
|
||||||
|
if board and who then
|
||||||
|
board[#board + 1] = { who, tonumber(score) }
|
||||||
|
if #board == 10 then
|
||||||
|
which = which + 1
|
||||||
|
end
|
||||||
|
elseif best then
|
||||||
|
found.percent[#found.percent + 1] = { best, tonumber(percent), difficulty }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
handle:close()
|
handle:close()
|
||||||
|
|
@ -647,6 +706,107 @@ for key, value in pairs(env) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- A table literal of Author.singe's, by its local's name: the runtime's own names and constants,
|
||||||
|
-- read from the runtime so they are written once.
|
||||||
|
local authorSource = nil
|
||||||
|
|
||||||
|
local function authorLiteral(name)
|
||||||
|
if authorSource == nil then
|
||||||
|
local path = (arg[0]:match("^(.*[/\\])") or "") .. "../forge/Author.singe"
|
||||||
|
local handle = io.open(path, "rb") or error("cannot read " .. path)
|
||||||
|
|
||||||
|
authorSource = handle:read("a")
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
local body = authorSource:match("\nlocal " .. name .. " = (%b{})") or error("no " .. name .. " in Author.singe")
|
||||||
|
|
||||||
|
return load("return " .. body, name, "t", {})()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The clips and stills under Forge's names, out of the settings: the names are Author.singe's
|
||||||
|
-- own QTE_CLIPS table, read from there so they are written once; a still is a frame global
|
||||||
|
-- named by what follows "frame"; the attract steps are offsetIntro01, 02, and on.
|
||||||
|
local clips, stills = {}, {}
|
||||||
|
|
||||||
|
do
|
||||||
|
local names = authorLiteral("QTE_CLIPS")
|
||||||
|
|
||||||
|
if not rdg then
|
||||||
|
for _, clip in ipairs(names) do
|
||||||
|
if settings[clip[2]] ~= nil then
|
||||||
|
clips[clip[1]] = { settings[clip[2]], settings[clip[3]] }
|
||||||
|
settings[clip[2]] = nil
|
||||||
|
settings[clip[3]] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local attract = {}
|
||||||
|
|
||||||
|
while settings[string.format("offsetIntro%02d", #attract + 1)] ~= nil do
|
||||||
|
local step = #attract + 1
|
||||||
|
|
||||||
|
attract[step] = { settings[string.format("offsetIntro%02d", step)], settings[string.format("offsetIntro%02dend", step)] }
|
||||||
|
settings[string.format("offsetIntro%02d", step)] = nil
|
||||||
|
settings[string.format("offsetIntro%02dend", step)] = nil
|
||||||
|
end
|
||||||
|
if #attract > 0 then
|
||||||
|
clips.attract = attract
|
||||||
|
end
|
||||||
|
for key, value in pairs(settings) do
|
||||||
|
local name = key:match("^frame(%u%w*)$")
|
||||||
|
|
||||||
|
if name and (type(value) == "number") then
|
||||||
|
stills[name:sub(1, 1):lower() .. name:sub(2)] = value
|
||||||
|
settings[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The other named fields, by Author.singe's QTE_FIELDS: each value leaves the settings for its
|
||||||
|
-- group (scoring, lifeBar, windowDelay, order, deaths, secret, video).
|
||||||
|
local groups = {}
|
||||||
|
|
||||||
|
if not rdg then
|
||||||
|
for _, field in ipairs(authorLiteral("QTE_FIELDS")) do
|
||||||
|
local group, name = field[1]:match("^(%w+)%.(%w+)$")
|
||||||
|
|
||||||
|
if settings[field[2]] ~= nil then
|
||||||
|
groups[group] = groups[group] or {}
|
||||||
|
groups[group][name] = settings[field[2]]
|
||||||
|
settings[field[2]] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A setting equal to the constant the runtime lays down before the settings (Author.singe's QTE
|
||||||
|
-- table, and HELD over it for a held game) is a copy of it, and is left out.
|
||||||
|
do
|
||||||
|
local constants = {}
|
||||||
|
|
||||||
|
for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do
|
||||||
|
for key, value in pairs(authorLiteral(tableName)) do
|
||||||
|
if (key ~= "FLAG_OF") and (key ~= "BRANCH") then
|
||||||
|
constants[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for key, value in pairs(settings) do
|
||||||
|
if (type(value) ~= "table") and (constants[key] == value) then
|
||||||
|
settings[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The segmented lineage's runtime reads no settings (its game's own Lua declares them all), so
|
||||||
|
-- its descriptions carry none (see the qte table below).
|
||||||
|
-- One source for each: the levels are in levels, and a dip is in dips (the runtime lays the dips
|
||||||
|
-- over the settings, so a copy here would only look like it mattered).
|
||||||
|
if not rdg then
|
||||||
|
settings.Level = nil
|
||||||
|
end
|
||||||
|
for key in pairs(dips or {}) do
|
||||||
|
settings[key] = nil
|
||||||
|
end
|
||||||
-- (A game that sets its rate by number rather than by name has no MovieFPS; under the
|
-- (A game that sets its rate by number rather than by name has no MovieFPS; under the
|
||||||
-- hand-written games' catch-all stubs the name answers as a function, which is not data.)
|
-- hand-written games' catch-all stubs the name answers as a function, which is not data.)
|
||||||
settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil
|
settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil
|
||||||
|
|
@ -664,40 +824,277 @@ end
|
||||||
-- to them and not to the game's declarations -- a snapshot that carried them would describe a
|
-- to them and not to the game's declarations -- a snapshot that carried them would describe a
|
||||||
-- state the game never starts in. A file that will not load under the stubs is no loss; the
|
-- state the game never starts in. A file that will not load under the stubs is no loss; the
|
||||||
-- game still names it and the loop still runs it.
|
-- game still names it and the loop still runs it.
|
||||||
|
-- The functions they define or replace are the hooks the loop calls into the game's own Lua, which
|
||||||
|
-- the ledger names (FORGE_LEDGER, below).
|
||||||
|
local hooks = {}
|
||||||
|
|
||||||
do
|
do
|
||||||
local chunk = loadfile(dir .. "/Script/addons.singe", "t", env)
|
local chunk = loadfile(dir .. "/Script/addons.singe", "t", env)
|
||||||
|
|
||||||
if chunk then
|
if chunk then
|
||||||
|
local before = {}
|
||||||
|
|
||||||
|
for key, value in pairs(env) do
|
||||||
|
before[key] = value
|
||||||
|
end
|
||||||
pcall(chunk)
|
pcall(chunk)
|
||||||
|
-- A hook whose body is only blank lines and comments is a stub, and does nothing.
|
||||||
|
local source = {}
|
||||||
|
local handle = io.open(dir .. "/Script/addons.singe", "rb")
|
||||||
|
|
||||||
|
for line in (handle and handle:read("a") or ""):gmatch("([^\n]*)\n?") do
|
||||||
|
source[#source + 1] = line
|
||||||
|
end
|
||||||
|
if handle then
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
for key, value in pairs(env) do
|
||||||
|
if (type(value) == "function") and (before[key] ~= value) then
|
||||||
|
local info = debug.getinfo(value, "S")
|
||||||
|
local body = {}
|
||||||
|
|
||||||
|
for at = info.linedefined + 1, info.lastlinedefined - 1 do
|
||||||
|
body[#body + 1] = (source[at] or ""):gsub("%-%-.*$", "")
|
||||||
|
end
|
||||||
|
-- The one-line form, "function name() end", has its body on its own line.
|
||||||
|
if info.linedefined == info.lastlinedefined then
|
||||||
|
body[1] = (source[info.linedefined] or ""):gsub("%-%-.*$", ""):gsub("^.-%b()", ""):gsub("end%s*$", "")
|
||||||
|
end
|
||||||
|
hooks[#hooks + 1] = { name = key, empty = not table.concat(body, " "):find("%S") }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
table.sort(hooks, function(a, b) return a.name < b.name end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Where the games the qte behaviour plays differ, the description says which way, by name
|
||||||
|
-- (FORGE.md 15.4): the runtime never asks which framework or version a game came from. Only
|
||||||
|
-- the qte loop reads these; the other loops' games carry none.
|
||||||
|
local function optionsFor()
|
||||||
|
if alg or timetraveler then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local options = {}
|
||||||
|
|
||||||
|
-- How the qte behaviour judges and runs this game; the default, windows, is left unsaid.
|
||||||
|
options.style = (timegal and "checkpoints") or (sdq and "trophies") or (rdg and "segments") or (kimmy and "held") or nil
|
||||||
|
|
||||||
|
if frameworkVersion == "3.32b" then
|
||||||
|
options.gameOverAfterContinue = "seek"
|
||||||
|
options.secretInputs = { "p1BUTTON4", "p1UP" }
|
||||||
|
end
|
||||||
|
if mazescater then
|
||||||
|
options.hintSeconds = 4
|
||||||
|
options.alternateMashRate = 6
|
||||||
|
options.mashAnySwitch = true
|
||||||
|
options.doubleKind = 34
|
||||||
|
end
|
||||||
|
|
||||||
|
return (next(options) ~= nil) and options or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local shipped = defaults()
|
||||||
|
|
||||||
local qte = {
|
local qte = {
|
||||||
script = main,
|
script = main,
|
||||||
addons = (not rdg) and "Script/addons.singe" or nil,
|
addons = (not rdg) and "Script/addons.singe" or nil,
|
||||||
framework = frameworkVersion,
|
options = optionsFor(),
|
||||||
hsDR = kimmy and dieAndRetry() or nil,
|
hsDR = kimmy and dieAndRetry() or nil,
|
||||||
saves = kimmy and saves() or nil,
|
saves = (not rdg) and saves() or nil,
|
||||||
settings = settings,
|
settings = (not rdg) and settings or nil,
|
||||||
|
clips = next(clips) and clips or nil,
|
||||||
|
stills = next(stills) and stills or nil,
|
||||||
|
scoring = groups.scoring,
|
||||||
|
lifeBar = groups.lifeBar,
|
||||||
|
windowDelay = groups.windowDelay,
|
||||||
|
order = groups.order,
|
||||||
|
deaths = groups.deaths,
|
||||||
|
secret = groups.secret,
|
||||||
|
video = groups.video,
|
||||||
dips = dips,
|
dips = dips,
|
||||||
|
defaults = (not rdg) and next(shipped) and shipped or nil,
|
||||||
swaps = (not rdg) and swaps() or nil,
|
swaps = (not rdg) and swaps() or nil,
|
||||||
highScores = rdg and RDG_BOARD or highScores(),
|
highScores = rdg and RDG_BOARD or nil,
|
||||||
|
boards = (not rdg) and boards() or nil,
|
||||||
tweaks = rdg and RDG_TWEAKS[name] or nil,
|
tweaks = rdg and RDG_TWEAKS[name] or nil,
|
||||||
sdq = sdq and sdqOrder() or nil,
|
trophyOrder = sdq and sdqOrder() or nil,
|
||||||
percents = sdq and RDG_EXTRA or nil,
|
percents = sdq and RDG_EXTRA or nil,
|
||||||
flows = alg and algLevels() or nil,
|
flows = alg and algLevels() or nil,
|
||||||
levels = (not rdg) and levels or nil
|
levels = (not rdg) and levels or nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- ===== The ledger ===============================================================================
|
||||||
|
--
|
||||||
|
-- FORGE_LEDGER=<file> writes, instead of the description, what the title uses and how this
|
||||||
|
-- converter maps each thing (FORGE.md section 17), one line each: a status, a capability, and a
|
||||||
|
-- detail. "forge" is a field of Forge's own model; "framework" is something Forge plays under
|
||||||
|
-- the framework's names or numbers (a dip, a leftover global, a move kind, a style); "lua" is
|
||||||
|
-- something the game's own Lua still does. util/forgeLedger.py gathers these over the library.
|
||||||
|
|
||||||
|
-- The judge (FORGE.md 15.4) each framework move kind is, by the kind's name in Author.singe's QTE
|
||||||
|
-- table, or HELD's for a held game.
|
||||||
|
local KIND_JUDGES = {
|
||||||
|
UP = "press.direction", DOWN = "press.direction", LEFT = "press.direction", RIGHT = "press.direction",
|
||||||
|
BUTTON1 = "press.button", BUTTON2 = "press.button", BUTTON3 = "press.button", BUTTON4 = "press.button",
|
||||||
|
UPLEFT = "press.diagonal", UPRIGHT = "press.diagonal", DOWNLEFT = "press.diagonal", DOWNRIGHT = "press.diagonal",
|
||||||
|
ACTUP = "chord.buttonDirection", ACTDOWN = "chord.buttonDirection", ACTLEFT = "chord.buttonDirection", ACTRIGHT = "chord.buttonDirection",
|
||||||
|
COMBO = "chord", MASH = "mash.one", MASHMIN = "mash.one", MASHMAX = "mash.one",
|
||||||
|
MASH2 = "mash.two", MASH2MIN = "mash.two", MASH2MAX = "mash.two", RUN = "mash.run", RUNMIN = "mash.run", RUNMAX = "mash.run",
|
||||||
|
HOLDUP = "hold.direction", HOLDDOWN = "hold.direction", HOLDLEFT = "hold.direction", HOLDRIGHT = "hold.direction",
|
||||||
|
HOLDBUT = "hold.button", HOLD = "hold", LETGO = "letGo", DOUBLE = "double", MULTI = "count",
|
||||||
|
LOOP = "circle", LOOPLEFT = "circle", LOOPRIGHT = "circle", UD = "sequence", DU = "sequence", LR = "sequence", RL = "sequence", B1B2 = "sequence",
|
||||||
|
CHOOSE = "choose", PATH = "path", YESNO = "yesNo", TIMED = "timed", MTIMED = "timed.multi",
|
||||||
|
ANYTHING = "anything", NOTHING = "nothing", SKIP = "skip",
|
||||||
|
WAY = "jump", WAYOUT = "jump", TOLEVEL = "jump", TOSCENE = "jump"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
local function ledger(path)
|
||||||
|
local lines = {}
|
||||||
|
local seen = {}
|
||||||
|
|
||||||
|
local function use(status, capability, detail)
|
||||||
|
local key = status .. "\t" .. capability .. "\t" .. (detail or "")
|
||||||
|
|
||||||
|
if not seen[key] then
|
||||||
|
seen[key] = true
|
||||||
|
lines[#lines + 1] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local family = (videoland and "videoland") or (timetraveler and "timetraveler") or (alg and "alg") or ("qte." .. ((qte.options and qte.options.style) or "windows"))
|
||||||
|
|
||||||
|
use("forge", "family." .. family, frameworkVersion)
|
||||||
|
-- The moves: every row's kind, as the judge it is.
|
||||||
|
local names = {}
|
||||||
|
|
||||||
|
for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do
|
||||||
|
for key, value in pairs(authorLiteral(tableName)) do
|
||||||
|
if KIND_JUDGES[key] and (type(value) == "number") then
|
||||||
|
names[value] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function moves(value)
|
||||||
|
for key, item in pairs(value) do
|
||||||
|
if (key == "moves") and (type(item) == "table") then
|
||||||
|
for _, row in ipairs(item) do
|
||||||
|
if (type(row) == "table") and (type(row[3]) == "number") then
|
||||||
|
local kind = names[row[3]]
|
||||||
|
|
||||||
|
if kind then
|
||||||
|
use("framework", "judge." .. KIND_JUDGES[kind], kind)
|
||||||
|
else
|
||||||
|
-- Not a kind Forge names: the framework's own constants of that value,
|
||||||
|
-- as candidates for what it is.
|
||||||
|
local candidates = {}
|
||||||
|
|
||||||
|
for key, value in pairs(env) do
|
||||||
|
if (value == row[3]) and (type(key) == "string") and key:match("^%u[%u%d]+$") then
|
||||||
|
candidates[#candidates + 1] = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(candidates)
|
||||||
|
use("framework", "judge.unnamed", "kind " .. row[3] .. ((#candidates > 0) and (" (" .. table.concat(candidates, " or ") .. ")") or ""))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif type(item) == "table" then
|
||||||
|
moves(item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
moves(levels)
|
||||||
|
if qte.swaps then
|
||||||
|
use("forge", "scene.swaps")
|
||||||
|
end
|
||||||
|
-- The screens and the named groups.
|
||||||
|
for clip in pairs(clips) do
|
||||||
|
use("forge", "screen." .. clip)
|
||||||
|
end
|
||||||
|
for still in pairs(stills) do
|
||||||
|
use("forge", "still." .. still)
|
||||||
|
end
|
||||||
|
for group, fields in pairs(groups) do
|
||||||
|
for field in pairs(fields) do
|
||||||
|
use("forge", group .. "." .. field)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Persistence.
|
||||||
|
if qte.boards or qte.highScores then
|
||||||
|
use("forge", "persist.boards")
|
||||||
|
end
|
||||||
|
if qte.saves then
|
||||||
|
use("forge", "persist.saves")
|
||||||
|
end
|
||||||
|
if qte.hsDR then
|
||||||
|
use("framework", "persist.dieAndRetryRecords")
|
||||||
|
end
|
||||||
|
-- What is still the framework's: its settings, the globals nothing names, its style, and the
|
||||||
|
-- per-title tweaks and tables of the map-mode lineage.
|
||||||
|
for dip in pairs(dips or {}) do
|
||||||
|
use("framework", "setting." .. dip)
|
||||||
|
end
|
||||||
|
for key in pairs(qte.settings or {}) do
|
||||||
|
use("framework", "global." .. key)
|
||||||
|
end
|
||||||
|
for option in pairs(qte.options or {}) do
|
||||||
|
use((option == "style") and "framework" or "forge", "option." .. option, (option == "style") and qte.options.style or nil)
|
||||||
|
end
|
||||||
|
for tweak in pairs(qte.tweaks or {}) do
|
||||||
|
use("framework", "tweak." .. tweak)
|
||||||
|
end
|
||||||
|
if qte.trophyOrder then
|
||||||
|
use("framework", "order.trophies")
|
||||||
|
end
|
||||||
|
if qte.percents then
|
||||||
|
use("framework", "scoring.percents")
|
||||||
|
end
|
||||||
|
if qte.flows then
|
||||||
|
use("framework", "flows.handWritten", main:gsub("%.singe$", "") .. ".levels.lua")
|
||||||
|
end
|
||||||
|
-- What the game's own Lua still does.
|
||||||
|
if alg or videoland or timetraveler then
|
||||||
|
use("lua", "vocabulary", "Vocabulary.singe")
|
||||||
|
elseif rdg then
|
||||||
|
use("lua", "levelFunctions", main)
|
||||||
|
end
|
||||||
|
-- swapScene is run here, over every level, scene, and difficulty, into scene.swaps (see swaps).
|
||||||
|
for _, hook in ipairs(hooks) do
|
||||||
|
if hook.empty then
|
||||||
|
use("framework", "hook." .. hook.name, "empty")
|
||||||
|
elseif hook.name == "swapScene" then
|
||||||
|
use("forge", "scene.swaps", "read from swapScene")
|
||||||
|
else
|
||||||
|
use("lua", "hook." .. hook.name, "Script/addons.singe")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if qte.script and not (alg or videoland or timetraveler) then
|
||||||
|
use("lua", "script", qte.script)
|
||||||
|
end
|
||||||
|
table.sort(lines)
|
||||||
|
local handle = assert(io.open(path, "w"))
|
||||||
|
|
||||||
|
handle:write(table.concat(lines, "\n") .. "\n")
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
if os.getenv("FORGE_LEDGER") then
|
||||||
|
ledger(os.getenv("FORGE_LEDGER"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if videoland then
|
if videoland then
|
||||||
-- The objects are set up by the game's own reset; the rooms, furniture, and directions
|
-- The objects are set up by the game's own reset; the rooms, furniture, and directions
|
||||||
-- stand as the script declares them.
|
-- stand as the script declares them.
|
||||||
env.resetGameData()
|
env.resetGameData()
|
||||||
put("-- " .. name .. ", ported to Forge from its Adventures in Videoland script by util/forgePortKaris.lua:")
|
put("-- " .. name .. ", ported to Forge from its original script by the port converter:")
|
||||||
put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop")
|
put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop")
|
||||||
put("-- under the console look; both live in Vocabulary.singe beside this file.")
|
put("-- under the console look; both live in Vocabulary.singe beside this file.")
|
||||||
put("return {")
|
put("return {")
|
||||||
put("\ttitle = " .. lua(name) .. ",")
|
put("\ttitle = " .. lua(name) .. ",")
|
||||||
|
putCredits()
|
||||||
put("\tvocabulary = \"Vocabulary.singe\",")
|
put("\tvocabulary = \"Vocabulary.singe\",")
|
||||||
put("\tplayers = 1,")
|
put("\tplayers = 1,")
|
||||||
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
|
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
|
||||||
|
|
@ -724,12 +1121,13 @@ if videoland then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local loop = (timetraveler and "timetraveler") or (alg and "alg") or (timegal and "timegal" or (sdq and "sdq" or (rdg and "rdg" or (kimmy and "kimmy" or "qte"))))
|
local loop = (timetraveler and "timetraveler") or (alg and "alg") or "qte"
|
||||||
|
|
||||||
put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its " .. (timetraveler and "Time Traveler (Singe Edition)" or alg and "American Laser Games" or (timegal and "Time Gal (Singe Edition)" or (rdg and "map-mode" or (kimmy and "Kimmy Script Engine" or (mazescater and "MazescaterFramework" or "KarisFramework"))))) .. " script by util/forgePortKaris.lua:")
|
put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its original script by the port converter:")
|
||||||
put("-- the framework's tables as data for the " .. loop .. " behaviour, which plays the framework's game loop.")
|
put("-- the game's tables as data for the " .. loop .. " behaviour, which plays the game's loop.")
|
||||||
put("return {")
|
put("return {")
|
||||||
put("\ttitle = " .. lua(name) .. ",")
|
put("\ttitle = " .. lua(name) .. ",")
|
||||||
|
putCredits()
|
||||||
if alg then
|
if alg then
|
||||||
-- The title's mechanics and screens are a vocabulary beside its script.
|
-- The title's mechanics and screens are a vocabulary beside its script.
|
||||||
put("\tvocabulary = \"Vocabulary.singe\",")
|
put("\tvocabulary = \"Vocabulary.singe\",")
|
||||||
|
|
@ -739,11 +1137,27 @@ 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("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
|
||||||
put("")
|
put("")
|
||||||
put("\ttypes = {")
|
put("\ttypes = {")
|
||||||
-- No look: the loops expose score, lives, credits, level and prompt as vars for a game's own
|
-- The qte behaviour's games draw their HUD with the qteHud look (FORGE.md 14.15), from the game's
|
||||||
-- hud layer to bind, and draw none of the game's own screens. The hud and <loop>Hud looks this
|
-- own pictures and font; the other loops' games draw none of it yet.
|
||||||
-- line used to name belong to the drawing work the owner parked (FORGE.md 14.15) and are in no
|
put("\t\tgame = { look = { kind = \"" .. ((loop == "qte") and "qteHud" or "none") .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
|
||||||
-- manifest, so naming one only made the runtime complain about a look it does not have.
|
-- The HUD's second font (the options' Font row other than the game's own) is the framework's
|
||||||
put("\t\tgame = { look = { kind = \"none\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
|
-- Media/Font/default.ttf, which the port cannot reach: it travels as the title's Fonts/hudDefault.ttf.
|
||||||
|
if loop == "qte" then
|
||||||
|
local font, found = frameworkFile:gsub("/Script/globals%.singe$", "/Media/Font/default.ttf")
|
||||||
|
local from = (found == 1) and io.open(font, "rb") or nil
|
||||||
|
local into = env.MYDIR .. "/Fonts/hudDefault.ttf"
|
||||||
|
|
||||||
|
if from and not io.open(into, "rb") then
|
||||||
|
local bytes = from:read("a")
|
||||||
|
local handle = io.open(into, "wb") or error("cannot write " .. into)
|
||||||
|
|
||||||
|
handle:write(bytes)
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
if from then
|
||||||
|
from:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
put("\t},")
|
put("\t},")
|
||||||
put("")
|
put("")
|
||||||
put("\trooms = {")
|
put("\trooms = {")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Which Forge ports play from their description alone, with none of the game's own Lua?
|
# 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
|
# The qte behaviour's windows and held styles 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
|
# "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
|
# 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.
|
# with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt.
|
||||||
|
|
@ -34,7 +34,7 @@ HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$')
|
||||||
|
|
||||||
|
|
||||||
def titles():
|
def titles():
|
||||||
"""Every description the qte or kimmy behaviour plays, as (name, family, game argument,
|
"""Every description the qte behaviour plays, as (name, family, game argument,
|
||||||
description path). The library's own are a directory and a script; a Hypseus title is its
|
description path). The library's own are a directory and a script; a Hypseus title is its
|
||||||
folder under hypseus/."""
|
folder under hypseus/."""
|
||||||
found = []
|
found = []
|
||||||
|
|
@ -43,7 +43,10 @@ def titles():
|
||||||
path = os.path.join(root, name)
|
path = os.path.join(root, name)
|
||||||
text = open(path, errors="replace").read()
|
text = open(path, errors="replace").read()
|
||||||
loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text)
|
loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text)
|
||||||
if not loop or loop.group(1) not in ("qte", "kimmy"):
|
if not loop or loop.group(1) != "qte":
|
||||||
|
continue
|
||||||
|
# The segmented styles run the game's own level functions every play: never standalone.
|
||||||
|
if re.search(r'style = "(segments|checkpoints|trophies)"', text):
|
||||||
continue
|
continue
|
||||||
script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M)
|
script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M)
|
||||||
if not script:
|
if not script:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue