Some bug fixes in recent work.

This commit is contained in:
Scott Duensing 2026-09-13 23:48:15 -05:00
parent 9bf4d9beb2
commit 3f69551719
38 changed files with 6017 additions and 1492 deletions

View file

@ -26,8 +26,8 @@ API Changes
the command line keep their values. Every game in a library therefore
behaves the same whether it was started from the menu or from a shell.
- A game can be described rather than written. Singe/Author.singe and
Singe/AuthorCompile.singe take a table of layers, entities, behaviours
- A game can be described rather than written. Forge/Author.singe and
Forge/AuthorCompile.singe take a table of layers, entities, behaviours
and rules and compile it into an ordinary Singe game: the rules become
real Lua, so nothing walks a table every frame and the result can be
read and edited by hand. There is no notion of genre in it -- a game
@ -38,6 +38,19 @@ API Changes
releases, and the "lua" action is the deliberate way out when a rule
needs something the vocabulary cannot say. See the manual.
- Forge runs as a game rather than only as a library: started from the
menu it opens on a chooser (its own descriptions, a new game from a
starter, a copy of anything dropped into its directory), the keys, the
mouse and the pad reach the editor, P saves, builds and plays what is on
screen and comes back to it, and ESC closes or leaves. Entities can be
added, duplicated, deleted and typed field by field -- position, look,
behaviours and their parameters, all from the manifest -- and renaming
one renames it in every rule. Conditions and actions are picked from
the vocabulary with their help beside them, rules reorder, a rule's
note is editable, and U undoes forty steps. The compiler loads the
runtime from Forge/, where it lives, so it no longer fails looking for
a Singe/Author.singe that never shipped.
- An editor for those descriptions, Forge, which is itself
a Singe game: the canvas is the same overlay at the same coordinates the
game will be played in, so what is placed is what is seen. Entity list
@ -1461,6 +1474,59 @@ Fixes
materialNew, sceneGetSize and sceneGetStats no longer reject extra
arguments.
- A review of the week's work (the io patch, the scheduler, Forge, the
menu, the GLES backend, saves and the GUI) fixed what it found:
Lua's io on a packed asset: f:lines and io.lines with more than one
format handed back only the last value read a step; a later format
that comes up empty now returns what came before it with nil in its
place, as Lua does, and only an empty first read ends the loop.
read("a") reads in blocks rather than a byte at a time, read(0) is the
end-of-file test it is in Lua, and a NUL byte is no longer taken for
part of a number. testScripts/packedIo covers all of it.
Timer and tween handles carry a generation: a handle kept past its
timer's end no longer cancels, or answers for, whatever timer was
given the same slot afterwards.
Forge: a rule's keyHeld condition never fired from a real keyboard
(it compared a key name against the scancodes the engine delivers);
an entity whose look kind is unknown is drawn as a box with a message
instead of ending the game; and the compiler loads the runtime from
Forge/, where it lives, rather than a Singe/Author.singe that was never
shipped. The games.dat Forge writes no longer carries a DATA key the
engine does not read, and the manual's example loses it too.
The menu: a games.dat entry with no ATTRACT clip no longer stops the
engine on either renderer, quitting with no games listed no longer
fails in onShutdown, and a directory entry lfs cannot describe is
skipped rather than indexed.
The GLES backend freed nothing when its framebuffer cache filled and
was reset; the framebuffer objects go now. A save is written with a
single atomic rename everywhere but Windows, so the old save stays
whole until the new one is in place. A GUI texture decoded with a
padded pitch is packed row by row instead of uploaded with its padding
read as pixels. The menu's backdrop stops rewriting its grid colours
every frame once the grid is lit.
Net.singe verified a connection against Linux's CA list by path, so
without a pin every HTTPS request failed on Windows, macOS and the
handhelds. The engine now extracts Mozilla's CA list as
Singe/cacerts.pem with the other support files and Net.singe uses it
wherever the platform's own list is missing. A chunked response --
what an HTTP/1.1 server sends when it does not know the length -- is
unwrapped as it arrives rather than handed to the caller with the chunk
sizes still in it. And a mistyped value in a table given to scriptPush
or scriptExecute is reported like any other bad API argument -- the
script line and the call named -- rather than as a bare exit message.
- Forge has its own manual, docs/Forge.adoc, built to Forge.html and
Forge.pdf beside the Singe manual; the engine's manual keeps a pointer.
The build packs Forge.game itself, with the manual inside, and Forge's
first run copies Forge.pdf out to its data directory and says where.
R redoes what U undid, forty steps deep.
SINGE 2.10

View file

@ -209,6 +209,7 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h
# its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime
# a game it builds loads -- belongs inside Singe.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/cacerts.pem ${GENERATED_DIR}/cacerts_pem.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Backdrop.singe ${GENERATED_DIR}/Backdrop_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
@ -295,6 +296,18 @@ singeEmbedLua(thirdparty/lume/lume.lua "")
singeEmbedLua(thirdparty/inspect.lua/inspect.lua "")
singeEmbedLua(thirdparty/bump.lua/bump.lua "")
# Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into
# Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge.
add_custom_command(
OUTPUT ${MANUAL_DIR}/Forge.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
DEPENDS docs/Forge.adoc
COMMENT "Rendering Forge.pdf"
VERBATIM
)
add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf)
# Optional HTML manual for browsing: cmake --build . --target docs
if(ASCIIDOCTOR)
add_custom_target(docs
@ -819,7 +832,6 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_BINARY_DIR}/gen
# Rockchip's hardware decoders are reached by name (videoPlayer.c), and only exist when FFmpeg was
# built against the Rockchip MPP library. Asking FFmpeg itself is what decides it.
execute_process(COMMAND ${CMAKE_COMMAND} -E echo "" OUTPUT_QUIET)
if(EXISTS ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc)
file(READ ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc avcodecPc)
if(avcodecPc MATCHES "rockchip_mpp")
@ -857,9 +869,9 @@ endif()
# System libraries follow the static ones so their symbols resolve for everything above.
# Apple's linker has no --start-group, so the list is repeated there instead.
if(KANGAROO_OS STREQUAL "macos")
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE ${STATIC_LIBS} ${STATIC_LIBS} ${STATIC_LIBS} Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Debugger RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE ${STATIC_LIBS} ${STATIC_LIBS} ${STATIC_LIBS} Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
else()
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE -Wl,--start-group ${STATIC_LIBS} -Wl,--end-group Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Debugger RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE -Wl,--start-group ${STATIC_LIBS} -Wl,--end-group Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
endif()

View file

@ -67,3 +67,9 @@ Fonts
-----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
Data
----
Mozilla CA list 20260601 (Debian ca-certificates) MPL-2.0 https://wiki.mozilla.org/CA (assets/cacerts.pem)

View file

@ -134,6 +134,7 @@ local backdropStarted = 0 -- singeGetTicks() when the backdrop's clock began
local backdropTagX = 0
local backdropTagY = 0
local blown = false -- Whether the charge has gone off.
local gridLit = false -- Whether the grid has finished coming up and its colours are final.
local backdropFont = nil
-- ===== The backdrop ==========================================================================
@ -473,7 +474,6 @@ function backdropFrame()
emitterBurst(core, 110)
emitterBurst(flash, 5)
emitterBurst(sparks, 130)
lightSetIntensity(fireLight, 400)
end
-- The fire's light dies over the first second and a half.
@ -483,13 +483,15 @@ function backdropFrame()
-- there is no moment where one thing has ended and the next has not begun.
local lit = span(t, GRID_UP, GRID_LIT)
if (lit > 0) and (lit < 1.0001) then
if (lit > 0) and not gridLit then
materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit)
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * lit)
-- The sky stops being black at the same time, or the grid would rise into a void.
sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
-- Once fully lit the colours are final, so they are not written again every frame.
gridLit = (lit >= 1)
end
if lit > 0 then
-- Scrolling from the moment it is visible, so it is already alive when it arrives.
@ -579,6 +581,7 @@ function backdropBegin(showIntro)
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
blown = not showIntro
gridLit = false
if not showIntro then
nodeSetVisible(logo, false)
end

View file

@ -25,7 +25,7 @@
-- The runtime an authored game is compiled against (PLAN section 58).
--
-- A game made with the authoring tools is not interpreted: Singe/AuthorCompile.singe turns its
-- A game made with the authoring tools is not interpreted: Forge/AuthorCompile.singe turns its
-- description into ordinary Lua, and this file is the library that Lua calls. Rules become real
-- `if` statements, so nothing walks a table every frame and the result can be opened in ZeroBrane
-- and edited by hand like any other game.
@ -163,7 +163,9 @@ AUTHOR.behaviours.solid = {
help = "Immovable ground or a wall.",
params = {},
attach = function(entity)
bodyNew(entity.node, BODY_STATIC, SHAPE_BOX, entity.look.w / 2, entity.look.h / 2, entity.look.w / 2)
local w, h = authorSize(entity)
bodyNew(entity.node, BODY_STATIC, SHAPE_BOX, w / 2, h / 2, w / 2)
end
}
@ -171,9 +173,11 @@ AUTHOR.behaviours.platformer = {
help = "Runs, falls and jumps: the engine's character controller in 2D.",
params = { speed = "number", jump = "number" },
attach = function(entity, params)
local w, h = authorSize(entity)
entity.speed = params.speed or 200
entity.jump = params.jump or 500
playerNew(entity.node, SHAPE_CAPSULE, entity.look.w * PLAYER_RADIUS, entity.look.h)
playerNew(entity.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h)
end,
step = function(entity)
-- The rules say which way; this clears it each frame so releasing a key stops the run.
@ -205,7 +209,7 @@ AUTHOR.behaviours.drift = {
AUTHOR.conditions.keyHeld = {
help = "A key is down.",
params = { key = "scancode" },
emit = function(p) return string.format("authorKeyHeld(SCANCODE.%s)", p.key) end
emit = function(p) return string.format("authorKeyHeld(SCANCODE.%s.value)", p.key) end
}
AUTHOR.conditions.switchHeld = {
@ -368,9 +372,23 @@ function authorTouching(idA, idB)
local b = AUTHOR_WORLD[idB]
local ax, ay = authorPosition(a)
local bx, by = authorPosition(b)
local aw, ah = authorSize(a)
local bw, bh = authorSize(b)
return collideRects(ax - a.look.w / 2, ay - a.look.h / 2, a.look.w, a.look.h,
bx - b.look.w / 2, by - b.look.h / 2, b.look.w, b.look.h)
return collideRects(ax - aw / 2, ay - ah / 2, aw, ah, bx - bw / 2, by - bh / 2, bw, bh)
end
-- How big an entity is, for touching and for the body a behaviour gives it. A box says; a sprite
-- is its image; a text look has no size of its own and counts as a point unless it was given one.
function authorSize(entity)
local look = entity.look
if entity.sprite ~= nil then
return spriteGetWidth(entity.sprite), spriteGetHeight(entity.sprite)
end
return look.w or 0, look.h or 0
end
@ -431,9 +449,17 @@ function authorMake(description)
node = nodeNew()
}
local look = AUTHOR.looks[entity.look.kind]
local b
nodeSetPosition(entity.node, description.x or 0, description.y or 0, 0)
if look == nil then
-- A look that does not exist would take the game down when it came to be drawn; a box
-- is what it wears instead, and the console says which entity it was.
debugPrint("Author: no look called '" .. tostring(entity.look.kind) .. "' on " .. tostring(entity.id) .. "; drawing a box")
entity.look.kind = "box"
entity.look.w = entity.look.w or 20
entity.look.h = entity.look.h or 20
look = AUTHOR.looks.box
end
if look.load then
look.load(entity)
end
@ -458,8 +484,6 @@ end
-- Starts the layers the game declared. Anything a layer needs of the engine is asked for here and
-- nowhere else, which is what keeps the rest of this file free of genre.
function authorBegin(layers)
local layer
-- A font, because the text look draws with fontPrint and fontPrint ends the game when none is
-- selected. Every test had a scene select one first; a released game has nobody to do that,
-- and died on its first text entity. A game that wants its own calls fontSelect afterwards.
@ -489,12 +513,9 @@ end
function authorFrame(rules)
local now = authorTime()
local dt = now - lastTime
local entity
lastTime = now
for _, entity in ipairs(AUTHOR_ORDER) do
local b
for _, b in ipairs(entity.behaviours) do
local kind = AUTHOR.behaviours[b.kind]

View file

@ -33,10 +33,14 @@
-- This is written in Lua rather than in util/ as a Python script because the editor is itself a
-- Singe game: it has to be able to compile what it is editing without leaving the engine. It
-- lives with Forge rather than in Singe/ because only building needs it -- a finished game is
-- already Lua. Singe/Author.singe, the runtime that game loads, has to stay an engine support
-- file: a game handed to someone who has never installed Forge still has to run.
-- already Lua. Author.singe, the runtime that game loads, travels with the game: a copy is
-- written beside it when it is built, so a game handed to someone who has never installed Forge
-- still runs. The compiler loads it too, for the vocabulary it emits against.
dofile("Singe/Author.singe")
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe"
dofile(AUTHOR_RUNTIME)
local INDENT = "\t"
@ -58,7 +62,6 @@ end
local function tableSource(t)
local parts = {}
local keys = {}
local key
for key in pairs(t) do
keys[#keys + 1] = key
@ -88,7 +91,6 @@ end
local function emitConditions(rule)
local tests = {}
local item
for _, item in ipairs(rule.when or {}) do
local test = emitOne(AUTHOR.conditions, item, "condition")
@ -108,8 +110,6 @@ end
local function emitRule(out, rule, index)
local item
out[#out + 1] = string.format("%s-- %s", INDENT, rule.note or ("rule " .. index))
out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule))
for _, item in ipairs(rule.act or {}) do
@ -131,11 +131,8 @@ end
-- the engine's, as it is for every game ever written for Singe.
function authorCompile(game)
local out = {}
local entity
local layer
local rule
out[#out + 1] = "-- Generated by Singe/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then"
@ -160,7 +157,6 @@ function authorCompile(game)
for _, entity in ipairs(game.entities or {}) do
local parts = {}
local b
parts[#parts + 1] = string.format("id = %q", entity.id)
parts[#parts + 1] = string.format("x = %s, y = %s", entity.x or 0, entity.y or 0)
@ -226,10 +222,6 @@ function authorCopy(fromPath, toPath)
end
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe"
-- The directory part of a path, with its separator, or "" for a bare name.
local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "")
@ -267,9 +259,6 @@ end
-- Whether a table holds only leaves, in which case it is written on one line. A rule reads far
-- better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed.
local function isLeaf(t)
local key
local value
for key, value in pairs(t) do
if type(value) == "table" then
return false
@ -288,8 +277,6 @@ local function valueSource(value, depth)
local parts = {}
local keys = {}
local count = 0
local key
local item
local sep
local open
local close
@ -340,7 +327,6 @@ end
function authorSave(game, path)
local file = assert(io.open(path, "w"))
local copy = {}
local key
-- source says where a description came from and is set by the loader, so writing it back out
-- would make a description that had been through the editor differ from one that had not.
@ -349,7 +335,7 @@ function authorSave(game, path)
copy[key] = game[key]
end
end
file:write("-- Written by Singe/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n")
file:write("-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n")
file:write("return " .. valueSource(copy, 0) .. "\n")
file:close()

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,6 @@ GAMES = {
SCRIPT = "Forge/Forge.singe",
LEGACY_SPRITE_ARGS = false,
VIDEO = "Singe/menuBackground.mkv",
DATA = "Forge",
STRETCH = false,
NO_MOUSE = false,
RESOLUTION_X = 720,

View file

@ -81,12 +81,18 @@ end
function loadGameAssets()
local game = GAME_LIST[GAME_SELECTED]
-- An entry without an attract clip, or naming one that is not there, shows its art and no
-- video rather than taking the whole menu down with a videoLoad that cannot open it.
if menuFileExists(game.ATTRACT) then
VIDEO_ATTRACT = videoLoad(game.ATTRACT)
if game.AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK)
end
videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START or 0)
else
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
end
MENU_RENDER.showGame(game)
end
@ -96,18 +102,26 @@ end
MENU_MISSING_ART = "Singe/missing.png"
-- A readable art path: the game's own when it can be opened, the missing-art picture when not.
function menuArtPath(file)
-- Whether a name is a file the engine can open. Asked of the index rather than of the file:
-- io.open, which this used to use, has to hand Lua a real file it can read with the C library, so
-- every picture it was asked about was unpacked out of the .game into data/<game>/cache first --
-- and the menu asks about two of them for every game in the library, none of which it then opens
-- that way: the sprite loader and the document's texture loader both read straight out of the
-- database. lfs.attributes goes through the same virtual filesystem and answers from the index,
-- opening nothing.
function menuFileExists(file)
local path = tostring(file or "")
local found = (path ~= "") and lfs.attributes(path) or nil
-- Asked of the index rather than of the file. io.open, which this used to use, has to hand
-- Lua a real file it can read with the C library, so every picture it was asked about was
-- unpacked out of the .game into data/<game>/cache first -- and the menu asks about two of
-- them for every game in the library, none of which it then opens that way: the sprite loader
-- and the document's texture loader both read straight out of the database. lfs.attributes
-- goes through the same virtual filesystem and answers from the index, opening nothing.
if (found == nil) or (found.mode == "directory") then
return (found ~= nil) and (found.mode ~= "directory")
end
-- A readable art path: the game's own when it can be opened, the missing-art picture when not.
function menuArtPath(file)
local path = tostring(file or "")
if not menuFileExists(path) then
if path ~= "" then
debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.")
end
@ -209,8 +223,10 @@ function onOverlayUpdate()
end
-- The attract clip runs between the two frames the game's entry names.
if VIDEO_ATTRACT and GAME_LIST[GAME_SELECTED].ATTRACT_END then
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START)
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START or 0)
end
end
MENU_RENDER.frame()
end
@ -221,6 +237,10 @@ end
function onShutdown()
-- With no games found there is no renderer and nothing was loaded; quitting is all there is.
if MENU_RENDER == nil then
return
end
unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH)
MENU_RENDER.finish()
@ -306,7 +326,7 @@ for dir in lfs.dir(".") do
local dirattr = lfs.attributes(dir)
if dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
elseif dirattr.mode == "directory" then
elseif dirattr and (dirattr.mode == "directory") then
for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil)

View file

@ -69,8 +69,6 @@ local detailClock = 0
local SCROLL_HOLD = 3.0 -- Seconds it rests at each end before moving, as in the overlay
local SCROLL_STEP = 1.1 -- renderer, and seconds a step while it is moving.
local SCROLL_LINE = 16 -- How far a step goes: a line of the description.
local backdropRoot = nil
local backdropGrid = nil
function menuElement(id)
@ -358,7 +356,9 @@ end
MENU_RENDER.frame = function()
-- Attract Mode Video, under the hole the document leaves for it
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
if scrollPending then
menuScrollToSelection()
end

View file

@ -430,7 +430,9 @@ MENU_RENDER.frame = function()
spriteDraw(spriteMarquee, x, y)
-- Attract mode video
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
-- Which game this is, since there is no list to look at. It heads the details rather than
-- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.

View file

@ -39,6 +39,11 @@
--
-- 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.
--
-- Without a pin the chain is verified against a CA list. Linux has one; Windows, macOS and the
-- handhelds have nothing LuaSec can read, so the engine extracts Mozilla's list as
-- Singe/cacerts.pem beside the other support files and that is used wherever the platform's own
-- is missing.
local socket = require("socket")
@ -53,6 +58,9 @@ NET_REDIRECT_MAX = 5
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
NET_PINS = {}
NET_CA_SYSTEM = "/etc/ssl/certs/ca-certificates.crt" -- Linux's list, when it is there,
NET_CA_SHIPPED = "Singe/cacerts.pem" -- and the one Singe carries otherwise.
local active = {}
local nextId = 1
@ -149,17 +157,28 @@ local function buildRequest(request)
end
-- The CA list a connection without a pin is verified against.
local function caFile()
local lfs = require("lfs")
if lfs.attributes(NET_CA_SYSTEM, "mode") == "file" then
return NET_CA_SYSTEM
end
return NET_CA_SHIPPED
end
local function startTls(request)
local params = {
mode = "client",
protocol = "any",
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
-- what decides. Without one, the host's own store is asked -- and if it has nothing to
-- say, the request fails rather than proceeding unverified.
-- what decides. Without one, a CA list is asked -- and if it has nothing to say, the
-- request fails rather than proceeding unverified.
verify = (#NET_PINS > 0) and "none" or "peer",
capath = "/etc/ssl/certs",
cafile = "/etc/ssl/certs/ca-certificates.crt",
cafile = caFile(),
}
local wrapped, err = ssl.wrap(request.socket, params)
@ -238,6 +257,12 @@ local function splitHead(request)
end
request.length = tonumber(request.responseHeaders["content-length"])
request.got = 0
-- HTTP/1.1 may send the body in sized pieces instead of announcing a length; those are
-- unwrapped as they arrive and the last, empty, piece is the end of the body.
request.chunked = (request.responseHeaders["transfer-encoding"] or ""):lower():find("chunked", 1, true) ~= nil
request.chunkBuffer = ""
request.chunkLeft = nil
request.chunkDone = false
if request.toFile then
local file, err = io.open(request.toFile, "wb")
if not file then
@ -263,6 +288,46 @@ local function consume(request, data)
end
-- Body bytes arriving under chunked transfer encoding: a hexadecimal size on a line, that many
-- bytes, a blank line, again until a size of zero. Whatever is left over waits for the next read.
local function consumeChunked(request, data)
request.chunkBuffer = request.chunkBuffer .. data
while not request.chunkDone do
if request.chunkLeft == nil then
local at = request.chunkBuffer:find("\r\n", 1, true)
if not at then
return
end
local size = tonumber(request.chunkBuffer:sub(1, at - 1):match("^%x+"), 16)
request.chunkBuffer = request.chunkBuffer:sub(at + 2)
if size == nil then
return fail(request, "the server sent a chunk with no size")
end
if size == 0 then
request.chunkDone = true
return
end
request.chunkLeft = size
elseif request.chunkLeft > 0 then
local take = request.chunkBuffer:sub(1, request.chunkLeft)
if #take == 0 then
return
end
request.chunkBuffer = request.chunkBuffer:sub(#take + 1)
request.chunkLeft = request.chunkLeft - #take
consume(request, take)
else
-- The blank line after a chunk's bytes.
if #request.chunkBuffer < 2 then
return
end
request.chunkBuffer = request.chunkBuffer:sub(3)
request.chunkLeft = nil
end
end
end
local function complete(request)
local body = request.file and "" or table.concat(request.parts)
@ -277,6 +342,7 @@ local function complete(request)
local _, _, port, path = parseUrl(location)
request.port, request.path = port, path
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0
request.chunked, request.chunkBuffer, request.chunkLeft, request.chunkDone = false, "", nil, false
if request.socket then pcall(function() request.socket:close() end) end
request.socket = nil
return beginConnect(request)
@ -298,13 +364,22 @@ local function pumpReading(request)
if splitHead(request) and #request.buffer > 0 then
local rest = request.buffer
request.buffer = ""
if request.chunked then
consumeChunked(request, rest)
else
consume(request, rest)
end
end
elseif request.chunked then
consumeChunked(request, got)
else
consume(request, got)
end
end
if request.responseHeaders and request.length and request.got >= request.length then
if not active[request.id] then
return -- A bad chunk failed the request from inside consumeChunked.
end
if request.responseHeaders and ((request.length and request.got >= request.length) or request.chunkDone) then
return complete(request)
end
if err == "closed" then

View file

@ -1214,7 +1214,7 @@ end
-- A .game installed here that the catalogue no longer lists. Without this it would vanish from
-- the page the moment it was withdrawn, leaving a player with a game they can play, cannot update,
-- and cannot remove from anywhere but a file manager.
function shopAddWithdrawn()
local function shopAddWithdrawn()
local listed = {}
for _, game in ipairs(shopGames) do

3004
assets/cacerts.pem Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,19 @@
#!/usr/bin/env bash
# Build the Singe manual from docs/Manual.adoc.
# Produces .builddir/Manual.html and .builddir/Manual.pdf. The version comes from CMakeLists.txt.
# Build the Singe manual from docs/Manual.adoc and the Forge manual from docs/Forge.adoc.
# Produces .builddir/Manual.html, Manual.pdf, Forge.html and Forge.pdf. The version comes from
# CMakeLists.txt.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
src=$here/docs/Manual.adoc
forge=$here/docs/Forge.adoc
if [[ ! -f $src ]]; then
echo "error: $src not found" >&2
for doc in "$src" "$forge"; do
if [[ ! -f $doc ]]; then
echo "error: $doc not found" >&2
exit 1
fi
done
for tool in asciidoctor asciidoctor-pdf; do
if ! command -v "$tool" > /dev/null; then
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
@ -23,6 +27,8 @@ out=$here/.builddir
mkdir -p "$out"
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html"
asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf"
asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html"
asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf"
echo "built:"
ls -la "$out/Manual.html" "$out/Manual.pdf"
ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Forge.html" "$out/Forge.pdf"

View file

@ -457,18 +457,23 @@ ExternalProject_Add(singe
# Forge, packed into .builddir beside the binary so it can be copied out and tested. It is NOT
# part of the engine and nothing of it is embedded; this only puts the distributable where the
# binaries already are. Only a build that produces a runnable binary can pack it, since packing is
# the engine's own --pack, so cross builds skip it and the host build does the work.
# the engine's own --pack, so cross builds skip it and the host build does the work. The manual,
# rendered by the engine's own build into .builddir, goes in beside the scripts: Forge copies it
# out to its data directory on its first run.
if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux"))
file(GLOB forgeSources ${CMAKE_SOURCE_DIR}/assets/Forge/*)
set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game)
set(forgeManual ${CMAKE_SOURCE_DIR}/.builddir/Forge.pdf)
set(forgeStage ${SB_PREFIX}/forgepack)
add_custom_command(
OUTPUT ${forgePack}
# Packing refuses to overwrite, and a stale pack is worse than none.
COMMAND ${CMAKE_COMMAND} -E rm -f ${forgePack}
COMMAND ${CMAKE_COMMAND} -E make_directory ${SB_PREFIX}/forgepack
COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage}
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets/Forge ${forgeStage}/Forge
COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf
# Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory.
COMMAND ${CMAKE_COMMAND} -E chdir ${SB_PREFIX}/forgepack ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${CMAKE_SOURCE_DIR}/assets/Forge ${forgePack}
DEPENDS singe ${forgeSources}
COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack}
DEPENDS singe ${forgeSources} ${forgeManual}
COMMENT "Packing Forge into .builddir/Forge.game"
VERBATIM
)

253
docs/Forge.adoc Normal file
View file

@ -0,0 +1,253 @@
= Forge
Scott Duensing <scott@kangaroopunch.com>
:revnumber: 3.00
:revdate: 2026
:doctype: book
:toc: left
:toclevels: 3
:sectnums:
:sectnumlevels: 3
:source-highlighter: rouge
:icons: font
:experimental:
[preface]
== About Forge
Forge is Singe's authoring tool: a way to make a game by describing it --
placing things, attaching behaviours, writing rules -- and having ordinary
Singe Lua written for you. It is itself a Singe game and is distributed on
its own, beside the engine rather than inside it. This document covers the
description format, the vocabulary, the compiler and the editor. The engine
calls the generated code makes (`playerNew`, `collidePointRect`,
`onKeyPressed`, `scriptPush` and the rest) are documented in the Singe Manual.
== Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and
rules -- and compiled into an ordinary Singe game.
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime
the result calls. **No part of Forge ships with Singe** -- not the editor, not
the compiler, not the runtime -- so a game built with it carries its own copy of
that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules
become real Lua `if` statements, so a description costs nothing per frame on a
Raspberry Pi, and the game it produces can be opened, read and edited by hand
like any other.
There is no notion of genre anywhere in it. A game declares which of the
engine's own layers it uses, and that is the only difference between a light
gun game, a platformer and a quick-time event over video.
=== The three nouns
*Layers* are what the game draws through: `world2d` (physics in the XY plane,
drawn into the overlay), `overlay` (flat drawing over everything) and `disc`
(the video the game is played over). A game lists the ones it wants.
*Entities* are things on a layer. Each has a position, a `look` (`box`,
`sprite` or `text`) and any number of behaviours. Every entity is a node,
whether or not the 3D scene is drawing, which is what lets a 2D game built this
way run on a machine with no GPU.
*Behaviours* are bundles over engine calls that already exist: `platformer` is
the character controller (`playerNew` and friends), `solid` is a
static body, `drift` moves at a constant velocity. Attaching one is a line in
the description rather than code.
*Rules* are conditions and actions. Every rule is tested every frame, in the
order written, and all of a rule's conditions must hold for its actions to run.
.A description, in full
[source,lua]
----
return {
title = "One rule",
layers = { { kind = "world2d", gravity = 1500 } },
entities = {
{ id = "ground", x = 360, y = 440,
look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "hero", x = 120, y = 380,
look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }
},
rules = {
{ note = "Run right",
when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } }
}
}
----
Compile it and run what comes out:
[source,lua]
----
dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe"))
----
`testScripts/author/platformer.game` and `testScripts/author/qte.game` are
worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile
and play them.
=== The vocabulary, and adding to it
Conditions and actions are not built into the compiler. Each is an entry in
the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind
of game is a set of entries rather than a new release. The conditions today
are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`,
`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`,
`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`.
`once` deserves a word. Rules run every frame, so anything that should happen
a single time -- a door opening, a score awarded -- needs it:
[source,lua]
----
{ when = { { "touching", entity = "hero", other = "prize" },
{ "once", tag = "prize" } },
act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false } } }
----
=== The way out
The `lua` action takes a line of Lua and emits it as it stands. It is there on
purpose: when a rule needs something the vocabulary cannot say, that rule drops
to Lua and the rest of the game is unaffected. A description is a convenience,
not a cage, and the compiled output is a normal game you can stop describing
and start editing whenever it suits you.
=== The editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
its own directory beside the games, appears in the menu like one, and packs to
`Forge.game` with `--pack`; the build does that itself (the `forge` target),
with this manual inside.
Nothing in it is a preview: the canvas is the same overlay at the same
coordinates the game will be played in, so what is placed is what is seen.
Started from the menu it opens on a chooser: the descriptions in its data
directory, any dropped into the `Forge` directory itself (those are opened as a
copy, since inside a `.game` they are read only), and *New game*, which writes
a starter -- ground, a hero that runs and jumps, a score readout -- and opens
it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into
that data directory, and the chooser says where it is. `Esc` in the editor closes the description (twice, when it has unsaved
changes) and `Esc` on the chooser leaves Forge. `P` plays: the description is
saved, compiled beside a copy of the runtime, and handed to the engine with
`scriptPush`; when the game ends Forge comes back on the same file.
A script can drive the editor instead, which is how the test scenes do it:
[source,lua]
----
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
forgeBegin("mygame.game")
function onOverlayUpdate()
local x, y = mouseGetPosition(0)
forgeDraw(x, y)
return OVERLAY_UPDATED
end
----
The entity list and the details are an RmlUi document; the canvas beside them is
drawn into the overlay and picked with `collidePointRect`.
The two compose because the engine offers a button to the GUI first and passes
on what it did not use, while pointer motion is never consumed at all -- so
point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse
callbacks and clicks on the panels will not reach the canvas.
`forgeSave()` writes the description back, `forgeBuild(path)`
compiles what is on screen (and puts the runtime beside it, so the result
plays), and `forgeMove(index, x, y)` moves an entity without a pointer, which
is how `testScripts/scene54.singe` drives it.
=== Editing the entities
`A` adds a box in the middle of the canvas, `D` duplicates the selected entity
a little to one side, and `Delete` removes it. `Enter` walks the entity's
fields the same way it walks a rule's parameters: `id`, `x`, `y`, the look's
`kind`, then whatever that look takes, then `behaviours` -- typed as a list of
kinds, `platformer, solid` -- and then each behaviour's own parameters as
`platformer.speed` and so on. The fields come from the `AUTHOR` manifest, so a
new look or behaviour is editable the moment it is declared. Renaming an
entity renames it in every rule that talks about it. A `sprite` look is drawn
with its image once the file name is right, and as a box until then.
From a script, `forgeEntityAdd(x, y)`, `forgeEntityDuplicate()`,
`forgeEntityDelete()` and `forgeEntitySet(entity, field, value)` do the same.
=== Editing the rules
The panel shows either the entities or the event sheet; `Tab` swaps them. In
the rules, the selected rule opens in place and its conditions and actions are
listed under it, because a rule only means anything whole -- a `when` without a
`then` tells you nothing.
Point `onKeyPressed` at `forgeKey` and the whole editor
works without a pointer, which is how the bundled menu has always been driven
and what a cabinet wants:
[cols="1,4"]
|===
| `Tab` | entities or rules
| Up, Down | move through the list, and through the parts of the open rule
| Left, Right | slide the panel
| `Enter` | type a value for whatever is selected; again for its next value
| `Esc` | put the value back
| `A`, `D`, `Delete` | add, duplicate, delete the selected entity
| `N` | a new rule
| `C`, `T` | add a condition, an action -- picked from the vocabulary, with its help beside it
| `[`, `]` | move the rule up or down the sheet
| `Delete` | delete the selected rule, or the selected condition or action
| `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history
| `S`, `B`, `P` | save, build, play
|===
With the rule itself selected rather than one of its parts, `Enter` edits its
note.
A typed number comes back a number rather than a string, because `100` and
`"100"` compile to different source and a description that changed shape when a
value was retyped would stop round-tripping.
From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act",
name)`, `forgePartSet(key, value)`, `forgePartDelete()`, `forgeRuleMove(by)`,
`forgeUndo()` and `forgeRedo()` do the same work. Adding a condition selects it, so its
parameters can be set at once.
The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs
changing when a condition or an action is added: it offers whatever is
declared. A new parameter gets a sensible starting value for its type, so a
rule compiles the moment it is made rather than only once every field is
filled in.
A description survives the round trip: loading one, saving it and loading it
again compiles to the same game, byte for byte. The editor depends on that and
the test asserts it.
=== Releasing a game
`forgeExport(folder, name)` writes everything a finished game needs into a
directory of its own: the compiled script, a `games.dat` so the menu lists it,
the description it was built from so it can be opened again, and **a copy of
the runtime**, taken out of Forge. `--pack` turns that directory into a `.game`
like any other, and it runs on a machine that has never had Forge on it.
Every built game finds its own directory to load that runtime from, using
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
engine was *launched* with, so a game reached by `dofile` -- a test, a
launcher, a preview -- would otherwise look beside the caller.
The panel slides. Drag the tab on its outer edge and it moves across the
window, so an entity that lives underneath it -- a score readout at `12, 12`
does -- is never permanently out of reach. `forgePanelTo(x)` moves it from
a script, and `forgeOverPanel(x)` says whether a point is currently
covered.
[#migrating]

View file

@ -1421,7 +1421,6 @@ GAMES = {
TITLE = ".38 Ambush Alley",
SCRIPT = "ActionMax/38AmbushAlley.singe",
VIDEO = "ActionMax/frame_38AmbushAlley.txt",
DATA = "ActionMax",
STRETCH = false,
NO_MOUSE = false,
RESOLUTION_X = 720,
@ -3192,195 +3191,12 @@ die.
=== Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and
rules -- and compiled into an ordinary Singe game.
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime
the result calls. **No part of Forge ships with Singe** -- not the editor, not
the compiler, not the runtime -- so a game built with it carries its own copy of
that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules
become real Lua `if` statements, so a description costs nothing per frame on a
Raspberry Pi, and the game it produces can be opened, read and edited by hand
like any other.
rules -- and compiled into an ordinary Singe game by Forge, the authoring
tool. Forge is distributed on its own, beside the engine, and has its own
manual: `docs/Forge.adoc` in the source tree, built to `Forge.html` and
`Forge.pdf` beside this one.
There is no notion of genre anywhere in it. A game declares which of the
engine's own layers it uses, and that is the only difference between a light
gun game, a platformer and a quick-time event over video.
==== The three nouns
*Layers* are what the game draws through: `world2d` (physics in the XY plane,
drawn into the overlay), `overlay` (flat drawing over everything) and `disc`
(the video the game is played over). A game lists the ones it wants.
*Entities* are things on a layer. Each has a position, a `look` (`box`,
`sprite` or `text`) and any number of behaviours. Every entity is a node,
whether or not the 3D scene is drawing, which is what lets a 2D game built this
way run on a machine with no GPU.
*Behaviours* are bundles over engine calls that already exist: `platformer` is
the character controller (<<playernew,playerNew>> and friends), `solid` is a
static body, `drift` moves at a constant velocity. Attaching one is a line in
the description rather than code.
*Rules* are conditions and actions. Every rule is tested every frame, in the
order written, and all of a rule's conditions must hold for its actions to run.
.A description, in full
[source,lua]
----
return {
title = "One rule",
layers = { { kind = "world2d", gravity = 1500 } },
entities = {
{ id = "ground", x = 360, y = 440,
look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } },
{ id = "hero", x = 120, y = 380,
look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }
},
rules = {
{ note = "Run right",
when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } }
}
}
----
Compile it and run what comes out:
[source,lua]
----
dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe"))
----
`testScripts/author/platformer.game` and `testScripts/author/qte.game` are
worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile
and play them.
==== The vocabulary, and adding to it
Conditions and actions are not built into the compiler. Each is an entry in
the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind
of game is a set of entries rather than a new release. The conditions today
are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`,
`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`,
`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`.
`once` deserves a word. Rules run every frame, so anything that should happen
a single time -- a door opening, a score awarded -- needs it:
[source,lua]
----
{ when = { { "touching", entity = "hero", other = "prize" },
{ "once", tag = "prize" } },
act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false } } }
----
==== The way out
The `lua` action takes a line of Lua and emits it as it stands. It is there on
purpose: when a rule needs something the vocabulary cannot say, that rule drops
to Lua and the rest of the game is unaffected. A description is a convenience,
not a cage, and the compiled output is a normal game you can stop describing
and start editing whenever it suits you.
==== The editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
its own directory beside the games, appears in the menu like one, and packs to
`Forge.game` with `--pack`.
Nothing in it is a preview: the canvas is the same overlay at the same
coordinates the game will be played in, so what is placed is what is seen.
[source,lua]
----
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
forgeBegin("mygame.game")
function onOverlayUpdate()
local x, y = mouseGetPosition(0)
forgeDraw(x, y)
return OVERLAY_UPDATED
end
----
The entity list and the details are an RmlUi document; the canvas beside them is
drawn into the overlay and picked with <<collidepointrect,collidePointRect>>.
The two compose because the engine offers a button to the GUI first and passes
on what it did not use, while pointer motion is never consumed at all -- so
point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse
callbacks and clicks on the panels will not reach the canvas.
`forgeSave()` writes the description back, `forgeBuild(path)`
compiles what is on screen, and `forgeMove(index, x, y)` moves an entity
without a pointer, which is how `testScripts/scene54.singe` drives it.
==== Editing the rules
The panel shows either the entities or the event sheet; `Tab` swaps them. In
the rules, the selected rule opens in place and its conditions and actions are
listed under it, because a rule only means anything whole -- a `when` without a
`then` tells you nothing.
Point <<onkeypressed,onKeyPressed>> at `forgeKey` and the whole editor
works without a pointer, which is how the bundled menu has always been driven
and what a cabinet wants:
[cols="1,4"]
|===
| `Tab` | entities or rules
| Up, Down | move through the list, and through the parts of the open rule
| Left, Right | slide the panel
| `Enter` | type a value for the selected condition or action; again for its next value
| `Esc` | put the value back
| `N` | a new rule
| `Delete` | delete the selected rule, or the selected condition or action
| `S`, `B` | save, build
|===
A typed number comes back a number rather than a string, because `100` and
`"100"` compile to different source and a description that changed shape when a
value was retyped would stop round-tripping.
From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act",
name)`, `forgePartSet(key, value)` and `forgePartDelete()` do the
same work. Adding a condition selects it, so its parameters can be set at once.
The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs
changing when a condition or an action is added: it offers whatever is
declared. A new parameter gets a sensible starting value for its type, so a
rule compiles the moment it is made rather than only once every field is
filled in.
A description survives the round trip: loading one, saving it and loading it
again compiles to the same game, byte for byte. The editor depends on that and
the test asserts it.
==== Releasing a game
`forgeExport(folder, name)` writes everything a finished game needs into a
directory of its own: the compiled script, a `games.dat` so the menu lists it,
the description it was built from so it can be opened again, and **a copy of
the runtime**, taken out of Forge. `--pack` turns that directory into a `.game`
like any other, and it runs on a machine that has never had Forge on it.
Every built game finds its own directory to load that runtime from, using
`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the
engine was *launched* with, so a game reached by `dofile` -- a test, a
launcher, a preview -- would otherwise look beside the caller.
The panel slides. Drag the tab on its outer edge and it moves across the
window, so an entity that lives underneath it -- a score readout at `12, 12`
does -- is never permanently out of reach. `forgePanelTo(x)` moves it from
a script, and `forgeOverPanel(x)` says whether a point is currently
covered.
[#migrating]
=== Migrating from Singe 2.10
Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`,

View file

@ -477,30 +477,6 @@ SDL_Surface *decodeImage(const void *bytes, size_t size) {
}
SDL_Surface *decodeImageIO(SDL_IOStream *io, bool closeio) {
SDL_Surface *surface = IMG_Load_IO(io, false);
void *bytes = NULL;
size_t size = 0;
if (surface == NULL) {
// SDL_image could not read it. It may still be a TIFF, an OpenEXR, a JPEG 2000, an AVIF or
// any of the other pictures the bundled FFmpeg decodes for video.
if (SDL_SeekIO(io, 0, SDL_IO_SEEK_SET) >= 0) {
bytes = SDL_LoadFile_IO(io, &size, false);
if (bytes != NULL) {
surface = decodeImage(bytes, size);
SDL_free(bytes);
}
}
}
if (closeio) {
SDL_CloseIO(io);
}
return surface;
}
float *decodeImageFloat(const void *bytes, size_t size, int32_t *width, int32_t *height) {
const AVPixFmtDescriptor *description = NULL;
SourceT source;
@ -576,3 +552,28 @@ float *decodeImageFloat(const void *bytes, size_t size, int32_t *width, int32_t
return out;
}
SDL_Surface *decodeImageIO(SDL_IOStream *io, bool closeio) {
SDL_Surface *surface = IMG_Load_IO(io, false);
void *bytes = NULL;
size_t size = 0;
if (surface == NULL) {
// SDL_image could not read it. It may still be a TIFF, an OpenEXR, a JPEG 2000, an AVIF or
// any of the other pictures the bundled FFmpeg decodes for video.
if (SDL_SeekIO(io, 0, SDL_IO_SEEK_SET) >= 0) {
bytes = SDL_LoadFile_IO(io, &size, false);
if (bytes != NULL) {
surface = decodeImage(bytes, size);
SDL_free(bytes);
}
}
}
if (closeio) {
SDL_CloseIO(io);
}
return surface;
}

View file

@ -36,6 +36,7 @@
#include "generated/Menu_singe.h"
#include "generated/Tools_singe.h"
#include "generated/Net_singe.h"
#include "generated/cacerts_pem.h"
#include "generated/Master_singe.h"
#include "generated/Backdrop_singe.h"
#include "generated/MenuDocument_singe.h"

View file

@ -819,9 +819,11 @@ Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, con
SDL_Surface *surface = nullptr;
SDL_Surface *converted = nullptr;
Rml::byte *pixels = nullptr;
Rml::byte *row = nullptr;
size_t fileSize = 0;
size_t pixelSize = 0;
size_t i = 0;
size_t rowSize = 0;
size_t x = 0;
size_t y = 0;
size_t j = 0;
size_t dot = source.rfind('.');
Rml::String extension = (dot == Rml::String::npos) ? Rml::String() : source.substr(dot + 1);
@ -865,16 +867,23 @@ Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, con
}
surface = converted;
}
// Premultiplied alpha, which compositing needs.
pixelSize = (size_t)surface->w * (size_t)surface->h * BYTES_PER_PIXEL;
// Premultiplied alpha, which compositing needs. Rows are packed as they go, since the surface
// may pad its pitch and GenerateTexture wants the rows back to back.
rowSize = (size_t)surface->w * BYTES_PER_PIXEL;
pixels = static_cast<Rml::byte *>(surface->pixels);
for (i = 0; i < pixelSize; i += BYTES_PER_PIXEL) {
for (y = 0; y < (size_t)surface->h; y++) {
row = pixels + (y * (size_t)surface->pitch);
for (x = 0; x < rowSize; x += BYTES_PER_PIXEL) {
for (j = 0; j < COLOUR_CHANNELS; j++) {
pixels[i + j] = (Rml::byte)((int)pixels[i + j] * (int)pixels[i + COLOUR_CHANNELS] / COLOUR_MAX);
row[x + j] = (Rml::byte)((int)row[x + j] * (int)row[x + COLOUR_CHANNELS] / COLOUR_MAX);
}
}
if (row != pixels + (y * rowSize)) {
memmove(pixels + (y * rowSize), row, rowSize);
}
}
textureDimensions = { surface->w, surface->h };
handle = GenerateTexture(Rml::Span<const Rml::byte>(pixels, (size_t)surface->pitch * (size_t)surface->h), textureDimensions);
handle = GenerateTexture(Rml::Span<const Rml::byte>(pixels, rowSize * (size_t)surface->h), textureDimensions);
SDL_DestroySurface(surface);
return handle;
}

View file

@ -287,8 +287,8 @@ static int32_t _optionIndex(int32_t code);
static int32_t _optionNamed(const char *name);
static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]);
static bool _parseBoolean(const char *text, bool *value);
static bool _parseGamepadOrder(const char *text);
static bool _parseFloat(const char *text, double *value);
static bool _parseGamepadOrder(const char *text);
static bool _parseInteger(const char *text, int32_t *value);
static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit);
static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what);
@ -950,98 +950,6 @@ static void _crashHandler(int signalNumber) {
#endif
// Which audio formats this build can decode, for a bug report and so a user whose music is silent
// can see at a glance whether its format was ever compiled in. The mixer keeps the list.
char *mainDescribeAudioDecoders(void) {
char *list = strdup("");
char *grown = NULL;
int32_t count = MIX_GetNumAudioDecoders();
int32_t x = 0;
for (x = 0; x < count; x++) {
grown = utilCreateString("%s%s%s", list, (x > 0) ? ", " : "", MIX_GetAudioDecoder(x));
free(list);
list = grown;
}
return list;
}
// The processor, for a bug report. Every platform keeps the name somewhere different, and none of
// them is worth failing over: the core count is always there as a fallback.
char *mainDescribeCpu(void) {
#ifdef _WIN32
char name[128];
DWORD bytes = sizeof(name);
if (RegGetValueA(HKEY_LOCAL_MACHINE, "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", "ProcessorNameString", RRF_RT_REG_SZ, NULL, name, &bytes) == ERROR_SUCCESS) {
return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
#elif defined(__APPLE__)
char name[128];
size_t bytes = sizeof(name);
if (sysctlbyname("machdep.cpu.brand_string", name, &bytes, NULL, 0) == 0) {
return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
#else
char line[256];
FILE *info = fopen("/proc/cpuinfo", "r");
char *colon = NULL;
char *end = NULL;
char *found = NULL;
// /proc/cpuinfo calls it "model name" on x86 and "Model" on a Raspberry Pi. The kernel
// reports its size as zero, so it is read a line at a time rather than in one piece.
while ((info != NULL) && (found == NULL) && (fgets(line, sizeof(line), info) != NULL)) {
if (utilStartsWith(line, "model name") || utilStartsWith(line, "Model")) {
colon = strchr(line, ':');
if (colon != NULL) {
colon++;
while (*colon == ' ') {
colon++;
}
for (end = colon + strlen(colon); (end > colon) && ((uint8_t)end[-1] <= ' '); end--) {
end[-1] = 0;
}
found = utilCreateString("%s, %d cores, %d MB", colon, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
}
}
if (info != NULL) {
fclose(info);
}
if (found != NULL) {
return found;
}
#endif
return utilCreateString("%s, %d cores, %d MB", SDL_GetPlatform(), SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
// The operating system and its version, for a bug report.
char *mainDescribeOs(void) {
#ifdef _WIN32
char release[64];
DWORD bytes = sizeof(release);
if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuild", RRF_RT_REG_SZ, NULL, release, &bytes) == ERROR_SUCCESS) {
return utilCreateString("%s build %s", SDL_GetPlatform(), release);
}
#else
struct utsname system;
if (uname(&system) == 0) {
return utilCreateString("%s %s (%s)", system.sysname, system.release, system.machine);
}
#endif
return strdup(SDL_GetPlatform());
}
// Writes an embedded support file, or rewrites it when the installed copy differs from this build's.
static bool _extractFile(const char *filename, const uint8_t *data, size_t length) {
FILE *out = NULL;
@ -1428,33 +1336,6 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[])
}
// --gamepad_reorder is a list of enumeration positions, one per gamepad slot, written as Hypseus
// writes it: bare digits (3210) or separated by commas or spaces (3, 2, 1, 0). A repeated position
// is the user contradicting themselves and is refused; anything but a digit or a separator is too.
static bool _parseGamepadOrder(const char *text) {
bool seen[GAMEPAD_ORDER_DIGITS];
int32_t digits = 0;
int32_t x = 0;
memset(seen, 0, sizeof(seen));
for (x = 0; text[x] != '\0'; x++) {
if ((text[x] == ',') || (text[x] == ' ')) {
continue;
}
if ((text[x] < '0') || (text[x] > '9')) {
return false;
}
if (seen[text[x] - '0']) {
return false;
}
seen[text[x] - '0'] = true;
digits++;
}
return (digits > 0);
}
// The words a settings file (and --linearscale) may use for a switch.
static bool _parseBoolean(const char *text, bool *value) {
static const char *yes[] = { "true", "yes", "on", "1" };
@ -1494,6 +1375,33 @@ static bool _parseFloat(const char *text, double *value) {
}
// --gamepad_reorder is a list of enumeration positions, one per gamepad slot, written as Hypseus
// writes it: bare digits (3210) or separated by commas or spaces (3, 2, 1, 0). A repeated position
// is the user contradicting themselves and is refused; anything but a digit or a separator is too.
static bool _parseGamepadOrder(const char *text) {
bool seen[GAMEPAD_ORDER_DIGITS];
int32_t digits = 0;
int32_t x = 0;
memset(seen, 0, sizeof(seen));
for (x = 0; text[x] != '\0'; x++) {
if ((text[x] == ',') || (text[x] == ' ')) {
continue;
}
if ((text[x] < '0') || (text[x] > '9')) {
return false;
}
if (seen[text[x] - '0']) {
return false;
}
seen[text[x] - '0'] = true;
digits++;
}
return (digits > 0);
}
static bool _parseInteger(const char *text, int32_t *value) {
char *end = NULL;
long parsed = 0;
@ -1764,6 +1672,29 @@ static void _stopSDL(void) {
}
// Reduces every component that names a packed game to its stem, in place. A game writes to one
// data directory whether it was reached as the container the engine opened ("ActionMax.game", which
// createDataDirFor already reduces) or by a name that passes through it ("ActionMax.game/video.mkv",
// which is how a script names a file inside one). Without this the two disagreed: a packed game's
// video index went to data/ActionMax.game while everything else it wrote went to data/ActionMax,
// and the library appeared to have two of every game.
static void _stripDatabaseNames(char *path) {
const size_t extension = strlen(VFS_DATABASE_EXTENSION);
size_t i = 0;
char next = 0;
for (i = 0; path[i] != 0; i++) {
if (strncasecmp(path + i, VFS_DATABASE_EXTENSION, extension) != 0) {
continue;
}
next = path[i + extension];
if ((next == 0) || (next == '/') || (next == '\\')) {
memmove(path + i, path + i + extension, strlen(path + i + extension) + 1);
}
}
}
// What a bug report needs, at the top of trace.txt and nowhere else: the build, the command that
// started it, the machine, and what the engine picked to run on. main.c's own trace lines wait
// behind it so the block is always first and can be pasted whole.
@ -1810,6 +1741,7 @@ static void _unpackData(const char *exePath, bool absolute) {
{ "Menu.singe", Menu_singe, Menu_singe_len },
{ "Tools.singe", Tools_singe, Tools_singe_len },
{ "Net.singe", Net_singe, Net_singe_len },
{ "cacerts.pem", cacerts_pem, cacerts_pem_len },
{ "Master.singe", Master_singe, Master_singe_len },
{ "Backdrop.singe", Backdrop_singe, Backdrop_singe_len },
{ "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len },
@ -1909,29 +1841,6 @@ ConfigT *cloneConf(const ConfigT *conf) {
}
// Reduces every component that names a packed game to its stem, in place. A game writes to one
// data directory whether it was reached as the container the engine opened ("ActionMax.game", which
// createDataDirFor already reduces) or by a name that passes through it ("ActionMax.game/video.mkv",
// which is how a script names a file inside one). Without this the two disagreed: a packed game's
// video index went to data/ActionMax.game while everything else it wrote went to data/ActionMax,
// and the library appeared to have two of every game.
static void _stripDatabaseNames(char *path) {
const size_t extension = strlen(VFS_DATABASE_EXTENSION);
size_t i = 0;
char next = 0;
for (i = 0; path[i] != 0; i++) {
if (strncasecmp(path + i, VFS_DATABASE_EXTENSION, extension) != 0) {
continue;
}
next = path[i + extension];
if ((next == 0) || (next == '/') || (next == '\\')) {
memmove(path + i, path + i + extension, strlen(path + i + extension) + 1);
}
}
}
// Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
char *createDataDir(const char *dataDirBase, const char *filename) {
const char separator = utilGetPathSeparator();
@ -2020,6 +1929,98 @@ bool isFrameFileName(const char *filename) {
}
// Which audio formats this build can decode, for a bug report and so a user whose music is silent
// can see at a glance whether its format was ever compiled in. The mixer keeps the list.
char *mainDescribeAudioDecoders(void) {
char *list = strdup("");
char *grown = NULL;
int32_t count = MIX_GetNumAudioDecoders();
int32_t x = 0;
for (x = 0; x < count; x++) {
grown = utilCreateString("%s%s%s", list, (x > 0) ? ", " : "", MIX_GetAudioDecoder(x));
free(list);
list = grown;
}
return list;
}
// The processor, for a bug report. Every platform keeps the name somewhere different, and none of
// them is worth failing over: the core count is always there as a fallback.
char *mainDescribeCpu(void) {
#ifdef _WIN32
char name[128];
DWORD bytes = sizeof(name);
if (RegGetValueA(HKEY_LOCAL_MACHINE, "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", "ProcessorNameString", RRF_RT_REG_SZ, NULL, name, &bytes) == ERROR_SUCCESS) {
return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
#elif defined(__APPLE__)
char name[128];
size_t bytes = sizeof(name);
if (sysctlbyname("machdep.cpu.brand_string", name, &bytes, NULL, 0) == 0) {
return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
#else
char line[256];
FILE *info = fopen("/proc/cpuinfo", "r");
char *colon = NULL;
char *end = NULL;
char *found = NULL;
// /proc/cpuinfo calls it "model name" on x86 and "Model" on a Raspberry Pi. The kernel
// reports its size as zero, so it is read a line at a time rather than in one piece.
while ((info != NULL) && (found == NULL) && (fgets(line, sizeof(line), info) != NULL)) {
if (utilStartsWith(line, "model name") || utilStartsWith(line, "Model")) {
colon = strchr(line, ':');
if (colon != NULL) {
colon++;
while (*colon == ' ') {
colon++;
}
for (end = colon + strlen(colon); (end > colon) && ((uint8_t)end[-1] <= ' '); end--) {
end[-1] = 0;
}
found = utilCreateString("%s, %d cores, %d MB", colon, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
}
}
if (info != NULL) {
fclose(info);
}
if (found != NULL) {
return found;
}
#endif
return utilCreateString("%s, %d cores, %d MB", SDL_GetPlatform(), SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM());
}
// The operating system and its version, for a bug report.
char *mainDescribeOs(void) {
#ifdef _WIN32
char release[64];
DWORD bytes = sizeof(release);
if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuild", RRF_RT_REG_SZ, NULL, release, &bytes) == ERROR_SUCCESS) {
return utilCreateString("%s build %s", SDL_GetPlatform(), release);
}
#else
struct utsname system;
if (uname(&system) == 0) {
return utilCreateString("%s %s (%s)", system.sysname, system.release, system.machine);
}
#endif
return strdup(SDL_GetPlatform());
}
// Ok, this thing can have a mess of different arguments:
// WW - Just the width of the white border
// WW WB - Width of white border and then black border

View file

@ -152,6 +152,16 @@ bool midiIs(const void *bytes, size_t size) {
}
void midiQuit(void) {
if (_soundfont != NULL) {
tsf_close(_soundfont);
_soundfont = NULL;
}
free(_found);
_found = NULL;
}
bool midiRender(const void *bytes, size_t size, DecodedAudioT *out) {
tml_message *messages = NULL;
tml_message *message = NULL;
@ -212,16 +222,6 @@ bool midiRender(const void *bytes, size_t size, DecodedAudioT *out) {
}
void midiQuit(void) {
if (_soundfont != NULL) {
tsf_close(_soundfont);
_soundfont = NULL;
}
free(_found);
_found = NULL;
}
const char *midiSoundfont(void) {
return (_found != NULL) ? _found : "none found (MIDI will not play; name one with --soundfont)";
}

View file

@ -116,9 +116,12 @@ bool persistWrite(const char *text, size_t length) {
return false;
}
fclose(file);
// Windows will not rename onto an existing file, so the old one goes first. The window between
// the two is the one risk left, and it is smaller than writing in place.
// Windows will not rename onto an existing file, so the old one goes first there. The window
// between the two is the one risk left, and it is smaller than writing in place. Everywhere
// else the rename is atomic and the old save stays whole until the new one is in place.
#ifdef _WIN32
remove(_path);
#endif
if (rename(_temporary, _path) != 0) {
remove(_temporary);
return false;

View file

@ -68,6 +68,14 @@ void renderEnd(void) {
}
void renderSelect(RenderApiT api, const RenderBackendT *backend, SDL_Renderer *renderer) {
_api = backend != NULL ? api : RENDER_NONE;
_backend = backend;
_renderer = renderer;
utilTrace("Render: %s", renderApiName());
}
SDL_GPUTexture *renderTextureFor(SDL_Texture *texture) {
if (texture == NULL) {
return NULL;
@ -112,14 +120,6 @@ SDL_Texture *renderWrapTexture(SDL_Renderer *renderer, SDL_GPUTexture *texture,
}
void renderSelect(RenderApiT api, const RenderBackendT *backend, SDL_Renderer *renderer) {
_api = backend != NULL ? api : RENDER_NONE;
_backend = backend;
_renderer = renderer;
utilTrace("Render: %s", renderApiName());
}
// Every run of rgpu* drawing sits between acquiring a command buffer and submitting it, so that is
// where the context is taken and given back. Bracketing here rather than at the callers means

View file

@ -159,9 +159,9 @@ typedef struct GlesDeviceS {
static GlesDeviceT _device;
static GLenum _addressMode(SDL_GPUSamplerAddressMode mode);
static void _applyVertexLayout(GlesRenderPassT *pass);
static void _bindTextureUnit(uint32_t unit, GlesTextureT *texture, SDL_GPUSampler *sampler);
static GLenum _addressMode(SDL_GPUSamplerAddressMode mode);
static GLenum _blendFactor(SDL_GPUBlendFactor factor);
static GLenum _blendOp(SDL_GPUBlendOp op);
static GLenum _compare(SDL_GPUCompareOp op);
@ -214,8 +214,8 @@ static void _glesUnmapTransferBuffer(SDL_GPUDevice *device,
static void _glesUploadToBuffer(SDL_GPUCopyPass *copyPass, const SDL_GPUTransferBufferLocation *source, const SDL_GPUBufferRegion *destination, bool cycle);
static void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTextureTransferInfo *source, const SDL_GPUTextureRegion *destination, bool cycle);
static GLenum _primitive(SDL_GPUPrimitiveType type);
static GLuint _scratchFramebuffer(GLenum binding, GlesTextureT *texture, uint32_t level);
static void _remapFragmentBlocks(GLuint program, const char *fragmentSource);
static GLuint _scratchFramebuffer(GLenum binding, GlesTextureT *texture, uint32_t level);
static void _setPipelineState(GlesPipelineT *pipeline);
static GLenum _stencilOp(SDL_GPUStencilOp op);
static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum *type, GLboolean *normalized);
@ -224,6 +224,56 @@ void renderGlesRestore(void);
bool renderGlesStart(void);
static GLenum _addressMode(SDL_GPUSamplerAddressMode mode) {
switch (mode) {
case SDL_GPU_SAMPLERADDRESSMODE_REPEAT: return GL_REPEAT;
case SDL_GPU_SAMPLERADDRESSMODE_MIRRORED_REPEAT: return GL_MIRRORED_REPEAT;
default: return GL_CLAMP_TO_EDGE;
}
}
static void _applyVertexLayout(GlesRenderPassT *pass) {
GlesPipelineT *pipeline = pass->pipeline;
uint32_t i;
uint32_t a;
if ((pipeline == NULL) || !pass->vertexDirty) {
return;
}
pass->vertexDirty = false;
for (i = 0; i < pass->vertexCount; i++) {
GlesBufferT *buffer = pass->vertexBuffer[i];
uint32_t slot = i;
if (buffer == NULL) {
continue;
}
glBindBuffer(GL_ARRAY_BUFFER, buffer->name);
for (a = 0; a < pipeline->attributeCount; a++) {
const SDL_GPUVertexAttribute *attribute = &pipeline->attributes[a];
GLboolean normalized;
GLenum type;
GLint size;
if (attribute->buffer_slot != slot) {
continue;
}
_vertexFormat(attribute->format, &size, &type, &normalized);
glEnableVertexAttribArray(attribute->location);
// An integer attribute must be fed as an integer: the scene's joint indices are uvec4 in
// the shader, and handing them over as floats is a link-time type mismatch, not a value
// one, so the draw is rejected rather than merely wrong.
if (((type == GL_UNSIGNED_BYTE) && (normalized == GL_FALSE)) || (type == GL_INT) || (type == GL_UNSIGNED_INT)) {
glVertexAttribIPointer(attribute->location, size, type, (GLsizei)pipeline->strides[slot], (const void *)(uintptr_t)(pass->vertexOffset[slot] + attribute->offset));
} else {
glVertexAttribPointer(attribute->location, size, type, normalized, (GLsizei)pipeline->strides[slot], (const void *)(uintptr_t)(pass->vertexOffset[slot] + attribute->offset));
}
}
}
}
static void _bindTextureUnit(uint32_t unit, GlesTextureT *texture, SDL_GPUSampler *sampler) {
GLuint name = (GLuint)(uintptr_t)sampler;
@ -237,15 +287,6 @@ static void _bindTextureUnit(uint32_t unit, GlesTextureT *texture, SDL_GPUSample
}
static GLenum _addressMode(SDL_GPUSamplerAddressMode mode) {
switch (mode) {
case SDL_GPU_SAMPLERADDRESSMODE_REPEAT: return GL_REPEAT;
case SDL_GPU_SAMPLERADDRESSMODE_MIRRORED_REPEAT: return GL_MIRRORED_REPEAT;
default: return GL_CLAMP_TO_EDGE;
}
}
static GLenum _blendFactor(SDL_GPUBlendFactor factor) {
switch (factor) {
case SDL_GPU_BLENDFACTOR_ZERO: return GL_ZERO;
@ -398,6 +439,9 @@ static GLuint _framebufferFor(const SDL_GPUColorTargetInfo *colour, uint32_t col
// The cache is a fixed size on purpose; overflowing it means a target set is being rebuilt
// every frame, which is a bug worth hearing about rather than hiding behind a bigger array.
utilTrace("Gles: framebuffer cache full");
for (index = 0; index < _device.framebufferCount; index++) {
glDeleteFramebuffers(1, &_device.framebuffers[index].name);
}
_device.framebufferCount = 0;
}
slot = &_device.framebuffers[_device.framebufferCount];
@ -586,47 +630,6 @@ static void _glesBindVertexBuffers(SDL_GPURenderPass *renderPass, Uint32 firstSl
}
static void _applyVertexLayout(GlesRenderPassT *pass) {
GlesPipelineT *pipeline = pass->pipeline;
uint32_t i;
uint32_t a;
if ((pipeline == NULL) || !pass->vertexDirty) {
return;
}
pass->vertexDirty = false;
for (i = 0; i < pass->vertexCount; i++) {
GlesBufferT *buffer = pass->vertexBuffer[i];
uint32_t slot = i;
if (buffer == NULL) {
continue;
}
glBindBuffer(GL_ARRAY_BUFFER, buffer->name);
for (a = 0; a < pipeline->attributeCount; a++) {
const SDL_GPUVertexAttribute *attribute = &pipeline->attributes[a];
GLboolean normalized;
GLenum type;
GLint size;
if (attribute->buffer_slot != slot) {
continue;
}
_vertexFormat(attribute->format, &size, &type, &normalized);
glEnableVertexAttribArray(attribute->location);
// An integer attribute must be fed as an integer: the scene's joint indices are uvec4 in
// the shader, and handing them over as floats is a link-time type mismatch, not a value
// one, so the draw is rejected rather than merely wrong.
if (((type == GL_UNSIGNED_BYTE) && (normalized == GL_FALSE)) || (type == GL_INT) || (type == GL_UNSIGNED_INT)) {
glVertexAttribIPointer(attribute->location, size, type, (GLsizei)pipeline->strides[slot], (const void *)(uintptr_t)(pass->vertexOffset[slot] + attribute->offset));
} else {
glVertexAttribPointer(attribute->location, size, type, normalized, (GLsizei)pipeline->strides[slot], (const void *)(uintptr_t)(pass->vertexOffset[slot] + attribute->offset));
}
}
}
}
static void _glesBindVertexStorageBuffers(SDL_GPURenderPass *renderPass, Uint32 firstSlot, SDL_GPUBuffer *const *storageBuffers, Uint32 numBindings) {
uint32_t i;

View file

@ -3978,18 +3978,6 @@ bool materialSetBlend(int32_t material, bool blend) {
}
// glTF's alpha masking: a texel whose base colour alpha falls below the cutoff is discarded, in
// the lit pass and in the shadow pass alike. Zero turns masking off, which is the default and what
// every material that never asked for it keeps.
bool materialSetCutoff(int32_t material, float cutoff) {
if (!materialValid(material)) {
return false;
}
_scene.materials[material].cutoff = SDL_clamp(cutoff, 0.0f, 1.0f);
return true;
}
bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
if (!materialValid(material)) {
return false;
@ -4015,6 +4003,18 @@ bool materialSetColorLinear(int32_t material, float r, float g, float b, float a
}
// glTF's alpha masking: a texel whose base colour alpha falls below the cutoff is discarded, in
// the lit pass and in the shadow pass alike. Zero turns masking off, which is the default and what
// every material that never asked for it keeps.
bool materialSetCutoff(int32_t material, float cutoff) {
if (!materialValid(material)) {
return false;
}
_scene.materials[material].cutoff = SDL_clamp(cutoff, 0.0f, 1.0f);
return true;
}
bool materialSetDoubleSided(int32_t material, bool doubleSided) {
if (!materialValid(material)) {
return false;

View file

@ -31,9 +31,11 @@
#include "util.h"
#define SLOTS_FIRST 16 // Grown by doubling from here
#define SLOT_BITS 16 // A handle is the slot in its low bits and a generation above them:
#define SLOT_MASK 0xFFFF // a slot that is freed and given out again gets a new handle, so a
#define GENERATION_MASK 0x7FFF // stale handle a script kept cannot cancel or ask after the newcomer.
#define BACK_OVERSHOOT 1.70158 // How far past the target the "back" easings go, the usual constant
#define ELASTIC_PERIOD 0.3
#define ELASTIC_AMPLITUDE 0.1
#define BOUNCE_SCALE 7.5625 // The bounce curve's four arcs, as everyone writes them
#define BOUNCE_SPLIT 2.75
#define HALF 0.5
@ -47,6 +49,7 @@ typedef struct {
double from;
double to;
SchedulerEaseE easing;
int32_t generation; // Stepped every time the slot is given out
bool repeating;
bool used;
} SlotT;
@ -60,24 +63,33 @@ static int32_t _capacity = 0;
static int32_t _allocate(void);
static double _ease(SchedulerEaseE easing, double t);
static double _easeBounceOut(double t);
static int32_t _handleOf(int32_t slot);
static SlotT *_slotOf(int32_t handle);
// ===== Internal helpers =====
// The first free slot, growing the table when there is none. Handles are indexes, so a slot never
// moves while anything might still name it.
// The first free slot, growing the table when there is none. A slot never moves while anything
// might still name it, and each time one is handed out again its generation steps, so the handle
// a script was given for the old occupant names nothing once that occupant is gone.
static int32_t _allocate(void) {
SlotT *grown = NULL;
int32_t want = 0;
int32_t x = 0;
int32_t generation = 0;
for (x = 0; x < _count; x++) {
if (!_slots[x].used) {
generation = (_slots[x].generation + 1) & GENERATION_MASK;
memset(&_slots[x], 0, sizeof(_slots[x]));
_slots[x].generation = generation;
_slots[x].used = true;
return x;
}
}
if (_count > SLOT_MASK) {
utilDie("Too many timers and tweens are alive at once.");
}
if (_count == _capacity) {
want = (_capacity == 0) ? SLOTS_FIRST : (_capacity * 2);
grown = (SlotT *)realloc(_slots, sizeof(SlotT) * (size_t)want);
@ -155,13 +167,33 @@ static double _easeBounceOut(double t) {
}
// The handle a script is given for a slot: the slot's index under its generation.
static int32_t _handleOf(int32_t slot) {
return (_slots[slot].generation << SLOT_BITS) | slot;
}
// The slot a handle names, or NULL when it names nothing: out of range, free, or an earlier
// occupant of a slot that has since been given to something else.
static SlotT *_slotOf(int32_t handle) {
int32_t slot = handle & SLOT_MASK;
if ((handle < 0) || (slot >= _count) || !_slots[slot].used || (_handleOf(slot) != handle)) {
return NULL;
}
return &_slots[slot];
}
// ===== Public =====
void schedulerCancel(int32_t handle) {
if ((handle < 0) || (handle >= _count)) {
return;
SlotT *slot = _slotOf(handle);
if (slot != NULL) {
slot->used = false;
}
_slots[handle].used = false;
}
@ -180,11 +212,7 @@ int32_t schedulerCount(void) {
bool schedulerIsActive(int32_t handle) {
if ((handle < 0) || (handle >= _count)) {
return false;
}
return _slots[handle].used;
return _slotOf(handle) != NULL;
}
@ -197,8 +225,8 @@ void schedulerReset(void) {
int32_t schedulerTimer(int64_t milliseconds, bool repeating, uint64_t now) {
int32_t handle = _allocate();
SlotT *slot = &_slots[handle];
int32_t index = _allocate();
SlotT *slot = &_slots[index];
if (milliseconds < 0) {
milliseconds = 0;
@ -208,13 +236,13 @@ int32_t schedulerTimer(int64_t milliseconds, bool repeating, uint64_t now) {
slot->due = now + (uint64_t)milliseconds;
slot->repeating = repeating;
return handle;
return _handleOf(index);
}
int32_t schedulerTween(double from, double to, int64_t milliseconds, SchedulerEaseE easing, uint64_t now) {
int32_t handle = _allocate();
SlotT *slot = &_slots[handle];
int32_t index = _allocate();
SlotT *slot = &_slots[index];
if (milliseconds < 0) {
milliseconds = 0;
@ -230,7 +258,7 @@ int32_t schedulerTween(double from, double to, int64_t milliseconds, SchedulerEa
slot->to = to;
slot->easing = easing;
return handle;
return _handleOf(index);
}
@ -260,7 +288,7 @@ void schedulerUpdate(uint64_t now, SchedulerFireT fire, void *context) {
slot->used = false;
}
if (fire != NULL) {
fire(context, x, SCHEDULER_TIMER, 0, 0, !slot->repeating);
fire(context, _handleOf(x), SCHEDULER_TIMER, 0, 0, !slot->repeating);
}
continue;
}
@ -274,7 +302,7 @@ void schedulerUpdate(uint64_t now, SchedulerFireT fire, void *context) {
slot->used = false;
}
if (fire != NULL) {
fire(context, x, SCHEDULER_TWEEN, value, progress, finished);
fire(context, _handleOf(x), SCHEDULER_TWEEN, value, progress, finished);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,7 @@
#define HISTORY 120 // Frames kept, about two seconds at sixty
#define MARGIN 8
#define PADDING 4 // Between the backdrop edge and the text
#define LINE_HEIGHT (SDL_DEBUG_TEXT_FONT_CHARACTER_SIZE + 2)
#define PANEL_WIDTH 300 // Wide enough for the longest line, which is the frame times
#define LINES_MAX 6 // What statsDraw writes; the backdrop is sized from it
@ -50,7 +51,7 @@ static void _line(SDL_Renderer *renderer, int32_t index, const char *text);
static void _line(SDL_Renderer *renderer, int32_t index, const char *text) {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE);
SDL_RenderDebugText(renderer, (float)(MARGIN + 4), (float)(MARGIN + 4 + (index * LINE_HEIGHT)), text);
SDL_RenderDebugText(renderer, (float)(MARGIN + PADDING), (float)(MARGIN + PADDING + (index * LINE_HEIGHT)), text);
}
@ -67,7 +68,7 @@ void statsDraw(SDL_Renderer *renderer, const StatsT *stats) {
backdrop.x = MARGIN;
backdrop.y = MARGIN;
backdrop.w = PANEL_WIDTH;
backdrop.h = (float)((LINES_MAX * LINE_HEIGHT) + 8);
backdrop.h = (float)((LINES_MAX * LINE_HEIGHT) + (PADDING * 2));
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, BACKDROP_ALPHA);
SDL_RenderFillRect(renderer, &backdrop);

View file

@ -127,7 +127,7 @@ static int _listCompare(const void *a, const void *b); // qsort callba
static void _listDirectory(const char *path, ListT *list);
static char **_listFinish(ListT *list, int32_t *count);
static char *_normalise(const char *name);
static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer, const char *directory);
static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer);
static uint8_t *_readAsset(DatabaseT *db, const char *key, size_t *bytes, bool sdlMemory);
static bool _readChunk(DatabaseT *db, const char *key, int64_t index, uint8_t *buffer, int64_t capacity, int64_t *length);
static bool _resolve(const char *name, TargetT *target);
@ -540,15 +540,15 @@ static char *_normalise(const char *name) {
// A game's own directory under the data directory, holding its overlay.
static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer, const char *directory) {
static char *_overlayFor(const char *dataDirBase, const char *dataDir, const char *databasePath, bool isContainer) {
char *stem = NULL;
char *result = NULL;
if (isContainer && (dataDir != NULL)) {
return utilCreateString("%s%s%c", dataDir, directory, utilGetPathSeparator());
return utilCreateString("%s%s%c", dataDir, OVERLAY_DIRECTORY, utilGetPathSeparator());
}
stem = vfsDatabaseStem(utilGetLastPathComponent(databasePath));
result = utilCreateString("%s%s%c%s%c", dataDirBase ? dataDirBase : "", stem, utilGetPathSeparator(), directory, utilGetPathSeparator());
result = utilCreateString("%s%s%c%s%c", dataDirBase ? dataDirBase : "", stem, utilGetPathSeparator(), OVERLAY_DIRECTORY, utilGetPathSeparator());
free(stem);
return result;
@ -655,7 +655,7 @@ static bool _resolve(const char *name, TargetT *target) {
if (target->db != NULL) {
inner = (norm[i] == 0) ? norm + i : norm + i + 1;
if (target->db->overlay == NULL) {
target->db->overlay = _overlayFor(_dataDirBase, _dataDir, prefix, false, OVERLAY_DIRECTORY);
target->db->overlay = _overlayFor(_dataDirBase, _dataDir, prefix, false);
}
free(prefix);
break;
@ -824,7 +824,7 @@ void vfsInit(const char *container, const char *dataDirBase, const char *dataDir
utilDie("%s is not a Singe game database.", container);
}
free(_container->overlay);
_container->overlay = _overlayFor(_dataDirBase, _dataDir, container, true, OVERLAY_DIRECTORY);
_container->overlay = _overlayFor(_dataDirBase, _dataDir, container, true);
}
}
@ -872,14 +872,11 @@ bool vfsIsFilesystem(const char *name) {
}
// The entries directly under a directory name, from the loose directory, the overlay and the
// database together, each name once, sorted. Free with vfsListFree.
// Whether a name lives only inside a database: no loose file and no overlay copy shadows it. That
// is the case that has no filesystem path at all: vfsFilePath answers with a name that is not
// there, and a stream is the only way to read it.
bool vfsIsPacked(const char *name) {
TargetT target;
int64_t size = 0;
bool packed = false;
if (!_resolve(name, &target)) {
@ -887,13 +884,15 @@ bool vfsIsPacked(const char *name) {
}
packed = (target.db != NULL) && (target.key[0] != 0) &&
!utilFileExists(target.path) && !utilFileExists(target.overlay) &&
_assetSize(target.db, target.key, &size);
_assetExists(target.db, target.key);
_targetFree(&target);
return packed;
}
// The entries directly under a directory name, from the loose directory, the overlay and the
// database together, each name once, sorted. Free with vfsListFree.
char **vfsList(const char *name, int32_t *count) {
TargetT target;
ListT list;

View file

@ -287,14 +287,11 @@ static int64_t _avioSeek(void *opaque, int64_t offset, int whence);
static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath);
static int _compareFrames(const void *a, const void *b); // qsort callback. Not changing int.
static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame);
static const AVFrame *_deinterlace(VideoPlayerT *v, AVFrame *frame);
static void _deinterlaceClose(VideoPlayerT *v);
static bool _srtAppend(char **text, size_t *used, size_t *room, const char *addition);
static char *_srtFromRect(const AVSubtitleRect *rect);
static void _srtTime(int64_t milliseconds, char *out, size_t size);
static bool _deinterlaceOpen(VideoPlayerT *v, const AVFrame *frame);
static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want);
static void _decodeHere(VideoPlayerT *v);
static const AVFrame *_deinterlace(VideoPlayerT *v, AVFrame *frame);
static void _deinterlaceClose(VideoPlayerT *v);
static bool _deinterlaceOpen(VideoPlayerT *v, const AVFrame *frame);
static int _decoderThread(void *data); // SDL thread entry. Not changing int.
static void _feedAudio(VideoPlayerT *v);
static int64_t _findFrameIndex(VideoPlayerT *v, int64_t pts);
@ -314,13 +311,16 @@ static bool _readIndexCache(VideoPlayerT *v, const char *indexName, co
static void _reportKeyframes(VideoPlayerT *v, const char *filename);
static void _requestFrame(VideoPlayerT *v);
static void _resetClock(VideoPlayerT *v, uint64_t now);
static const AVCodec *_rkmppDecoder(const AVCodec *decoder);
static void _seekVideo(VideoPlayerT *v, int64_t keyframe);
static enum AVPixelFormat _selectPixelFormat(AVCodecContext *codec, const enum AVPixelFormat *formats); // libavcodec callback.
static bool _srtAppend(char **text, size_t *used, size_t *room, const char *addition);
static char *_srtFromRect(const AVSubtitleRect *rect);
static void _srtTime(int64_t milliseconds, char *out, size_t size);
static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase);
static bool _takeDecodedFrame(VideoPlayerT *v);
static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples);
static void _uploadFrame(VideoPlayerT *v);
static const AVCodec *_rkmppDecoder(const AVCodec *decoder);
static const AVCodec *_v4l2Decoder(const AVCodec *decoder);
static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header);
@ -835,91 +835,6 @@ static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *
}
// A frame ready to convert: the deinterlaced one when the picture is interlaced and the option
// allows it, and the frame itself otherwise. Laserdisc rips are the reason this exists -- a disc
// held interlaced fields, and a rip that kept them combs on every progressive display.
static const AVFrame *_deinterlace(VideoPlayerT *v, AVFrame *frame) {
if ((v->deinterlace == DEINTERLACE_OFF) || (frame->format == AV_PIX_FMT_NONE)) {
return frame;
}
// bwdif is told to leave progressive frames alone, so in automatic mode the graph is built only
// once something interlaced actually turns up and costs nothing on a progressive disc.
if ((v->deinterlace == DEINTERLACE_AUTO) && ((frame->flags & AV_FRAME_FLAG_INTERLACED) == 0) && (v->filterGraph == NULL)) {
return frame;
}
if ((v->filterGraph != NULL) && ((frame->format != v->filterFormat) || (frame->width != v->filterWidth) || (frame->height != v->filterHeight))) {
_deinterlaceClose(v);
}
if ((v->filterGraph == NULL) && !_deinterlaceOpen(v, frame)) {
return frame;
}
av_frame_unref(v->filterFrame);
if (av_buffersrc_add_frame_flags(v->filterSource, frame, AV_BUFFERSRC_FLAG_KEEP_REF) < 0) {
return frame;
}
if (av_buffersink_get_frame(v->filterSink, v->filterFrame) < 0) {
return frame;
}
return v->filterFrame;
}
static void _deinterlaceClose(VideoPlayerT *v) {
if (v->filterGraph != NULL) {
avfilter_graph_free(&v->filterGraph);
v->filterSource = NULL;
v->filterSink = NULL;
}
}
// buffer -> bwdif -> buffersink. bwdif runs in send_frame mode, one picture out for one in: a
// disc is addressed by frame number, and a filter that turned each frame into two fields would
// move every frame in the index.
static bool _deinterlaceOpen(VideoPlayerT *v, const AVFrame *frame) {
char arguments[FILTER_ARGUMENTS_MAX];
const AVFilter *source = avfilter_get_by_name("buffer");
const AVFilter *sink = avfilter_get_by_name("buffersink");
const AVFilter *bwdif = avfilter_get_by_name("bwdif");
AVFilterContext *filter = NULL;
AVRational ratio = (frame->sample_aspect_ratio.num > 0) ? frame->sample_aspect_ratio : (AVRational){ 1, 1 };
if ((source == NULL) || (sink == NULL) || (bwdif == NULL)) {
utilTrace("Video %d: this build has no deinterlacer.", v->id);
v->deinterlace = DEINTERLACE_OFF;
return false;
}
v->filterGraph = avfilter_graph_alloc();
if (v->filterGraph == NULL) {
utilDie("Unable to allocate the deinterlacing graph.");
}
snprintf(arguments, sizeof(arguments), "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d",
frame->width, frame->height, frame->format, v->videoTimeBase.num, v->videoTimeBase.den, ratio.num, ratio.den);
if ((avfilter_graph_create_filter(&v->filterSource, source, "in", arguments, NULL, v->filterGraph) < 0) ||
(avfilter_graph_create_filter(&filter, bwdif, "bwdif", (v->deinterlace == DEINTERLACE_ON) ? "mode=send_frame:parity=auto:deint=all" : "mode=send_frame:parity=auto:deint=interlaced", NULL, v->filterGraph) < 0) ||
(avfilter_graph_create_filter(&v->filterSink, sink, "out", NULL, NULL, v->filterGraph) < 0) ||
(avfilter_link(v->filterSource, 0, filter, 0) < 0) ||
(avfilter_link(filter, 0, v->filterSink, 0) < 0) ||
(avfilter_graph_config(v->filterGraph, NULL) < 0)) {
utilTrace("Video %d: the deinterlacing graph would not build; leaving the picture alone.", v->id);
_deinterlaceClose(v);
v->deinterlace = DEINTERLACE_OFF;
return false;
}
v->filterFormat = (enum AVPixelFormat)frame->format;
v->filterWidth = frame->width;
v->filterHeight = frame->height;
if (!v->filterReported) {
v->filterReported = true;
utilTrace("Video %d: deinterlacing with bwdif.", v->id);
}
return true;
}
// Decodes frame "want" into the back buffer. Runs on the decoder thread. On DECODE_ERROR the
// message is in threadErrMsg; the caller raises threadError under the lock, which publishes both.
static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) {
@ -1084,6 +999,91 @@ static int _decoderThread(void *data) {
}
// A frame ready to convert: the deinterlaced one when the picture is interlaced and the option
// allows it, and the frame itself otherwise. Laserdisc rips are the reason this exists -- a disc
// held interlaced fields, and a rip that kept them combs on every progressive display.
static const AVFrame *_deinterlace(VideoPlayerT *v, AVFrame *frame) {
if ((v->deinterlace == DEINTERLACE_OFF) || (frame->format == AV_PIX_FMT_NONE)) {
return frame;
}
// bwdif is told to leave progressive frames alone, so in automatic mode the graph is built only
// once something interlaced actually turns up and costs nothing on a progressive disc.
if ((v->deinterlace == DEINTERLACE_AUTO) && ((frame->flags & AV_FRAME_FLAG_INTERLACED) == 0) && (v->filterGraph == NULL)) {
return frame;
}
if ((v->filterGraph != NULL) && ((frame->format != v->filterFormat) || (frame->width != v->filterWidth) || (frame->height != v->filterHeight))) {
_deinterlaceClose(v);
}
if ((v->filterGraph == NULL) && !_deinterlaceOpen(v, frame)) {
return frame;
}
av_frame_unref(v->filterFrame);
if (av_buffersrc_add_frame_flags(v->filterSource, frame, AV_BUFFERSRC_FLAG_KEEP_REF) < 0) {
return frame;
}
if (av_buffersink_get_frame(v->filterSink, v->filterFrame) < 0) {
return frame;
}
return v->filterFrame;
}
static void _deinterlaceClose(VideoPlayerT *v) {
if (v->filterGraph != NULL) {
avfilter_graph_free(&v->filterGraph);
v->filterSource = NULL;
v->filterSink = NULL;
}
}
// buffer -> bwdif -> buffersink. bwdif runs in send_frame mode, one picture out for one in: a
// disc is addressed by frame number, and a filter that turned each frame into two fields would
// move every frame in the index.
static bool _deinterlaceOpen(VideoPlayerT *v, const AVFrame *frame) {
char arguments[FILTER_ARGUMENTS_MAX];
const AVFilter *source = avfilter_get_by_name("buffer");
const AVFilter *sink = avfilter_get_by_name("buffersink");
const AVFilter *bwdif = avfilter_get_by_name("bwdif");
AVFilterContext *filter = NULL;
AVRational ratio = (frame->sample_aspect_ratio.num > 0) ? frame->sample_aspect_ratio : (AVRational){ 1, 1 };
if ((source == NULL) || (sink == NULL) || (bwdif == NULL)) {
utilTrace("Video %d: this build has no deinterlacer.", v->id);
v->deinterlace = DEINTERLACE_OFF;
return false;
}
v->filterGraph = avfilter_graph_alloc();
if (v->filterGraph == NULL) {
utilDie("Unable to allocate the deinterlacing graph.");
}
snprintf(arguments, sizeof(arguments), "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d",
frame->width, frame->height, frame->format, v->videoTimeBase.num, v->videoTimeBase.den, ratio.num, ratio.den);
if ((avfilter_graph_create_filter(&v->filterSource, source, "in", arguments, NULL, v->filterGraph) < 0) ||
(avfilter_graph_create_filter(&filter, bwdif, "bwdif", (v->deinterlace == DEINTERLACE_ON) ? "mode=send_frame:parity=auto:deint=all" : "mode=send_frame:parity=auto:deint=interlaced", NULL, v->filterGraph) < 0) ||
(avfilter_graph_create_filter(&v->filterSink, sink, "out", NULL, NULL, v->filterGraph) < 0) ||
(avfilter_link(v->filterSource, 0, filter, 0) < 0) ||
(avfilter_link(filter, 0, v->filterSink, 0) < 0) ||
(avfilter_graph_config(v->filterGraph, NULL) < 0)) {
utilTrace("Video %d: the deinterlacing graph would not build; leaving the picture alone.", v->id);
_deinterlaceClose(v);
v->deinterlace = DEINTERLACE_OFF;
return false;
}
v->filterFormat = (enum AVPixelFormat)frame->format;
v->filterWidth = frame->width;
v->filterHeight = frame->height;
if (!v->filterReported) {
v->filterReported = true;
utilTrace("Video %d: deinterlacing with bwdif.", v->id);
}
return true;
}
// Keeps the mixer track's stream topped up from the current audio track.
static void _feedAudio(VideoPlayerT *v) {
int32_t result = 0;
@ -1537,6 +1537,74 @@ static void _resetClock(VideoPlayerT *v, uint64_t now) {
}
// The V4L2 memory-to-memory decoder for the stream's codec on a Pi build, NULL when there is none or
// hardware decoding is off. Whether the device exists is found out when the decoder opens.
// Rockchip boards decode through the vendor's Media Process Platform, which FFmpeg exposes as named
// decoders in the same way as V4L2's. They exist only when FFmpeg was built against that library,
// which is why this is compiled out everywhere else. Tried before V4L2: a Rockchip running its
// vendor kernel has both, and the MPP path is the one that works there.
static const AVCodec *_rkmppDecoder(const AVCodec *decoder) {
static const V4l2DecoderT table[] = {
{ AV_CODEC_ID_H264, "h264_rkmpp" },
{ AV_CODEC_ID_HEVC, "hevc_rkmpp" }
};
size_t x = 0;
if (!RKMPP_DECODE || !_hardwareDecoding) {
return NULL;
}
for (x = 0; x < SDL_arraysize(table); x++) {
if (table[x].id == decoder->id) {
return avcodec_find_decoder_by_name(table[x].name);
}
}
return NULL;
}
static void _seekVideo(VideoPlayerT *v, int64_t keyframe) {
if (av_seek_frame(v->videoFormat, v->videoStream, v->frames[keyframe].pts, AVSEEK_FLAG_BACKWARD) < 0) {
avformat_seek_file(v->videoFormat, v->videoStream, INT64_MIN, 0, INT64_MAX, 0);
keyframe = 0;
}
avcodec_flush_buffers(v->videoCodec);
if (v->packetPending) {
av_packet_unref(v->videoPacket);
v->packetPending = false;
}
// Streams without timestamps are counted from here; the rest are matched by timestamp.
v->nextDecodeFrame = keyframe;
v->seekKeyframe = keyframe;
v->videoDrained = false;
}
// libavcodec asks which of the formats it can produce we want; take the hardware one when offered.
// The list is every hardware format the build knows followed by the software one, so the fallback
// is the first entry that is not a hardware surface, not the first entry.
static enum AVPixelFormat _selectPixelFormat(AVCodecContext *codec, const enum AVPixelFormat *formats) {
VideoPlayerT *v = (VideoPlayerT *)codec->opaque;
const AVPixFmtDescriptor *desc = NULL;
int32_t x = 0;
for (x = 0; formats[x] != AV_PIX_FMT_NONE; x++) {
if (formats[x] == v->hwPixelFormat) {
return formats[x];
}
}
utilTrace("Video %d: hardware decoder declined the stream; decoding in software.", v->id);
for (x = 0; formats[x] != AV_PIX_FMT_NONE; x++) {
desc = av_pix_fmt_desc_get(formats[x]);
if ((desc != NULL) && !(desc->flags & AV_PIX_FMT_FLAG_HWACCEL)) {
return formats[x];
}
}
return formats[0];
}
// Positions the video demuxer at a keyframe and resets the decoder. Only ever called by whichever
// thread is decoding: the decoder thread, or the frame loop in deterministic mode.
// Appends to a growing string, doubling it as it goes. False when it will not grow.
@ -1644,48 +1712,6 @@ static void _srtTime(int64_t milliseconds, char *out, size_t size) {
}
static void _seekVideo(VideoPlayerT *v, int64_t keyframe) {
if (av_seek_frame(v->videoFormat, v->videoStream, v->frames[keyframe].pts, AVSEEK_FLAG_BACKWARD) < 0) {
avformat_seek_file(v->videoFormat, v->videoStream, INT64_MIN, 0, INT64_MAX, 0);
keyframe = 0;
}
avcodec_flush_buffers(v->videoCodec);
if (v->packetPending) {
av_packet_unref(v->videoPacket);
v->packetPending = false;
}
// Streams without timestamps are counted from here; the rest are matched by timestamp.
v->nextDecodeFrame = keyframe;
v->seekKeyframe = keyframe;
v->videoDrained = false;
}
// libavcodec asks which of the formats it can produce we want; take the hardware one when offered.
// The list is every hardware format the build knows followed by the software one, so the fallback
// is the first entry that is not a hardware surface, not the first entry.
static enum AVPixelFormat _selectPixelFormat(AVCodecContext *codec, const enum AVPixelFormat *formats) {
VideoPlayerT *v = (VideoPlayerT *)codec->opaque;
const AVPixFmtDescriptor *desc = NULL;
int32_t x = 0;
for (x = 0; formats[x] != AV_PIX_FMT_NONE; x++) {
if (formats[x] == v->hwPixelFormat) {
return formats[x];
}
}
utilTrace("Video %d: hardware decoder declined the stream; decoding in software.", v->id);
for (x = 0; formats[x] != AV_PIX_FMT_NONE; x++) {
desc = av_pix_fmt_desc_get(formats[x]);
if ((desc != NULL) && !(desc->flags & AV_PIX_FMT_FLAG_HWACCEL)) {
return formats[x];
}
}
return formats[0];
}
static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase) {
return av_rescale(ts, (int64_t)timeBase.num * (int64_t)MS_PER_SECOND, timeBase.den);
}
@ -1762,32 +1788,6 @@ static void _uploadFrame(VideoPlayerT *v) {
}
// The V4L2 memory-to-memory decoder for the stream's codec on a Pi build, NULL when there is none or
// hardware decoding is off. Whether the device exists is found out when the decoder opens.
// Rockchip boards decode through the vendor's Media Process Platform, which FFmpeg exposes as named
// decoders in the same way as V4L2's. They exist only when FFmpeg was built against that library,
// which is why this is compiled out everywhere else. Tried before V4L2: a Rockchip running its
// vendor kernel has both, and the MPP path is the one that works there.
static const AVCodec *_rkmppDecoder(const AVCodec *decoder) {
static const V4l2DecoderT table[] = {
{ AV_CODEC_ID_H264, "h264_rkmpp" },
{ AV_CODEC_ID_HEVC, "hevc_rkmpp" }
};
size_t x = 0;
if (!RKMPP_DECODE || !_hardwareDecoding) {
return NULL;
}
for (x = 0; x < SDL_arraysize(table); x++) {
if (table[x].id == decoder->id) {
return avcodec_find_decoder_by_name(table[x].name);
}
}
return NULL;
}
static const AVCodec *_v4l2Decoder(const AVCodec *decoder) {
static const V4l2DecoderT table[] = {
{ AV_CODEC_ID_H263, "h263_v4l2m2m" },
@ -1881,6 +1881,38 @@ int32_t videoGetAudioTracks(int32_t playerHandle) {
}
// What the decoder will be, for the trace header: the platform's hardware decoders in the order
// they are tried, or software when there are none or --softwarevideo ruled them out. Which one a
// given video actually got is traced as that video opens, since a codec may offer neither.
const char *videoGetDecoderDescription(void) {
if (!_hardwareDecoding) {
return "software only (--softwarevideo)";
}
if (V4L2_DECODE) {
// An ARM build may carry three: the stateless V4L2 decoders, Rockchip's own, and the
// stateful V4L2 ones. Which of them a board actually has is a question for the board.
if (RKMPP_DECODE && V4L2_REQUEST_DECODE) {
return "hardware v4l2 stateless, rkmpp or v4l2m2m where the board allows, software otherwise";
}
if (V4L2_REQUEST_DECODE) {
return "hardware v4l2 stateless or v4l2m2m where the board allows, software otherwise";
}
if (RKMPP_DECODE) {
return "hardware rkmpp or v4l2m2m where the board allows, software otherwise";
}
return "hardware v4l2m2m where the codec allows, software otherwise";
}
#if defined(_WIN32)
return "hardware d3d11va or dxva2 where the codec allows, software otherwise";
#elif defined(__APPLE__)
return "hardware videotoolbox where the codec allows, software otherwise";
#else
return "hardware vaapi or vdpau where the codec allows, software otherwise";
#endif
}
// Frames per second of the loaded video, which is what a subtitle's timestamps are turned into
// frame numbers with.
double videoGetFps(int32_t playerHandle) {
@ -1890,178 +1922,6 @@ double videoGetFps(int32_t playerHandle) {
}
// How many subtitle tracks the file holds, bitmap ones included: they are counted so that the
// numbering matches what a player or ffprobe shows, and videoReadSubtitles says which have words.
int32_t videoGetSubtitleTracks(int32_t playerHandle) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetSubtitleTracks");
AVFormatContext *format = _formatOpen(v->filename);
int32_t count = 0;
uint32_t x = 0;
if (format == NULL) {
return 0;
}
if (avformat_find_stream_info(format, NULL) >= 0) {
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type == AVMEDIA_TYPE_SUBTITLE) {
count++;
}
}
}
_formatClose(&format);
return count;
}
// The language of a subtitle track as the file labels it, "" when it says nothing. The pointer is
// good until the next call.
const char *videoGetSubtitleLanguage(int32_t playerHandle, int32_t track) {
static char language[LANGUAGE_CODE_BYTES];
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetSubtitleLanguage");
AVFormatContext *format = _formatOpen(v->filename);
AVDictionaryEntry *entry = NULL;
int32_t seen = 0;
uint32_t x = 0;
language[0] = '\0';
if (format == NULL) {
return language;
}
if (avformat_find_stream_info(format, NULL) >= 0) {
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
continue;
}
if (seen == track) {
entry = av_dict_get(format->streams[x]->metadata, "language", NULL, 0);
if ((entry != NULL) && (entry->value != NULL)) {
SDL_strlcpy(language, entry->value, sizeof(language));
}
break;
}
seen++;
}
}
_formatClose(&format);
return language;
}
// One subtitle track read out of the container as SubRip text, which is the form the engine's own
// subtitle loader already takes. The whole file is walked once, decoding nothing but the subtitle
// packets, so this costs a pass over the file and no picture decoding at all. NULL when the track
// does not exist, holds pictures rather than words, or yields nothing; free() what comes back.
char *videoReadSubtitles(int32_t playerHandle, int32_t track) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoReadSubtitles");
AVFormatContext *format = _formatOpen(v->filename);
AVCodecContext *codec = NULL;
const AVCodec *decoder = NULL;
AVPacket *packet = NULL;
AVSubtitle subtitle;
AVRational timeBase;
char *text = NULL;
char *words = NULL;
char line[SRT_CUE_MAX];
char from[SRT_TIME_MAX];
char to[SRT_TIME_MAX];
size_t used = 0;
size_t room = 0;
int64_t start = 0;
int64_t end = 0;
int32_t stream = -1;
int32_t seen = 0;
int32_t cues = 0;
int got = 0;
uint32_t x = 0;
bool ok = true;
if (format == NULL) {
return NULL;
}
if (avformat_find_stream_info(format, NULL) < 0) {
_formatClose(&format);
return NULL;
}
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
continue;
}
if (seen == track) {
stream = (int32_t)x;
break;
}
seen++;
}
if (stream < 0) {
_formatClose(&format);
return NULL;
}
timeBase = format->streams[stream]->time_base;
decoder = avcodec_find_decoder(format->streams[stream]->codecpar->codec_id);
codec = (decoder != NULL) ? avcodec_alloc_context3(decoder) : NULL;
packet = av_packet_alloc();
if ((codec == NULL) || (packet == NULL) ||
(avcodec_parameters_to_context(codec, format->streams[stream]->codecpar) < 0) ||
(avcodec_open2(codec, decoder, NULL) < 0)) {
utilTrace("Video %d: subtitle track %d has no decoder in this build.", v->id, track);
av_packet_free(&packet);
avcodec_free_context(&codec);
_formatClose(&format);
return NULL;
}
while (ok && (av_read_frame(format, packet) >= 0)) {
if (packet->stream_index != stream) {
av_packet_unref(packet);
continue;
}
memset(&subtitle, 0, sizeof(subtitle));
got = 0;
if (avcodec_decode_subtitle2(codec, &subtitle, &got, packet) >= 0) {
if (got != 0) {
start = (int64_t)((double)packet->pts * av_q2d(timeBase) * MS_PER_SECOND) + (int64_t)subtitle.start_display_time;
if (subtitle.end_display_time > subtitle.start_display_time) {
end = start + (int64_t)(subtitle.end_display_time - subtitle.start_display_time);
} else {
end = start + (int64_t)((double)packet->duration * av_q2d(timeBase) * MS_PER_SECOND);
}
for (x = 0; (x < subtitle.num_rects) && ok; x++) {
words = _srtFromRect(subtitle.rects[x]);
if (words == NULL) {
continue;
}
cues++;
_srtTime(start, from, sizeof(from));
_srtTime(end, to, sizeof(to));
snprintf(line, sizeof(line), "%d\n", cues);
ok = _srtAppend(&text, &used, &room, line) &&
_srtAppend(&text, &used, &room, from) &&
_srtAppend(&text, &used, &room, " --> ") &&
_srtAppend(&text, &used, &room, to) &&
_srtAppend(&text, &used, &room, "\n") &&
_srtAppend(&text, &used, &room, words) &&
_srtAppend(&text, &used, &room, "\n\n");
free(words);
}
}
avsubtitle_free(&subtitle);
}
av_packet_unref(packet);
}
av_packet_free(&packet);
avcodec_free_context(&codec);
_formatClose(&format);
if (!ok || (cues == 0)) {
free(text);
return NULL;
}
utilTrace("Video %d: subtitle track %d read as %d cues.", v->id, track, cues);
return text;
}
int64_t videoGetFrame(int32_t playerHandle) {
return _getPlayer(playerHandle, "videoGetFrame")->frame;
}
@ -2114,38 +1974,6 @@ MIX_Mixer *videoGetMixer(void) {
}
// What the decoder will be, for the trace header: the platform's hardware decoders in the order
// they are tried, or software when there are none or --softwarevideo ruled them out. Which one a
// given video actually got is traced as that video opens, since a codec may offer neither.
const char *videoGetDecoderDescription(void) {
if (!_hardwareDecoding) {
return "software only (--softwarevideo)";
}
if (V4L2_DECODE) {
// An ARM build may carry three: the stateless V4L2 decoders, Rockchip's own, and the
// stateful V4L2 ones. Which of them a board actually has is a question for the board.
if (RKMPP_DECODE && V4L2_REQUEST_DECODE) {
return "hardware v4l2 stateless, rkmpp or v4l2m2m where the board allows, software otherwise";
}
if (V4L2_REQUEST_DECODE) {
return "hardware v4l2 stateless or v4l2m2m where the board allows, software otherwise";
}
if (RKMPP_DECODE) {
return "hardware rkmpp or v4l2m2m where the board allows, software otherwise";
}
return "hardware v4l2m2m where the codec allows, software otherwise";
}
#if defined(_WIN32)
return "hardware d3d11va or dxva2 where the codec allows, software otherwise";
#elif defined(__APPLE__)
return "hardware videotoolbox where the codec allows, software otherwise";
#else
return "hardware vaapi or vdpau where the codec allows, software otherwise";
#endif
}
// Reads one pixel of the frame being shown. Returns false if there is no frame yet.
bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixel");
@ -2200,6 +2028,65 @@ bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch
}
// The language of a subtitle track as the file labels it, "" when it says nothing. The pointer is
// good until the next call.
const char *videoGetSubtitleLanguage(int32_t playerHandle, int32_t track) {
static char language[LANGUAGE_CODE_BYTES];
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetSubtitleLanguage");
AVFormatContext *format = _formatOpen(v->filename);
AVDictionaryEntry *entry = NULL;
int32_t seen = 0;
uint32_t x = 0;
language[0] = '\0';
if (format == NULL) {
return language;
}
if (avformat_find_stream_info(format, NULL) >= 0) {
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
continue;
}
if (seen == track) {
entry = av_dict_get(format->streams[x]->metadata, "language", NULL, 0);
if ((entry != NULL) && (entry->value != NULL)) {
SDL_strlcpy(language, entry->value, sizeof(language));
}
break;
}
seen++;
}
}
_formatClose(&format);
return language;
}
// How many subtitle tracks the file holds, bitmap ones included: they are counted so that the
// numbering matches what a player or ffprobe shows, and videoReadSubtitles says which have words.
int32_t videoGetSubtitleTracks(int32_t playerHandle) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetSubtitleTracks");
AVFormatContext *format = _formatOpen(v->filename);
int32_t count = 0;
uint32_t x = 0;
if (format == NULL) {
return 0;
}
if (avformat_find_stream_info(format, NULL) >= 0) {
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type == AVMEDIA_TYPE_SUBTITLE) {
count++;
}
}
}
_formatClose(&format);
return count;
}
void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetVolume");
@ -2397,6 +2284,119 @@ void videoQuit(void) {
}
// One subtitle track read out of the container as SubRip text, which is the form the engine's own
// subtitle loader already takes. The whole file is walked once, decoding nothing but the subtitle
// packets, so this costs a pass over the file and no picture decoding at all. NULL when the track
// does not exist, holds pictures rather than words, or yields nothing; free() what comes back.
char *videoReadSubtitles(int32_t playerHandle, int32_t track) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoReadSubtitles");
AVFormatContext *format = _formatOpen(v->filename);
AVCodecContext *codec = NULL;
const AVCodec *decoder = NULL;
AVPacket *packet = NULL;
AVSubtitle subtitle;
AVRational timeBase;
char *text = NULL;
char *words = NULL;
char line[SRT_CUE_MAX];
char from[SRT_TIME_MAX];
char to[SRT_TIME_MAX];
size_t used = 0;
size_t room = 0;
int64_t start = 0;
int64_t end = 0;
int32_t stream = -1;
int32_t seen = 0;
int32_t cues = 0;
int got = 0;
uint32_t x = 0;
bool ok = true;
if (format == NULL) {
return NULL;
}
if (avformat_find_stream_info(format, NULL) < 0) {
_formatClose(&format);
return NULL;
}
for (x = 0; x < format->nb_streams; x++) {
if (format->streams[x]->codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
continue;
}
if (seen == track) {
stream = (int32_t)x;
break;
}
seen++;
}
if (stream < 0) {
_formatClose(&format);
return NULL;
}
timeBase = format->streams[stream]->time_base;
decoder = avcodec_find_decoder(format->streams[stream]->codecpar->codec_id);
codec = (decoder != NULL) ? avcodec_alloc_context3(decoder) : NULL;
packet = av_packet_alloc();
if ((codec == NULL) || (packet == NULL) ||
(avcodec_parameters_to_context(codec, format->streams[stream]->codecpar) < 0) ||
(avcodec_open2(codec, decoder, NULL) < 0)) {
utilTrace("Video %d: subtitle track %d has no decoder in this build.", v->id, track);
av_packet_free(&packet);
avcodec_free_context(&codec);
_formatClose(&format);
return NULL;
}
while (ok && (av_read_frame(format, packet) >= 0)) {
if (packet->stream_index != stream) {
av_packet_unref(packet);
continue;
}
memset(&subtitle, 0, sizeof(subtitle));
got = 0;
if (avcodec_decode_subtitle2(codec, &subtitle, &got, packet) >= 0) {
if (got != 0) {
start = (int64_t)((double)packet->pts * av_q2d(timeBase) * MS_PER_SECOND) + (int64_t)subtitle.start_display_time;
if (subtitle.end_display_time > subtitle.start_display_time) {
end = start + (int64_t)(subtitle.end_display_time - subtitle.start_display_time);
} else {
end = start + (int64_t)((double)packet->duration * av_q2d(timeBase) * MS_PER_SECOND);
}
for (x = 0; (x < subtitle.num_rects) && ok; x++) {
words = _srtFromRect(subtitle.rects[x]);
if (words == NULL) {
continue;
}
cues++;
_srtTime(start, from, sizeof(from));
_srtTime(end, to, sizeof(to));
snprintf(line, sizeof(line), "%d\n", cues);
ok = _srtAppend(&text, &used, &room, line) &&
_srtAppend(&text, &used, &room, from) &&
_srtAppend(&text, &used, &room, " --> ") &&
_srtAppend(&text, &used, &room, to) &&
_srtAppend(&text, &used, &room, "\n") &&
_srtAppend(&text, &used, &room, words) &&
_srtAppend(&text, &used, &room, "\n\n");
free(words);
}
}
avsubtitle_free(&subtitle);
}
av_packet_unref(packet);
}
av_packet_free(&packet);
avcodec_free_context(&codec);
_formatClose(&format);
if (!ok || (cues == 0)) {
free(text);
return NULL;
}
utilTrace("Video %d: subtitle track %d read as %d cues.", v->id, track, cues);
return text;
}
// Replaces the audio side with another file (NULL: the video's own audio, or silence when it has
// none) and realigns it at the current frame, keeping the volume, the play state and the track
// index when the new file has it. False, with the old audio untouched, when the file cannot be read.
@ -2485,12 +2485,6 @@ void videoSetBlend(int32_t playerHandle, bool enabled) {
}
// Chosen before any video loads; existing players keep whatever they opened with.
void videoSetHardwareDecoding(bool enabled) {
_hardwareDecoding = enabled;
}
// Brightens or darkens the picture. The level runs 0 to LUMA_LEVEL_MAX with LUMA_LEVEL_NEUTRAL
// changing nothing, and the distance from neutral is applied to every luma sample in eighths, so 0
// halves the brightness and 8 raises it by half. Turning it off, or asking for the neutral level,
@ -2500,6 +2494,12 @@ void videoSetDeinterlace(DeinterlaceE mode) {
}
// Chosen before any video loads; existing players keep whatever they opened with.
void videoSetHardwareDecoding(bool enabled) {
_hardwareDecoding = enabled;
}
void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetLuma");

View file

@ -1,4 +1,4 @@
-- A game description: data, not code. Singe/AuthorCompile.singe turns it into a Singe game.
-- A game description: data, not code. Forge/AuthorCompile.singe turns it into a Singe game.
--
-- Genre one of two. This one uses a single world2d layer and shares nothing with the QTE beside
-- it -- no disc, no branching, no time windows -- which is the point: if the core can express

View file

@ -63,6 +63,26 @@ check("f:lines count", count, 3)
check("f:lines leaves it open", io.type(g), "file")
g:close()
-- More than one format a step keeps every value, and a later format coming up empty hands back
-- what came before it with nil in its place, as Lua does; only an empty first read ends the loop.
local pairs = {}
for a, b in io.lines("packedIo/numbers.txt", "n", "n") do
pairs[#pairs + 1] = { a, b }
end
check("lines n n steps", #pairs, 2)
check("lines n n first", pairs[1][1] + pairs[1][2], 46.5)
check("lines n n second", pairs[2][1], 16)
check("lines n n short", pairs[2][2], nil)
-- read(0) is the test for the end of the file.
local z = io.open("packedIo/tail.txt")
check("read 0 with more", z:read(0), "")
z:read("a")
check("read 0 at end", z:read(0), nil)
z:close()
-- Numbers, and the default input.
local n = io.open("packedIo/numbers.txt")

View file

@ -59,16 +59,16 @@ function onOverlayUpdate()
frames = frames + 1
if not held then
authorKeyDown(0, SCANCODE.RIGHT)
authorKeyDown(0, SCANCODE.RIGHT.value)
held = true
end
-- Jump once, when the hero is under way and has ground beneath it.
if (not jumped) and (hx >= JUMP_AT) and playerIsOnGround(hero.node) then
authorKeyDown(0, SCANCODE.SPACE)
authorKeyDown(0, SCANCODE.SPACE.value)
jumped = true
elseif jumped then
authorKeyUp(0, SCANCODE.SPACE)
authorKeyUp(0, SCANCODE.SPACE.value)
end
local r = gameUpdate()

View file

@ -77,7 +77,7 @@ local function step()
if after == before then
debugPrint("RULES FAIL the new rule did not reach the compiled game")
elseif after:find('authorKeyHeld%(SCANCODE%.UP%)') and after:find('authorOnGround%("hero"%)') and after:find('authorJump%("hero"%)') then
elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%("hero"%)') and after:find('authorJump%("hero"%)') then
debugPrint("RULES the compiled game carries the new rule")
else
debugPrint("RULES FAIL the compiled game is missing part of the new rule")
@ -89,7 +89,7 @@ local function step()
forgePartDelete()
local after = compiled()
if after:find('authorKeyHeld%(SCANCODE%.UP%)') then
if after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') then
debugPrint("RULES FAIL the deleted condition is still compiled")
else
debugPrint("RULES the deleted condition is gone from the compiled game")

236
testScripts/scene57.singe Normal file
View file

@ -0,0 +1,236 @@
-- Forge as a game (PLAN section 58): the chooser, the engine callbacks, and the editing that
-- needs no pointer -- entities added, renamed and deleted, a rule built from the pickers, undo.
--
-- Unlike scene54 to 56 this does not set FORGE_LIBRARY: Forge installs its own callbacks, and
-- the scene drives them the way the engine would, two integers to onKeyPressed. What is
-- asserted is that every key reaches the description and that a rename reaches the rules.
dofile("Forge/Forge.singe")
local forgeUpdate = onOverlayUpdate
local frames = 0
local stage = 0
local opened = nil
local function key(code, character)
onKeyPressed(character or 0, code.value)
end
local function typeIn(text)
for c in string.gmatch(text, ".") do
key(SCANCODE.A, string.byte(c))
end
end
local function step()
if stage == 1 then
-- The chooser is up and offers a new game at least.
if FORGE.game ~= nil then
debugPrint("GAME FAIL the editor is open before anything was chosen")
end
debugPrint("GAME the chooser offers " .. #forgeFiles() .. " entries")
-- The last entry is always a new game.
for _ = 1, 20 do
key(SCANCODE.DOWN)
end
key(SCANCODE.RETURN)
if FORGE.game == nil then
debugPrint("GAME FAIL ENTER on the chooser opened nothing")
else
opened = FORGE.path
debugPrint("GAME opened " .. opened .. " with " .. #FORGE.game.entities .. " entities")
end
elseif stage == 2 then
-- Add an entity, name it, and give it a look by typing the fields ENTER walks.
local count = #FORGE.game.entities
key(SCANCODE.A)
if #FORGE.game.entities ~= count + 1 then
debugPrint("GAME FAIL A did not add an entity")
end
key(SCANCODE.RETURN) -- id
typeIn("coin")
key(SCANCODE.RETURN) -- x
typeIn("300")
key(SCANCODE.RETURN) -- y
typeIn("200")
key(SCANCODE.RETURN) -- kind
key(SCANCODE.ESCAPE) -- untouched
local coin = FORGE.game.entities[#FORGE.game.entities]
if coin.id == "coin" and coin.x == 300 and coin.y == 200 and coin.look.kind == "box" then
debugPrint("GAME typed an entity in: " .. coin.id .. " at " .. coin.x .. "," .. coin.y)
else
debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y) .. " " .. tostring(coin.look.kind))
end
elseif stage == 3 then
-- Renaming the hero has to reach every rule that names it.
key(SCANCODE.UP)
key(SCANCODE.UP)
key(SCANCODE.UP)
key(SCANCODE.UP)
key(SCANCODE.DOWN) -- ground, then hero
key(SCANCODE.RETURN)
typeIn("player")
key(SCANCODE.ESCAPE) -- ESC puts it back: the hero keeps its name
if FORGE.game.entities[FORGE.selected].id ~= "hero" then
debugPrint("GAME FAIL escape did not put the name back")
end
key(SCANCODE.RETURN)
typeIn("player")
key(SCANCODE.RETURN) -- commits, moves on to x
key(SCANCODE.ESCAPE)
local named = 0
for _, rule in ipairs(FORGE.game.rules) do
for _, item in ipairs(rule.act) do
if item.entity == "player" then
named = named + 1
end
end
end
if FORGE.game.entities[FORGE.selected].id == "player" and named == 3 then
debugPrint("GAME renamed the hero and " .. named .. " actions followed")
else
debugPrint("GAME FAIL rename: id " .. tostring(FORGE.game.entities[FORGE.selected].id) .. ", " .. named .. " actions renamed")
end
elseif stage == 4 then
-- A rule from the pickers: N, then C picks the first condition, T the first action.
local rules = #FORGE.game.rules
key(SCANCODE.TAB)
key(SCANCODE.N)
key(SCANCODE.C)
if FORGE.picker == nil then
debugPrint("GAME FAIL C opened no picker")
end
key(SCANCODE.DOWN)
key(SCANCODE.RETURN)
key(SCANCODE.T)
key(SCANCODE.RETURN)
local rule = FORGE.game.rules[#FORGE.game.rules]
if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 then
debugPrint("GAME built a rule from the pickers: when " .. rule.when[1][1] .. " then " .. rule.act[1][1])
else
debugPrint("GAME FAIL the picked rule has " .. #(rule.when or {}) .. " conditions and " .. #(rule.act or {}) .. " actions")
end
elseif stage == 5 then
-- Undo takes the action back, then the condition, then the rule; redo brings the rule
-- back, and a change by hand ends the redo history.
local rules = #FORGE.game.rules
key(SCANCODE.U)
key(SCANCODE.U)
key(SCANCODE.U)
if #FORGE.game.rules == rules - 1 then
debugPrint("GAME three undos took the rule back out")
else
debugPrint("GAME FAIL after three undos there are " .. #FORGE.game.rules .. " rules, not " .. (rules - 1))
end
key(SCANCODE.R)
if (#FORGE.game.rules == rules) and (#FORGE.game.rules[rules].when == 0) then
debugPrint("GAME redo brought the empty rule back")
else
debugPrint("GAME FAIL redo left " .. #FORGE.game.rules .. " rules")
end
key(SCANCODE.U)
key(SCANCODE.N)
key(SCANCODE.R)
if #FORGE.redo == 0 and #FORGE.game.rules == rules then
debugPrint("GAME a new change emptied the redo history")
else
debugPrint("GAME FAIL redo history survived a change: " .. #FORGE.redo .. " kept")
end
key(SCANCODE.U)
elseif stage == 6 then
-- Duplicate and delete in the entity list.
key(SCANCODE.TAB)
local count = #FORGE.game.entities
key(SCANCODE.D)
key(SCANCODE.DELETE)
if #FORGE.game.entities == count then
debugPrint("GAME duplicate then delete leaves " .. count .. " entities")
else
debugPrint("GAME FAIL duplicate then delete leaves " .. #FORGE.game.entities)
end
elseif stage == 7 then
-- ESC on unsaved work warns, S saves, ESC then leaves; the chooser lists the file.
key(SCANCODE.ESCAPE)
if FORGE.game == nil then
debugPrint("GAME FAIL one ESC closed unsaved work")
end
key(SCANCODE.S)
key(SCANCODE.ESCAPE)
if FORGE.game ~= nil then
debugPrint("GAME FAIL ESC did not close a saved description")
end
local listed = false
for _, entry in ipairs(forgeFiles()) do
if entry.path == opened then
listed = true
end
end
debugPrint("GAME closed; the chooser lists what was saved: " .. tostring(listed))
elseif stage == 8 then
-- Open it again from the chooser -- it is listed first -- and the rename is in the file.
for _ = 1, 20 do
key(SCANCODE.UP)
end
key(SCANCODE.RETURN)
if FORGE.game == nil then
debugPrint("GAME FAIL could not reopen the saved description")
else
local found = false
for _, entity in ipairs(FORGE.game.entities) do
if entity.id == "player" then
found = true
end
end
debugPrint("GAME reopened " .. FORGE.path .. "; the rename was saved: " .. tostring(found))
end
elseif stage == 9 then
-- B builds beside the runtime, which is what makes the result playable.
key(SCANCODE.B)
local runtime = io.open(singeGetDataPath() .. "Author.singe", "r")
if runtime == nil then
debugPrint("GAME FAIL build left no runtime beside the preview")
else
runtime:close()
debugPrint("GAME the build put the runtime beside the preview")
end
debugPrint("GAME RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 12 == 0 and stage < 10 then
stage = stage + 1
step()
end
forgeUpdate()
if frames == 6 or frames == 40 or frames == 60 or frames == 100 then
singeScreenshot()
end
if frames > 130 then
singeQuit()
end
return OVERLAY_UPDATED
end