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 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. 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 - A game can be described rather than written. Forge/Author.singe and
Singe/AuthorCompile.singe take a table of layers, entities, behaviours Forge/AuthorCompile.singe take a table of layers, entities, behaviours
and rules and compile it into an ordinary Singe game: the rules become 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 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 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 releases, and the "lua" action is the deliberate way out when a rule
needs something the vocabulary cannot say. See the manual. 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 - An editor for those descriptions, Forge, which is itself
a Singe game: the canvas is the same overlay at the same coordinates the 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 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 materialNew, sceneGetSize and sceneGetStats no longer reject extra
arguments. 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 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 # 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. # 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/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/Master.singe ${GENERATED_DIR}/Master_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Backdrop.singe ${GENERATED_DIR}/Backdrop_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 "") 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/inspect.lua/inspect.lua "")
singeEmbedLua(thirdparty/bump.lua/bump.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 # Optional HTML manual for browsing: cmake --build . --target docs
if(ASCIIDOCTOR) if(ASCIIDOCTOR)
add_custom_target(docs 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 # 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. # 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) if(EXISTS ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc)
file(READ ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc avcodecPc) file(READ ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc avcodecPc)
if(avcodecPc MATCHES "rockchip_mpp") if(avcodecPc MATCHES "rockchip_mpp")
@ -857,9 +869,9 @@ endif()
# System libraries follow the static ones so their symbols resolve for everything above. # 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. # Apple's linker has no --start-group, so the list is repeated there instead.
if(KANGAROO_OS STREQUAL "macos") 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() 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() endif()

View file

@ -67,3 +67,9 @@ Fonts
----- -----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont 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 backdropTagX = 0
local backdropTagY = 0 local backdropTagY = 0
local blown = false -- Whether the charge has gone off. 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 local backdropFont = nil
-- ===== The backdrop ========================================================================== -- ===== The backdrop ==========================================================================
@ -473,7 +474,6 @@ function backdropFrame()
emitterBurst(core, 110) emitterBurst(core, 110)
emitterBurst(flash, 5) emitterBurst(flash, 5)
emitterBurst(sparks, 130) emitterBurst(sparks, 130)
lightSetIntensity(fireLight, 400)
end end
-- The fire's light dies over the first second and a half. -- The fire's light dies over the first second and a half.
@ -483,13 +483,15 @@ function backdropFrame()
-- there is no moment where one thing has ended and the next has not begun. -- there is no moment where one thing has ended and the next has not begun.
local lit = span(t, GRID_UP, GRID_LIT) 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(gridMat, 255 * lit, 45 * lit, 190 * lit)
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit) materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * 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. -- 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) sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR) 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 end
if lit > 0 then if lit > 0 then
-- Scrolling from the moment it is visible, so it is already alive when it arrives. -- 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)) backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
blown = not showIntro blown = not showIntro
gridLit = false
if not showIntro then if not showIntro then
nodeSetVisible(logo, false) nodeSetVisible(logo, false)
end end

View file

@ -25,7 +25,7 @@
-- The runtime an authored game is compiled against (PLAN section 58). -- 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 -- 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 -- `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. -- and edited by hand like any other game.
@ -163,7 +163,9 @@ AUTHOR.behaviours.solid = {
help = "Immovable ground or a wall.", help = "Immovable ground or a wall.",
params = {}, params = {},
attach = function(entity) 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 end
} }
@ -171,9 +173,11 @@ AUTHOR.behaviours.platformer = {
help = "Runs, falls and jumps: the engine's character controller in 2D.", help = "Runs, falls and jumps: the engine's character controller in 2D.",
params = { speed = "number", jump = "number" }, params = { speed = "number", jump = "number" },
attach = function(entity, params) attach = function(entity, params)
local w, h = authorSize(entity)
entity.speed = params.speed or 200 entity.speed = params.speed or 200
entity.jump = params.jump or 500 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, end,
step = function(entity) step = function(entity)
-- The rules say which way; this clears it each frame so releasing a key stops the run. -- 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 = { AUTHOR.conditions.keyHeld = {
help = "A key is down.", help = "A key is down.",
params = { key = "scancode" }, 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 = { AUTHOR.conditions.switchHeld = {
@ -368,9 +372,23 @@ function authorTouching(idA, idB)
local b = AUTHOR_WORLD[idB] local b = AUTHOR_WORLD[idB]
local ax, ay = authorPosition(a) local ax, ay = authorPosition(a)
local bx, by = authorPosition(b) 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, return collideRects(ax - aw / 2, ay - ah / 2, aw, ah, bx - bw / 2, by - bh / 2, bw, bh)
bx - b.look.w / 2, by - b.look.h / 2, b.look.w, b.look.h) 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 end
@ -431,9 +449,17 @@ function authorMake(description)
node = nodeNew() node = nodeNew()
} }
local look = AUTHOR.looks[entity.look.kind] local look = AUTHOR.looks[entity.look.kind]
local b
nodeSetPosition(entity.node, description.x or 0, description.y or 0, 0) 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 if look.load then
look.load(entity) look.load(entity)
end end
@ -458,8 +484,6 @@ end
-- Starts the layers the game declared. Anything a layer needs of the engine is asked for here and -- 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. -- nowhere else, which is what keeps the rest of this file free of genre.
function authorBegin(layers) function authorBegin(layers)
local layer
-- A font, because the text look draws with fontPrint and fontPrint ends the game when none is -- 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, -- 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. -- and died on its first text entity. A game that wants its own calls fontSelect afterwards.
@ -489,12 +513,9 @@ end
function authorFrame(rules) function authorFrame(rules)
local now = authorTime() local now = authorTime()
local dt = now - lastTime local dt = now - lastTime
local entity
lastTime = now lastTime = now
for _, entity in ipairs(AUTHOR_ORDER) do for _, entity in ipairs(AUTHOR_ORDER) do
local b
for _, b in ipairs(entity.behaviours) do for _, b in ipairs(entity.behaviours) do
local kind = AUTHOR.behaviours[b.kind] 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 -- 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 -- 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 -- 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 -- already Lua. Author.singe, the runtime that game loads, travels with the game: a copy is
-- file: a game handed to someone who has never installed Forge still has to run. -- 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" local INDENT = "\t"
@ -58,7 +62,6 @@ end
local function tableSource(t) local function tableSource(t)
local parts = {} local parts = {}
local keys = {} local keys = {}
local key
for key in pairs(t) do for key in pairs(t) do
keys[#keys + 1] = key keys[#keys + 1] = key
@ -88,7 +91,6 @@ end
local function emitConditions(rule) local function emitConditions(rule)
local tests = {} local tests = {}
local item
for _, item in ipairs(rule.when or {}) do for _, item in ipairs(rule.when or {}) do
local test = emitOne(AUTHOR.conditions, item, "condition") local test = emitOne(AUTHOR.conditions, item, "condition")
@ -108,8 +110,6 @@ end
local function emitRule(out, rule, index) 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("%s-- %s", INDENT, rule.note or ("rule " .. index))
out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule)) out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule))
for _, item in ipairs(rule.act or {}) do 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. -- the engine's, as it is for every game ever written for Singe.
function authorCompile(game) function authorCompile(game)
local out = {} 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] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--" out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then" 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 for _, entity in ipairs(game.entities or {}) do
local parts = {} local parts = {}
local b
parts[#parts + 1] = string.format("id = %q", entity.id) 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) 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 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. -- The directory part of a path, with its separator, or "" for a bare name.
local function directoryOf(path) local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "") 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 -- 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. -- better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed.
local function isLeaf(t) local function isLeaf(t)
local key
local value
for key, value in pairs(t) do for key, value in pairs(t) do
if type(value) == "table" then if type(value) == "table" then
return false return false
@ -288,8 +277,6 @@ local function valueSource(value, depth)
local parts = {} local parts = {}
local keys = {} local keys = {}
local count = 0 local count = 0
local key
local item
local sep local sep
local open local open
local close local close
@ -340,7 +327,6 @@ end
function authorSave(game, path) function authorSave(game, path)
local file = assert(io.open(path, "w")) local file = assert(io.open(path, "w"))
local copy = {} local copy = {}
local key
-- source says where a description came from and is set by the loader, so writing it back out -- 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. -- 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] copy[key] = game[key]
end end
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:write("return " .. valueSource(copy, 0) .. "\n")
file:close() file:close()

File diff suppressed because it is too large Load diff

View file

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

View file

@ -81,12 +81,18 @@ end
function loadGameAssets() function loadGameAssets()
local game = GAME_LIST[GAME_SELECTED] 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) VIDEO_ATTRACT = videoLoad(game.ATTRACT)
if game.AUDIO_TRACK then if game.AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK) videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK)
end end
videoPlay(VIDEO_ATTRACT) 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) MENU_RENDER.showGame(game)
end end
@ -96,18 +102,26 @@ end
MENU_MISSING_ART = "Singe/missing.png" 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. -- Whether a name is a file the engine can open. Asked of the index rather than of the file:
function menuArtPath(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 path = tostring(file or "")
local found = (path ~= "") and lfs.attributes(path) or nil 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 return (found ~= nil) and (found.mode ~= "directory")
-- Lua a real file it can read with the C library, so every picture it was asked about was end
-- 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 -- A readable art path: the game's own when it can be opened, the missing-art picture when not.
-- goes through the same virtual filesystem and answers from the index, opening nothing. function menuArtPath(file)
if (found == nil) or (found.mode == "directory") then local path = tostring(file or "")
if not menuFileExists(path) then
if path ~= "" then if path ~= "" then
debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.") debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.")
end end
@ -209,8 +223,10 @@ function onOverlayUpdate()
end end
-- The attract clip runs between the two frames the game's entry names. -- 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 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 end
MENU_RENDER.frame() MENU_RENDER.frame()
end end
@ -221,6 +237,10 @@ end
function onShutdown() 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() unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH) saveConfig(not SHUTDOWN_FROM_PUSH)
MENU_RENDER.finish() MENU_RENDER.finish()
@ -306,7 +326,7 @@ for dir in lfs.dir(".") do
local dirattr = lfs.attributes(dir) local dirattr = lfs.attributes(dir)
if dir:sub(-5):lower() == ".game" then if dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir) loadGamesDat(dir .. "/games.dat", dir)
elseif dirattr.mode == "directory" then elseif dirattr and (dirattr.mode == "directory") then
for file in lfs.dir(dir .. "/.") do for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil) 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_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_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 SCROLL_LINE = 16 -- How far a step goes: a line of the description.
local backdropRoot = nil
local backdropGrid = nil
function menuElement(id) function menuElement(id)
@ -358,7 +356,9 @@ end
MENU_RENDER.frame = function() MENU_RENDER.frame = function()
-- Attract Mode Video, under the hole the document leaves for it -- Attract Mode Video, under the hole the document leaves for it
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
if scrollPending then if scrollPending then
menuScrollToSelection() menuScrollToSelection()
end end

View file

@ -430,7 +430,9 @@ MENU_RENDER.frame = function()
spriteDraw(spriteMarquee, x, y) spriteDraw(spriteMarquee, x, y)
-- Attract mode video -- Attract mode video
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH) 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 -- 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. -- 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 -- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated. -- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
--
-- Without a pin the chain is verified against a CA list. Linux has one; Windows, macOS and the
-- 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") 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. -- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
NET_PINS = {} NET_PINS = {}
NET_CA_SYSTEM = "/etc/ssl/certs/ca-certificates.crt" -- Linux's list, when it is there,
NET_CA_SHIPPED = "Singe/cacerts.pem" -- and the one Singe carries otherwise.
local active = {} local active = {}
local nextId = 1 local nextId = 1
@ -149,17 +157,28 @@ local function buildRequest(request)
end 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 function startTls(request)
local params = { local params = {
mode = "client", mode = "client",
protocol = "any", protocol = "any",
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" }, options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is -- With a pin, the chain is checked against the key rather than a CA list, and the pin is
-- what decides. Without one, the host's own store is asked -- and if it has nothing to -- what decides. Without one, a CA list is asked -- and if it has nothing to say, the
-- say, the request fails rather than proceeding unverified. -- request fails rather than proceeding unverified.
verify = (#NET_PINS > 0) and "none" or "peer", verify = (#NET_PINS > 0) and "none" or "peer",
capath = "/etc/ssl/certs", cafile = caFile(),
cafile = "/etc/ssl/certs/ca-certificates.crt",
} }
local wrapped, err = ssl.wrap(request.socket, params) local wrapped, err = ssl.wrap(request.socket, params)
@ -238,6 +257,12 @@ local function splitHead(request)
end end
request.length = tonumber(request.responseHeaders["content-length"]) request.length = tonumber(request.responseHeaders["content-length"])
request.got = 0 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 if request.toFile then
local file, err = io.open(request.toFile, "wb") local file, err = io.open(request.toFile, "wb")
if not file then if not file then
@ -263,6 +288,46 @@ local function consume(request, data)
end 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 function complete(request)
local body = request.file and "" or table.concat(request.parts) local body = request.file and "" or table.concat(request.parts)
@ -277,6 +342,7 @@ local function complete(request)
local _, _, port, path = parseUrl(location) local _, _, port, path = parseUrl(location)
request.port, request.path = port, path request.port, request.path = port, path
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0 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 if request.socket then pcall(function() request.socket:close() end) end
request.socket = nil request.socket = nil
return beginConnect(request) return beginConnect(request)
@ -298,13 +364,22 @@ local function pumpReading(request)
if splitHead(request) and #request.buffer > 0 then if splitHead(request) and #request.buffer > 0 then
local rest = request.buffer local rest = request.buffer
request.buffer = "" request.buffer = ""
if request.chunked then
consumeChunked(request, rest)
else
consume(request, rest) consume(request, rest)
end end
end
elseif request.chunked then
consumeChunked(request, got)
else else
consume(request, got) consume(request, got)
end end
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) return complete(request)
end end
if err == "closed" then 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 -- 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, -- 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. -- and cannot remove from anywhere but a file manager.
function shopAddWithdrawn() local function shopAddWithdrawn()
local listed = {} local listed = {}
for _, game in ipairs(shopGames) do 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 #!/usr/bin/env bash
# Build the Singe manual from docs/Manual.adoc. # Build the Singe manual from docs/Manual.adoc and the Forge manual from docs/Forge.adoc.
# Produces .builddir/Manual.html and .builddir/Manual.pdf. The version comes from CMakeLists.txt. # Produces .builddir/Manual.html, Manual.pdf, Forge.html and Forge.pdf. The version comes from
# CMakeLists.txt.
set -euo pipefail set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd) here=$(cd "$(dirname "$0")" && pwd)
src=$here/docs/Manual.adoc src=$here/docs/Manual.adoc
forge=$here/docs/Forge.adoc
if [[ ! -f $src ]]; then for doc in "$src" "$forge"; do
echo "error: $src not found" >&2 if [[ ! -f $doc ]]; then
echo "error: $doc not found" >&2
exit 1 exit 1
fi fi
done
for tool in asciidoctor asciidoctor-pdf; do for tool in asciidoctor asciidoctor-pdf; do
if ! command -v "$tool" > /dev/null; then if ! command -v "$tool" > /dev/null; then
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2 echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
@ -23,6 +27,8 @@ out=$here/.builddir
mkdir -p "$out" mkdir -p "$out"
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html" asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html"
asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf" 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:" 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 # 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 # 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 # 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")) if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux"))
file(GLOB forgeSources ${CMAKE_SOURCE_DIR}/assets/Forge/*) file(GLOB forgeSources ${CMAKE_SOURCE_DIR}/assets/Forge/*)
set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game) set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game)
set(forgeManual ${CMAKE_SOURCE_DIR}/.builddir/Forge.pdf)
set(forgeStage ${SB_PREFIX}/forgepack)
add_custom_command( add_custom_command(
OUTPUT ${forgePack} OUTPUT ${forgePack}
# Packing refuses to overwrite, and a stale pack is worse than none. # Packing refuses to overwrite, and a stale pack is worse than none.
COMMAND ${CMAKE_COMMAND} -E rm -f ${forgePack} COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage}
COMMAND ${CMAKE_COMMAND} -E make_directory ${SB_PREFIX}/forgepack 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. # 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} COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack}
DEPENDS singe ${forgeSources} DEPENDS singe ${forgeSources} ${forgeManual}
COMMENT "Packing Forge into .builddir/Forge.game" COMMENT "Packing Forge into .builddir/Forge.game"
VERBATIM 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", TITLE = ".38 Ambush Alley",
SCRIPT = "ActionMax/38AmbushAlley.singe", SCRIPT = "ActionMax/38AmbushAlley.singe",
VIDEO = "ActionMax/frame_38AmbushAlley.txt", VIDEO = "ActionMax/frame_38AmbushAlley.txt",
DATA = "ActionMax",
STRETCH = false, STRETCH = false,
NO_MOUSE = false, NO_MOUSE = false,
RESOLUTION_X = 720, RESOLUTION_X = 720,
@ -3192,195 +3191,12 @@ die.
=== Describing a Game Instead of Writing One === Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and A game can be written as a *description* -- a table of layers, entities and
rules -- and compiled into an ordinary Singe game. rules -- and compiled into an ordinary Singe game by Forge, the authoring
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime tool. Forge is distributed on its own, beside the engine, and has its own
the result calls. **No part of Forge ships with Singe** -- not the editor, not manual: `docs/Forge.adoc` in the source tree, built to `Forge.html` and
the compiler, not the runtime -- so a game built with it carries its own copy of `Forge.pdf` beside this one.
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,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 === Migrating from Singe 2.10
Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`, 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) { float *decodeImageFloat(const void *bytes, size_t size, int32_t *width, int32_t *height) {
const AVPixFmtDescriptor *description = NULL; const AVPixFmtDescriptor *description = NULL;
SourceT source; SourceT source;
@ -576,3 +552,28 @@ float *decodeImageFloat(const void *bytes, size_t size, int32_t *width, int32_t
return out; 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/Menu_singe.h"
#include "generated/Tools_singe.h" #include "generated/Tools_singe.h"
#include "generated/Net_singe.h" #include "generated/Net_singe.h"
#include "generated/cacerts_pem.h"
#include "generated/Master_singe.h" #include "generated/Master_singe.h"
#include "generated/Backdrop_singe.h" #include "generated/Backdrop_singe.h"
#include "generated/MenuDocument_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 *surface = nullptr;
SDL_Surface *converted = nullptr; SDL_Surface *converted = nullptr;
Rml::byte *pixels = nullptr; Rml::byte *pixels = nullptr;
Rml::byte *row = nullptr;
size_t fileSize = 0; size_t fileSize = 0;
size_t pixelSize = 0; size_t rowSize = 0;
size_t i = 0; size_t x = 0;
size_t y = 0;
size_t j = 0; size_t j = 0;
size_t dot = source.rfind('.'); size_t dot = source.rfind('.');
Rml::String extension = (dot == Rml::String::npos) ? Rml::String() : source.substr(dot + 1); 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; surface = converted;
} }
// Premultiplied alpha, which compositing needs. // Premultiplied alpha, which compositing needs. Rows are packed as they go, since the surface
pixelSize = (size_t)surface->w * (size_t)surface->h * BYTES_PER_PIXEL; // 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); 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++) { 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 }; 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); SDL_DestroySurface(surface);
return handle; return handle;
} }

View file

@ -287,8 +287,8 @@ static int32_t _optionIndex(int32_t code);
static int32_t _optionNamed(const char *name); static int32_t _optionNamed(const char *name);
static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]); static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]);
static bool _parseBoolean(const char *text, bool *value); static bool _parseBoolean(const char *text, bool *value);
static bool _parseGamepadOrder(const char *text);
static bool _parseFloat(const char *text, double *value); static bool _parseFloat(const char *text, double *value);
static bool _parseGamepadOrder(const char *text);
static bool _parseInteger(const char *text, int32_t *value); static bool _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 _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit);
static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what); static void _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 #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. // 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) { static bool _extractFile(const char *filename, const uint8_t *data, size_t length) {
FILE *out = NULL; 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. // The words a settings file (and --linearscale) may use for a switch.
static bool _parseBoolean(const char *text, bool *value) { static bool _parseBoolean(const char *text, bool *value) {
static const char *yes[] = { "true", "yes", "on", "1" }; 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) { static bool _parseInteger(const char *text, int32_t *value) {
char *end = NULL; char *end = NULL;
long parsed = 0; 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 // 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 // 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. // 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 }, { "Menu.singe", Menu_singe, Menu_singe_len },
{ "Tools.singe", Tools_singe, Tools_singe_len }, { "Tools.singe", Tools_singe, Tools_singe_len },
{ "Net.singe", Net_singe, Net_singe_len }, { "Net.singe", Net_singe, Net_singe_len },
{ "cacerts.pem", cacerts_pem, cacerts_pem_len },
{ "Master.singe", Master_singe, Master_singe_len }, { "Master.singe", Master_singe, Master_singe_len },
{ "Backdrop.singe", Backdrop_singe, Backdrop_singe_len }, { "Backdrop.singe", Backdrop_singe, Backdrop_singe_len },
{ "MenuDocument.singe", MenuDocument_singe, MenuDocument_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. // Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
char *createDataDir(const char *dataDirBase, const char *filename) { char *createDataDir(const char *dataDirBase, const char *filename) {
const char separator = utilGetPathSeparator(); 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: // Ok, this thing can have a mess of different arguments:
// WW - Just the width of the white border // WW - Just the width of the white border
// WW WB - Width of white border and then black 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) { bool midiRender(const void *bytes, size_t size, DecodedAudioT *out) {
tml_message *messages = NULL; tml_message *messages = NULL;
tml_message *message = 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) { const char *midiSoundfont(void) {
return (_found != NULL) ? _found : "none found (MIDI will not play; name one with --soundfont)"; 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; return false;
} }
fclose(file); fclose(file);
// Windows will not rename onto an existing file, so the old one goes first. The window between // Windows will not rename onto an existing file, so the old one goes first there. The window
// the two is the one risk left, and it is smaller than writing in place. // 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); remove(_path);
#endif
if (rename(_temporary, _path) != 0) { if (rename(_temporary, _path) != 0) {
remove(_temporary); remove(_temporary);
return false; 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) { SDL_GPUTexture *renderTextureFor(SDL_Texture *texture) {
if (texture == NULL) { if (texture == NULL) {
return 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 // 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 // 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 GlesDeviceT _device;
static GLenum _addressMode(SDL_GPUSamplerAddressMode mode);
static void _applyVertexLayout(GlesRenderPassT *pass); static void _applyVertexLayout(GlesRenderPassT *pass);
static void _bindTextureUnit(uint32_t unit, GlesTextureT *texture, SDL_GPUSampler *sampler); 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 _blendFactor(SDL_GPUBlendFactor factor);
static GLenum _blendOp(SDL_GPUBlendOp op); static GLenum _blendOp(SDL_GPUBlendOp op);
static GLenum _compare(SDL_GPUCompareOp 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 _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 void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTextureTransferInfo *source, const SDL_GPUTextureRegion *destination, bool cycle);
static GLenum _primitive(SDL_GPUPrimitiveType type); 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 void _remapFragmentBlocks(GLuint program, const char *fragmentSource);
static GLuint _scratchFramebuffer(GLenum binding, GlesTextureT *texture, uint32_t level);
static void _setPipelineState(GlesPipelineT *pipeline); static void _setPipelineState(GlesPipelineT *pipeline);
static GLenum _stencilOp(SDL_GPUStencilOp op); static GLenum _stencilOp(SDL_GPUStencilOp op);
static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum *type, GLboolean *normalized); static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum *type, GLboolean *normalized);
@ -224,6 +224,56 @@ void renderGlesRestore(void);
bool renderGlesStart(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) { static void _bindTextureUnit(uint32_t unit, GlesTextureT *texture, SDL_GPUSampler *sampler) {
GLuint name = (GLuint)(uintptr_t)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) { static GLenum _blendFactor(SDL_GPUBlendFactor factor) {
switch (factor) { switch (factor) {
case SDL_GPU_BLENDFACTOR_ZERO: return GL_ZERO; 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 // 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. // every frame, which is a bug worth hearing about rather than hiding behind a bigger array.
utilTrace("Gles: framebuffer cache full"); utilTrace("Gles: framebuffer cache full");
for (index = 0; index < _device.framebufferCount; index++) {
glDeleteFramebuffers(1, &_device.framebuffers[index].name);
}
_device.framebufferCount = 0; _device.framebufferCount = 0;
} }
slot = &_device.framebuffers[_device.framebufferCount]; 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) { static void _glesBindVertexStorageBuffers(SDL_GPURenderPass *renderPass, Uint32 firstSlot, SDL_GPUBuffer *const *storageBuffers, Uint32 numBindings) {
uint32_t i; 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) { bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
if (!materialValid(material)) { if (!materialValid(material)) {
return false; 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) { bool materialSetDoubleSided(int32_t material, bool doubleSided) {
if (!materialValid(material)) { if (!materialValid(material)) {
return false; return false;

View file

@ -31,9 +31,11 @@
#include "util.h" #include "util.h"
#define SLOTS_FIRST 16 // Grown by doubling from here #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 BACK_OVERSHOOT 1.70158 // How far past the target the "back" easings go, the usual constant
#define ELASTIC_PERIOD 0.3 #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_SCALE 7.5625 // The bounce curve's four arcs, as everyone writes them
#define BOUNCE_SPLIT 2.75 #define BOUNCE_SPLIT 2.75
#define HALF 0.5 #define HALF 0.5
@ -47,6 +49,7 @@ typedef struct {
double from; double from;
double to; double to;
SchedulerEaseE easing; SchedulerEaseE easing;
int32_t generation; // Stepped every time the slot is given out
bool repeating; bool repeating;
bool used; bool used;
} SlotT; } SlotT;
@ -60,24 +63,33 @@ static int32_t _capacity = 0;
static int32_t _allocate(void); static int32_t _allocate(void);
static double _ease(SchedulerEaseE easing, double t); static double _ease(SchedulerEaseE easing, double t);
static double _easeBounceOut(double t); static double _easeBounceOut(double t);
static int32_t _handleOf(int32_t slot);
static SlotT *_slotOf(int32_t handle);
// ===== Internal helpers ===== // ===== Internal helpers =====
// The first free slot, growing the table when there is none. Handles are indexes, so a slot never // The first free slot, growing the table when there is none. A slot never moves while anything
// moves while anything might still name it. // 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) { static int32_t _allocate(void) {
SlotT *grown = NULL; SlotT *grown = NULL;
int32_t want = 0; int32_t want = 0;
int32_t x = 0; int32_t x = 0;
int32_t generation = 0;
for (x = 0; x < _count; x++) { for (x = 0; x < _count; x++) {
if (!_slots[x].used) { if (!_slots[x].used) {
generation = (_slots[x].generation + 1) & GENERATION_MASK;
memset(&_slots[x], 0, sizeof(_slots[x])); memset(&_slots[x], 0, sizeof(_slots[x]));
_slots[x].generation = generation;
_slots[x].used = true; _slots[x].used = true;
return x; return x;
} }
} }
if (_count > SLOT_MASK) {
utilDie("Too many timers and tweens are alive at once.");
}
if (_count == _capacity) { if (_count == _capacity) {
want = (_capacity == 0) ? SLOTS_FIRST : (_capacity * 2); want = (_capacity == 0) ? SLOTS_FIRST : (_capacity * 2);
grown = (SlotT *)realloc(_slots, sizeof(SlotT) * (size_t)want); 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 ===== // ===== Public =====
void schedulerCancel(int32_t handle) { void schedulerCancel(int32_t handle) {
if ((handle < 0) || (handle >= _count)) { SlotT *slot = _slotOf(handle);
return;
if (slot != NULL) {
slot->used = false;
} }
_slots[handle].used = false;
} }
@ -180,11 +212,7 @@ int32_t schedulerCount(void) {
bool schedulerIsActive(int32_t handle) { bool schedulerIsActive(int32_t handle) {
if ((handle < 0) || (handle >= _count)) { return _slotOf(handle) != NULL;
return false;
}
return _slots[handle].used;
} }
@ -197,8 +225,8 @@ void schedulerReset(void) {
int32_t schedulerTimer(int64_t milliseconds, bool repeating, uint64_t now) { int32_t schedulerTimer(int64_t milliseconds, bool repeating, uint64_t now) {
int32_t handle = _allocate(); int32_t index = _allocate();
SlotT *slot = &_slots[handle]; SlotT *slot = &_slots[index];
if (milliseconds < 0) { if (milliseconds < 0) {
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->due = now + (uint64_t)milliseconds;
slot->repeating = repeating; 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 schedulerTween(double from, double to, int64_t milliseconds, SchedulerEaseE easing, uint64_t now) {
int32_t handle = _allocate(); int32_t index = _allocate();
SlotT *slot = &_slots[handle]; SlotT *slot = &_slots[index];
if (milliseconds < 0) { if (milliseconds < 0) {
milliseconds = 0; milliseconds = 0;
@ -230,7 +258,7 @@ int32_t schedulerTween(double from, double to, int64_t milliseconds, SchedulerEa
slot->to = to; slot->to = to;
slot->easing = easing; slot->easing = easing;
return handle; return _handleOf(index);
} }
@ -260,7 +288,7 @@ void schedulerUpdate(uint64_t now, SchedulerFireT fire, void *context) {
slot->used = false; slot->used = false;
} }
if (fire != NULL) { if (fire != NULL) {
fire(context, x, SCHEDULER_TIMER, 0, 0, !slot->repeating); fire(context, _handleOf(x), SCHEDULER_TIMER, 0, 0, !slot->repeating);
} }
continue; continue;
} }
@ -274,7 +302,7 @@ void schedulerUpdate(uint64_t now, SchedulerFireT fire, void *context) {
slot->used = false; slot->used = false;
} }
if (fire != NULL) { 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 HISTORY 120 // Frames kept, about two seconds at sixty
#define MARGIN 8 #define MARGIN 8
#define PADDING 4 // Between the backdrop edge and the text
#define LINE_HEIGHT (SDL_DEBUG_TEXT_FONT_CHARACTER_SIZE + 2) #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 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 #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) { static void _line(SDL_Renderer *renderer, int32_t index, const char *text) {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE); 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.x = MARGIN;
backdrop.y = MARGIN; backdrop.y = MARGIN;
backdrop.w = PANEL_WIDTH; 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_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, BACKDROP_ALPHA); SDL_SetRenderDrawColor(renderer, 0, 0, 0, BACKDROP_ALPHA);
SDL_RenderFillRect(renderer, &backdrop); 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 void _listDirectory(const char *path, ListT *list);
static char **_listFinish(ListT *list, int32_t *count); static char **_listFinish(ListT *list, int32_t *count);
static char *_normalise(const char *name); 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 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 _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); 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. // 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 *stem = NULL;
char *result = NULL; char *result = NULL;
if (isContainer && (dataDir != 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)); 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); free(stem);
return result; return result;
@ -655,7 +655,7 @@ static bool _resolve(const char *name, TargetT *target) {
if (target->db != NULL) { if (target->db != NULL) {
inner = (norm[i] == 0) ? norm + i : norm + i + 1; inner = (norm[i] == 0) ? norm + i : norm + i + 1;
if (target->db->overlay == NULL) { 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); free(prefix);
break; 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); utilDie("%s is not a Singe game database.", container);
} }
free(_container->overlay); 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 // 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 // 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. // there, and a stream is the only way to read it.
bool vfsIsPacked(const char *name) { bool vfsIsPacked(const char *name) {
TargetT target; TargetT target;
int64_t size = 0;
bool packed = false; bool packed = false;
if (!_resolve(name, &target)) { if (!_resolve(name, &target)) {
@ -887,13 +884,15 @@ bool vfsIsPacked(const char *name) {
} }
packed = (target.db != NULL) && (target.key[0] != 0) && packed = (target.db != NULL) && (target.key[0] != 0) &&
!utilFileExists(target.path) && !utilFileExists(target.overlay) && !utilFileExists(target.path) && !utilFileExists(target.overlay) &&
_assetSize(target.db, target.key, &size); _assetExists(target.db, target.key);
_targetFree(&target); _targetFree(&target);
return packed; 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) { char **vfsList(const char *name, int32_t *count) {
TargetT target; TargetT target;
ListT list; 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 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 int _compareFrames(const void *a, const void *b); // qsort callback. Not changing int.
static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame); 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 DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want);
static void _decodeHere(VideoPlayerT *v); 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 int _decoderThread(void *data); // SDL thread entry. Not changing int.
static void _feedAudio(VideoPlayerT *v); static void _feedAudio(VideoPlayerT *v);
static int64_t _findFrameIndex(VideoPlayerT *v, int64_t pts); 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 _reportKeyframes(VideoPlayerT *v, const char *filename);
static void _requestFrame(VideoPlayerT *v); static void _requestFrame(VideoPlayerT *v);
static void _resetClock(VideoPlayerT *v, uint64_t now); static void _resetClock(VideoPlayerT *v, uint64_t now);
static const AVCodec *_rkmppDecoder(const AVCodec *decoder);
static void _seekVideo(VideoPlayerT *v, int64_t keyframe); static void _seekVideo(VideoPlayerT *v, int64_t keyframe);
static enum AVPixelFormat _selectPixelFormat(AVCodecContext *codec, const enum AVPixelFormat *formats); // libavcodec callback. 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 int64_t _streamTimeToMs(int64_t ts, AVRational timeBase);
static bool _takeDecodedFrame(VideoPlayerT *v); static bool _takeDecodedFrame(VideoPlayerT *v);
static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples);
static void _uploadFrame(VideoPlayerT *v); static void _uploadFrame(VideoPlayerT *v);
static const AVCodec *_rkmppDecoder(const AVCodec *decoder);
static const AVCodec *_v4l2Decoder(const AVCodec *decoder); static const AVCodec *_v4l2Decoder(const AVCodec *decoder);
static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header); 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 // 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. // message is in threadErrMsg; the caller raises threadError under the lock, which publishes both.
static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) { 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. // Keeps the mixer track's stream topped up from the current audio track.
static void _feedAudio(VideoPlayerT *v) { static void _feedAudio(VideoPlayerT *v) {
int32_t result = 0; 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 // 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. // 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. // 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) { static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase) {
return av_rescale(ts, (int64_t)timeBase.num * (int64_t)MS_PER_SECOND, timeBase.den); 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 AVCodec *_v4l2Decoder(const AVCodec *decoder) {
static const V4l2DecoderT table[] = { static const V4l2DecoderT table[] = {
{ AV_CODEC_ID_H263, "h263_v4l2m2m" }, { 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 // Frames per second of the loaded video, which is what a subtitle's timestamps are turned into
// frame numbers with. // frame numbers with.
double videoGetFps(int32_t playerHandle) { 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) { int64_t videoGetFrame(int32_t playerHandle) {
return _getPlayer(playerHandle, "videoGetFrame")->frame; 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. // 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) { 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"); 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) { void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetVolume"); 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 // 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 // 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. // 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 // 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 // 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, // 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) { void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetLuma"); 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 -- 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 -- 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") check("f:lines leaves it open", io.type(g), "file")
g:close() 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. -- Numbers, and the default input.
local n = io.open("packedIo/numbers.txt") local n = io.open("packedIo/numbers.txt")

View file

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

View file

@ -77,7 +77,7 @@ local function step()
if after == before then if after == before then
debugPrint("RULES FAIL the new rule did not reach the compiled game") 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") debugPrint("RULES the compiled game carries the new rule")
else else
debugPrint("RULES FAIL the compiled game is missing part of the new rule") debugPrint("RULES FAIL the compiled game is missing part of the new rule")
@ -89,7 +89,7 @@ local function step()
forgePartDelete() forgePartDelete()
local after = compiled() 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") debugPrint("RULES FAIL the deleted condition is still compiled")
else else
debugPrint("RULES the deleted condition is gone from the compiled game") 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