Compare commits

..

No commits in common. "master" and "ffms-video-player" have entirely different histories.

32386 changed files with 50619 additions and 10187496 deletions

90
.gitattributes vendored
View file

@ -1,90 +0,0 @@
#
# Image
#
*.xcf filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.bmp filter=lfs diff=lfs merge=lfs -text
*.avif filter=lfs diff=lfs merge=lfs -text
*.tiff filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
*.jxl filter=lfs diff=lfs merge=lfs -text
*.pcx filter=lfs diff=lfs merge=lfs -text
*.pnm filter=lfs diff=lfs merge=lfs -text
*.pgm filter=lfs diff=lfs merge=lfs -text
*.qoi filter=lfs diff=lfs merge=lfs -text
*.tga filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.tif filter=lfs diff=lfs merge=lfs -text
*.jbg filter=lfs diff=lfs merge=lfs -text
#
# Other Binary
#
*.exe filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.dat filter=lfs diff=lfs merge=lfs -text
#
# Singe game and menu descriptors are Lua text, not binary.
#
games.dat !filter !diff !merge text
menu.dat !filter !diff !merge text
#
# Fonts
#
*.ttf filter=lfs diff=lfs merge=lfs -text
#
# Audio
#
*.wav filter=lfs diff=lfs merge=lfs -text
*.flac filter=lfs diff=lfs merge=lfs -text
*.vgz filter=lfs diff=lfs merge=lfs -text
*.nsf filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.mod filter=lfs diff=lfs merge=lfs -text
*.it filter=lfs diff=lfs merge=lfs -text
*.itz filter=lfs diff=lfs merge=lfs -text
*.MOD filter=lfs diff=lfs merge=lfs -text
*.med filter=lfs diff=lfs merge=lfs -text
*.xm filter=lfs diff=lfs merge=lfs -text
*.oxm filter=lfs diff=lfs merge=lfs -text
*.raw filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.opus filter=lfs diff=lfs merge=lfs -text
#
# Document
#
*.odt filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
*.docx filter=lfs diff=lfs merge=lfs -text
#
# Video
#
*.mkv filter=lfs diff=lfs merge=lfs -text
*.avi filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mpg filter=lfs diff=lfs merge=lfs -text
#
# Archive
#
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.xz filter=lfs diff=lfs merge=lfs -text
*.lz filter=lfs diff=lfs merge=lfs -text
*.lzma filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
#
# Model
#
*.glb filter=lfs diff=lfs merge=lfs -text
*.stl filter=lfs diff=lfs merge=lfs -text

28
.gitignore vendored
View file

@ -1,24 +1,8 @@
*~
**/.builddir/
**/.git-hidden/
thirdparty/**/.github/
cmake-build-debug/
*.log
*.user
temp/
*#
build/
CMakeCache.txt
CMakeFiles/
*.a
*.o
docs/.asciidoctor/
.claude/settings.local.json
**/__pycache__/
# Extracted by the binary when it is run from here; never source.
/Singe/
/Menu.sh
/Menu.bat
/data/
screenshots/
*.ISO
*.iso
*.m4v
*.index
*.avi
*.vob

2064
CHANGELOG

File diff suppressed because it is too large Load diff

View file

@ -1,982 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
cmake_minimum_required(VERSION 3.22)
# The version, in CMake's four numbers, because that is all CMake's project() will take:
#
# major . minor . patch . tweak
# ^^^^^ ^^^^^ ^^^^^
# kept as written (00 stays 00)
# 0 = a release, 1 = a beta
# which beta
#
# so 3.00.0.0 is "v3.00" and 3.00.1.2 is "v3.00b2". One line to edit, no second variable, and
# the parts stay numbers, which is what the Windows resource needs anyway.
#
# A beta of 3.00 comes BEFORE 3.00, and the master service knows it: it reads the same shape out
# of a version string and ranks a beta below the release of the same number, so a cabinet on
# v3.00b2 is offered v3.00 when it arrives.
project(singe2 VERSION 3.00.1.1 LANGUAGES C CXX)
if(PROJECT_VERSION_PATCH EQUAL 1)
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}b${PROJECT_VERSION_TWEAK}")
else()
set(SINGE_VERSION_FULL "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
endif()
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The copyright end year tracks the build date so nothing has to be hand edited.
string(TIMESTAMP SINGE_COPYRIGHT_END_YEAR "%Y")
if(NOT DEFINED KANGAROO_OS)
set(KANGAROO_OS linux)
endif()
if(NOT DEFINED KANGAROO_ARCH)
set(KANGAROO_ARCH x86_64)
endif()
# Where the superbuild installs the third party libraries for this platform. SINGE_TREE lets a
# second toolchain for the same platform keep its own tree (the zig Linux build beside the gcc one).
if(NOT DEFINED SINGE_TREE)
set(SINGE_TREE ${KANGAROO_OS})
endif()
set(BUILD_DIR ${CMAKE_SOURCE_DIR}/.builddir/${SINGE_TREE}/${KANGAROO_ARCH})
set(GENERATED_DIR ${CMAKE_BINARY_DIR}/generated)
file(MAKE_DIRECTORY ${GENERATED_DIR})
# With SINGE_SUPERBUILD on (the presets' default) this configure builds the vendored libraries
# first and then Singe as a nested project; see cmake/Superbuild.cmake.
option(SINGE_SUPERBUILD "Build the vendored libraries, then Singe" OFF)
if(SINGE_SUPERBUILD)
include(cmake/Superbuild.cmake)
return()
endif()
# ===== Host tools needed to generate embedded resources =====
find_program(IMAGEMAGICK NAMES magick convert)
if(NOT IMAGEMAGICK)
message(FATAL_ERROR "ImageMagick (magick or convert) is required to render the embedded images.")
endif()
find_program(FFMPEG_TOOL NAMES ffmpeg)
if(NOT FFMPEG_TOOL)
message(FATAL_ERROR "ffmpeg is required to build the menu background video.")
endif()
find_program(LUA_TOOL NAMES lua5.4 lua)
if(NOT LUA_TOOL)
message(FATAL_ERROR "A Lua interpreter is required to generate the LuaSec option table.")
endif()
find_program(ASCIIDOCTOR_PDF NAMES asciidoctor-pdf)
if(NOT ASCIIDOCTOR_PDF)
message(FATAL_ERROR "asciidoctor-pdf is required to build the embedded manual (gem install asciidoctor-pdf rouge).")
endif()
find_program(ASCIIDOCTOR NAMES asciidoctor)
# ===== Generated sources =====
# Version header and Windows resources come from the project() line above.
configure_file(src/version.h.in ${GENERATED_DIR}/version.h @ONLY)
configure_file(src/singe.rc.in ${GENERATED_DIR}/singe.rc @ONLY)
# LuaSec needs its headers reachable as <luasocket/...>.
file(GLOB LUASOCKET_HEADERS thirdparty/luasocket/src/*.h)
file(COPY ${LUASOCKET_HEADERS} DESTINATION ${GENERATED_DIR}/include/luasocket)
# LuaSec option table generated from the OpenSSL headers that were built.
add_custom_command(
OUTPUT ${GENERATED_DIR}/luasec_options.c
COMMAND ${CMAKE_COMMAND} -DOUTPUT=${GENERATED_DIR}/luasec_options.c -DCOMMAND=${LUA_TOOL} "-DARGS=${CMAKE_SOURCE_DIR}/thirdparty/luasec/src/options.lua;-g;${BUILD_DIR}/include/openssl/ssl.h" -P ${CMAKE_SOURCE_DIR}/cmake/runToFile.cmake
DEPENDS thirdparty/luasec/src/options.lua ${BUILD_DIR}/include/openssl/ssl.h cmake/runToFile.cmake
COMMENT "Generating LuaSec options"
VERBATIM
)
# Embeds a file as a C array. The symbol name is derived from the file name, as xxd does.
set(EMBEDDED_HEADERS)
function(singeEmbed input output prefix)
get_filename_component(name ${input} NAME)
string(MAKE_C_IDENTIFIER "${prefix}${name}" symbol)
get_filename_component(guardName ${output} NAME)
string(MAKE_C_IDENTIFIER "${guardName}" guard)
string(TOUPPER "${guard}" guard)
add_custom_command(
OUTPUT ${output}
COMMAND ${CMAKE_COMMAND} -DINPUT=${input} -DOUTPUT=${output} -DGUARD=${guard} -DSYMBOL=${symbol} -DCOPYRIGHT_END_YEAR=${SINGE_COPYRIGHT_END_YEAR} -P ${CMAKE_SOURCE_DIR}/cmake/embed.cmake
DEPENDS ${input} cmake/embed.cmake
COMMENT "Embedding ${name}"
VERBATIM
)
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} ${output} PARENT_SCOPE)
endfunction()
# The canvas size of a GIMP image, from the XCF header (ImageMagick only sees the layers, and its
# flatten crops to the first one).
function(singeXcfCanvas xcf widthVar heightVar)
file(READ ${xcf} header LIMIT 22 HEX)
string(SUBSTRING ${header} 28 8 widthHex)
string(SUBSTRING ${header} 36 8 heightHex)
math(EXPR width "0x${widthHex}")
math(EXPR height "0x${heightHex}")
set(${widthVar} ${width} PARENT_SCOPE)
set(${heightVar} ${height} PARENT_SCOPE)
endfunction()
# Renders assets/<source>.xcf as <name>.png the way GIMP would: every layer at its offset onto the
# image's own canvas, anything outside it clipped.
function(singeRenderImage name source)
singeXcfCanvas(${CMAKE_SOURCE_DIR}/assets/${source}.xcf width height)
add_custom_command(
OUTPUT ${GENERATED_DIR}/${name}.png
COMMAND ${IMAGEMAGICK} -background "rgba(0,0,0,0)" -size ${width}x${height} xc:none ${CMAKE_SOURCE_DIR}/assets/${source}.xcf -flatten ${GENERATED_DIR}/${name}.png
DEPENDS assets/${source}.xcf
COMMENT "Rendering ${name}.png (${width}x${height})"
VERBATIM
)
endfunction()
# Renders a layered image and embeds the PNG.
function(singeEmbedImage name)
singeRenderImage(${name} ${name})
singeEmbed(${GENERATED_DIR}/${name}.png ${GENERATED_DIR}/${name}.h "")
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} PARENT_SCOPE)
endfunction()
# Embeds a Lua module from the third party tree.
function(singeEmbedLua path prefix)
get_filename_component(name ${path} NAME_WE)
singeEmbed(${CMAKE_SOURCE_DIR}/${path} ${GENERATED_DIR}/${prefix}${name}_lua.h "${prefix}")
set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} PARENT_SCOPE)
endfunction()
# assets/ holds only what is embedded in Singe. Sources those files are made from (the traced
# artwork, OBJ and STL models, the sound samples) and anything else not embedded live in media/.
# Images
singeEmbedImage(font)
# The icon is drawn large; the window gets a 128 pixel copy and the Windows .ico the usual sizes,
# both cut from a square, transparent-padded render so a non-square source keeps its proportions.
set(ICON_SIZE 1024)
singeRenderImage(iconFull icon)
add_custom_command(
OUTPUT ${GENERATED_DIR}/iconSquare.png
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconFull.png -background "rgba(0,0,0,0)" -resize "${ICON_SIZE}x${ICON_SIZE}>" -gravity center -extent ${ICON_SIZE}x${ICON_SIZE} ${GENERATED_DIR}/iconSquare.png
DEPENDS ${GENERATED_DIR}/iconFull.png
COMMENT "Rendering iconSquare.png"
VERBATIM
)
add_custom_command(
OUTPUT ${GENERATED_DIR}/icon.png
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconSquare.png -resize 128x128 ${GENERATED_DIR}/icon.png
DEPENDS ${GENERATED_DIR}/iconSquare.png
COMMENT "Rendering icon.png"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/icon.png ${GENERATED_DIR}/icon.h "")
singeEmbedImage(kangarooPunchLogo)
# The picture shown where a game's art should be. Drawn here rather than kept as a file: it is
# ours, it needs no license, and it cannot drift from what the code expects. Loud on purpose --
# a blank space reads as a layout mistake, this reads as "your path is wrong".
add_custom_command(
OUTPUT ${GENERATED_DIR}/missing.png
COMMAND ${IMAGEMAGICK} -size 256x256 pattern:checkerboard -auto-level -fill "#c0208090" -colorize 60
-fill "#ffd84a" -pointsize 34 -gravity center -annotate +0-14 "MISSING"
-fill "#ffffffcc" -pointsize 18 -annotate +0+22 "art"
-bordercolor "#ffd84a" -border 4 ${GENERATED_DIR}/missing.png
COMMENT "Drawing missing.png"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/missing.png ${GENERATED_DIR}/missing_png.h "")
singeEmbedImage(singeLogo)
# Windows icon for the resource file.
add_custom_command(
OUTPUT ${GENERATED_DIR}/icon.ico
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconSquare.png -define icon:auto-resize=256,64,48,32,16 ${GENERATED_DIR}/icon.ico
DEPENDS ${GENERATED_DIR}/iconSquare.png
COMMENT "Rendering icon.ico"
VERBATIM
)
# Support files extracted at first run.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Debugger.singe ${GENERATED_DIR}/Debugger_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_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/Backdrop.singe ${GENERATED_DIR}/Backdrop_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rml ${GENERATED_DIR}/scoreBezel_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rcss ${GENERATED_DIR}/scoreBezel_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rml ${GENERATED_DIR}/subtitle_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "")
# The two models the menu's intro is made of. Singe's own branding, so they belong with the engine
# the way the font and the logos do -- the menu cannot reach into a game directory for them.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/DragonModel.glb ${GENERATED_DIR}/DragonModel_glb.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/SingeText.glb ${GENERATED_DIR}/SingeText_glb.h "")
# Menu background video: a recording of the backdrop the menu draws, for the machines that cannot
# draw it. It is not built here -- rendering it needs a GPU device and the engine itself, which is
# the wrong way round for a build -- so it is an asset like the models are, made by
# util/renderMenuVideo.py and re-made whenever assets/Backdrop.singe changes.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuBackground.mkv ${GENERATED_DIR}/menuBackground_mkv.h "")
# The same sound the video carries, for the renderer that draws the backdrop instead of playing it.
# util/renderMenuVideo.py writes the two together.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menuIntro.flac ${GENERATED_DIR}/menuIntro_flac.h "")
# Calibration click for the menu's audio delay screen: 20 ms of white noise with a fast fade.
add_custom_command(
OUTPUT ${GENERATED_DIR}/click.wav
COMMAND ${FFMPEG_TOOL} -y -loglevel error -f lavfi -i "anoisesrc=d=0.02:c=white:r=44100:a=0.6" -af "afade=t=out:st=0:d=0.02:curve=exp" -ac 1 -c:a pcm_s16le ${GENERATED_DIR}/click.wav
COMMENT "Building click.wav"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/click.wav ${GENERATED_DIR}/click_wav.h "")
# Manual, rendered from the AsciiDoc source into the top-level build directory beside the binaries
# (build-docs.sh writes the same files) and shipped inside the binary. compress deflates the PDF's
# streams, which halves it. Its translations, one folder each under docs/, are rendered into a
# folder of their language there and shipped as Manual-<language>.pdf; embedded.h and the table in
# main.c list the same languages.
set(MANUAL_DIR ${CMAKE_SOURCE_DIR}/.builddir)
set(SINGE_DOC_LANGUAGES fr it de es)
add_custom_command(
OUTPUT ${MANUAL_DIR}/Manual.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a compress -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.pdf
DEPENDS docs/Manual.adoc
COMMENT "Rendering Manual.pdf"
VERBATIM
)
singeEmbed(${MANUAL_DIR}/Manual.pdf ${GENERATED_DIR}/Manual_pdf.h "")
foreach(language ${SINGE_DOC_LANGUAGES})
add_custom_command(
OUTPUT ${MANUAL_DIR}/${language}/Manual.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}/${language}
COMMAND ${ASCIIDOCTOR_PDF} -a compress -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/${language}/Manual.adoc -o ${MANUAL_DIR}/${language}/Manual.pdf
DEPENDS docs/${language}/Manual.adoc docs/${language}/locale.adoc
COMMENT "Rendering ${language}/Manual.pdf"
VERBATIM
)
singeEmbed(${MANUAL_DIR}/${language}/Manual.pdf ${GENERATED_DIR}/${language}_Manual_pdf.h "${language}_")
endforeach()
# The ZeroBrane Studio package and its API, one per documentation language (util/zbstudioApi.py).
singeEmbed(${CMAKE_SOURCE_DIR}/zbstudio/packages/singe.lua ${GENERATED_DIR}/zbstudio_singe_lua.h "zbstudio_")
singeEmbed(${CMAKE_SOURCE_DIR}/zbstudio/packages/singe/api.lua ${GENERATED_DIR}/zbstudio_api_lua.h "zbstudio_")
foreach(language ${SINGE_DOC_LANGUAGES})
singeEmbed(${CMAKE_SOURCE_DIR}/zbstudio/packages/singe/api-${language}.lua ${GENERATED_DIR}/zbstudio_api_${language}_lua.h "zbstudio_")
endforeach()
# Its toolbar button is the dragon, at the sizes ZeroBrane Studio's toolbar comes in, cut from the
# same square render as the window's icon.
foreach(size 16 24 32 48)
add_custom_command(
OUTPUT ${GENERATED_DIR}/icon-${size}.png
COMMAND ${IMAGEMAGICK} ${GENERATED_DIR}/iconSquare.png -resize ${size}x${size} ${GENERATED_DIR}/icon-${size}.png
DEPENDS ${GENERATED_DIR}/iconSquare.png
COMMENT "Rendering icon-${size}.png"
VERBATIM
)
singeEmbed(${GENERATED_DIR}/icon-${size}.png ${GENERATED_DIR}/zbstudio_icon_${size}_png.h "zbstudio_")
endforeach()
# The license Singe is under and the notices its third-party libraries and assets require. Both
# have to reach whoever gets the binary, so they travel inside it like the manual does.
singeEmbed(${CMAKE_SOURCE_DIR}/COPYING ${GENERATED_DIR}/COPYING.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/LICENSES ${GENERATED_DIR}/LICENSES.h "")
# Lua libraries
singeEmbedLua(thirdparty/luasocket/src/ftp.lua "")
singeEmbedLua(thirdparty/luasocket/src/headers.lua "")
singeEmbedLua(thirdparty/luasocket/src/http.lua "")
singeEmbedLua(thirdparty/luasocket/src/ltn12.lua "")
singeEmbedLua(thirdparty/luasocket/src/mbox.lua "")
singeEmbedLua(thirdparty/luasocket/src/mime.lua "")
singeEmbedLua(thirdparty/luasocket/src/smtp.lua "")
singeEmbedLua(thirdparty/luasocket/src/socket.lua "")
singeEmbedLua(thirdparty/luasocket/src/tp.lua "")
singeEmbedLua(thirdparty/luasocket/src/url.lua "")
singeEmbedLua(thirdparty/luasec/src/https.lua "")
singeEmbedLua(thirdparty/luasec/src/ssl.lua "")
singeEmbedLua(thirdparty/librs232/bindings/lua/rs232.lua "")
singeEmbedLua(thirdparty/copas/src/copas.lua "")
singeEmbedLua(thirdparty/copas/src/copas/ftp.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/future.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/http.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/smtp.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/lock.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/queue.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/semaphore.lua "copas_")
singeEmbedLua(thirdparty/copas/src/copas/timer.lua "copas_")
singeEmbedLua(thirdparty/binaryheap.lua/src/binaryheap.lua "")
singeEmbedLua(thirdparty/timerwheel.lua/src/timerwheel/timerwheel.lua "")
singeEmbedLua(thirdparty/json.lua/json.lua "")
singeEmbedLua(thirdparty/middleclass/middleclass.lua "")
singeEmbedLua(thirdparty/lume/lume.lua "")
singeEmbedLua(thirdparty/inspect.lua/inspect.lua "")
singeEmbedLua(thirdparty/bump.lua/bump.lua "")
singeEmbedLua(thirdparty/MobDebug/src/mobdebug.lua "")
# Optional HTML for browsing: cmake --build . --target docs. The user manual is the one book the
# binary carries; the reference and the beginner's course are the optional
# documentation download that build-docs.sh packs. Each translation goes in its language's folder.
if(ASCIIDOCTOR)
set(DOCS_COMMANDS
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Manual.adoc -o ${MANUAL_DIR}/Manual.html
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Reference.adoc -o ${MANUAL_DIR}/Reference.html
)
set(DOCS_DEPENDS ${MANUAL_DIR}/Manual.pdf)
foreach(language ${SINGE_DOC_LANGUAGES})
list(APPEND DOCS_COMMANDS
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/${language}/Manual.adoc -o ${MANUAL_DIR}/${language}/Manual.html
COMMAND ${ASCIIDOCTOR} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/${language}/Reference.adoc -o ${MANUAL_DIR}/${language}/Reference.html
)
list(APPEND DOCS_DEPENDS ${MANUAL_DIR}/${language}/Manual.pdf)
endforeach()
add_custom_target(docs
${DOCS_COMMANDS}
DEPENDS ${DOCS_DEPENDS}
COMMENT "Rendering Manual.html and Reference.html in every language"
VERBATIM
)
endif()
# ===== Sources =====
set(SINGE_SOURCE
src/collide.c
src/collide.h
src/common.h
src/debugger.c
src/debugger.h
src/decode.c
src/decode.h
src/embedded.h
src/frameFile.c
src/rotoZoom.c
src/rotoZoom.h
src/frameFile.h
src/main.c
src/main.h
src/pack.c
src/pack.h
src/particles.c
src/particles.h
src/persist.c
src/persist.h
src/scheduler.c
src/scheduler.h
src/stats.c
src/stats.h
src/physics.h
src/navRecast.cpp
src/physicsJolt.cpp
src/gui.cpp
src/gui.h
src/guiRender.cpp
src/guiRender.h
src/hdr.c
src/ktx2.h
src/ktx2Basis.cpp
thirdparty/basis_universal/transcoder/basisu_transcoder.cpp
src/model.c
src/model.h
src/math3d.c
src/math3d.h
src/midi.c
src/midi.h
src/midiIo.c
src/midiIo.h
src/mic.c
src/mic.h
src/voice.c
src/voice.h
src/netTrust.c
src/netTrust.h
src/record.c
src/record.h
thirdparty/tinysoundfont/tsf.h
thirdparty/tinysoundfont/tml.h
src/render.c
src/render.h
src/renderGles.c
src/renderGlesApi.h
src/renderGlesLoad.c
src/renderGpu.c
src/scene.c
src/scene.h
src/scriptWatch.c
src/scriptWatch.h
src/singe.c
src/singe.h
src/stddclmr.h
src/util.c
src/util.h
src/videoPlayer.c
src/videoPlayer.h
src/vfs.c
src/vfs.h
)
# The binding reports this from sqlite3.lversion(); it is the version of thirdparty/lsqlite3/lsqlite3.c.
set(LSQLITE_VERSION "0.9.7")
set(SQLITE_SOURCE
thirdparty/sqlite/sqlite3.c
thirdparty/sqlite/sqlite3.h
thirdparty/lsqlite3/lsqlite3.c
)
set_source_files_properties(thirdparty/lsqlite3/lsqlite3.c PROPERTIES COMPILE_DEFINITIONS "LSQLITE_VERSION=\"${LSQLITE_VERSION}\"")
# SpeexDSP 1.2.1 (thirdparty/speexdsp): echo cancellation, noise suppression, gain, voice activity,
# and a jitter buffer, for voice.c. It has no CMake build, so its library sources are compiled here,
# floating point, with its own small FFT. speexdsp_config_types.h is written by hand (see its top).
set(SPEEXDSP_SOURCE
thirdparty/speexdsp/libspeexdsp/buffer.c
thirdparty/speexdsp/libspeexdsp/fftwrap.c
thirdparty/speexdsp/libspeexdsp/filterbank.c
thirdparty/speexdsp/libspeexdsp/jitter.c
thirdparty/speexdsp/libspeexdsp/mdf.c
thirdparty/speexdsp/libspeexdsp/preprocess.c
thirdparty/speexdsp/libspeexdsp/resample.c
thirdparty/speexdsp/libspeexdsp/scal.c
thirdparty/speexdsp/libspeexdsp/smallft.c
)
set_source_files_properties(${SPEEXDSP_SOURCE} PROPERTIES COMPILE_DEFINITIONS "FLOATING_POINT;USE_SMALLFT;EXPORT=" COMPILE_OPTIONS "-w")
set(ARG_PARSER_SOURCE
thirdparty/arg_parser/carg_parser.c
thirdparty/arg_parser/carg_parser.h
)
file(GLOB LUA_SOURCE thirdparty/lua/src/*.c thirdparty/lua/src/*.h)
list(FILTER LUA_SOURCE EXCLUDE REGEX "/(lua|luac|onelua)\\.c$")
set(LUA_FILESYSTEM_SOURCE
thirdparty/luafilesystem/src/lfs.c
thirdparty/luafilesystem/src/lfs.h
)
# The PUC-Rio md5 library, as Hypseus bundles it (its sumhexa is named hexsum), so the games that
# require("md5") get the same five functions with the same behavior.
set(LUA_MD5_SOURCE
thirdparty/md5/md5.c
thirdparty/md5/md5.h
thirdparty/md5/md5lib.c
)
# The rest of the Lua modules Hypseus exposes, so a game written against it finds them here too:
# LuaBitOp, lrandom, Lua CJSON and LPeg.
set(LUA_BITOP_SOURCE
thirdparty/luabitop/lbit.c
)
set(LUA_RANDOM_SOURCE
thirdparty/lrandom/lrandom.c
)
set(LUA_CJSON_SOURCE
thirdparty/lua-cjson/fpconv.c
thirdparty/lua-cjson/fpconv.h
thirdparty/lua-cjson/lua_cjson.c
thirdparty/lua-cjson/strbuf.c
thirdparty/lua-cjson/strbuf.h
)
set(LPEG_SOURCE
thirdparty/lpeg/lpcap.c
thirdparty/lpeg/lpcap.h
thirdparty/lpeg/lpcode.c
thirdparty/lpeg/lpcode.h
thirdparty/lpeg/lpcset.c
thirdparty/lpeg/lpcset.h
thirdparty/lpeg/lpprint.c
thirdparty/lpeg/lpprint.h
thirdparty/lpeg/lptree.c
thirdparty/lpeg/lptree.h
thirdparty/lpeg/lptypes.h
thirdparty/lpeg/lpvm.c
thirdparty/lpeg/lpvm.h
)
set(LUA_SOCKET_SOURCE
thirdparty/luasocket/src/auxiliar.c
thirdparty/luasocket/src/auxiliar.h
thirdparty/luasocket/src/buffer.c
thirdparty/luasocket/src/buffer.h
thirdparty/luasocket/src/compat.c
thirdparty/luasocket/src/compat.h
thirdparty/luasocket/src/except.c
thirdparty/luasocket/src/except.h
thirdparty/luasocket/src/inet.c
thirdparty/luasocket/src/inet.h
thirdparty/luasocket/src/io.c
thirdparty/luasocket/src/io.h
thirdparty/luasocket/src/luasocket.c
thirdparty/luasocket/src/luasocket.h
thirdparty/luasocket/src/mime.c
thirdparty/luasocket/src/mime.h
thirdparty/luasocket/src/options.c
thirdparty/luasocket/src/options.h
thirdparty/luasocket/src/select.c
thirdparty/luasocket/src/select.h
thirdparty/luasocket/src/socket.h
thirdparty/luasocket/src/tcp.c
thirdparty/luasocket/src/tcp.h
thirdparty/luasocket/src/timeout.c
thirdparty/luasocket/src/timeout.h
thirdparty/luasocket/src/udp.c
thirdparty/luasocket/src/udp.h
)
if(WIN32)
list(APPEND LUA_SOCKET_SOURCE
thirdparty/luasocket/src/wsocket.c
thirdparty/luasocket/src/wsocket.h
)
else()
list(APPEND LUA_SOCKET_SOURCE
thirdparty/luasocket/src/serial.c
thirdparty/luasocket/src/unix.c
thirdparty/luasocket/src/unix.h
thirdparty/luasocket/src/unixdgram.c
thirdparty/luasocket/src/unixdgram.h
thirdparty/luasocket/src/unixstream.c
thirdparty/luasocket/src/unixstream.h
thirdparty/luasocket/src/usocket.c
thirdparty/luasocket/src/usocket.h
)
endif()
set(LUASEC_SOURCE
${GENERATED_DIR}/luasec_options.c
thirdparty/luasec/src/compat.h
thirdparty/luasec/src/config.c
thirdparty/luasec/src/context.c
thirdparty/luasec/src/context.h
thirdparty/luasec/src/ec.c
thirdparty/luasec/src/ec.h
thirdparty/luasec/src/options.h
thirdparty/luasec/src/ssl.c
thirdparty/luasec/src/ssl.h
thirdparty/luasec/src/x509.c
thirdparty/luasec/src/x509.h
)
set(LUA_RS232_SOURCE
thirdparty/librs232/bindings/lua/luars232.c
thirdparty/librs232/src/rs232.c
)
if(WIN32)
list(APPEND LUA_RS232_SOURCE thirdparty/librs232/src/rs232_windows.c)
else()
list(APPEND LUA_RS232_SOURCE thirdparty/librs232/src/rs232_posix.c)
endif()
set(UTHASH_SOURCE
thirdparty/uthash/src/uthash.h
thirdparty/uthash/src/utlist.h
)
set(MANYMOUSE_SOURCE
thirdparty/manymouse/linux_evdev.c
thirdparty/manymouse/macosx_hidmanager.c
thirdparty/manymouse/macosx_hidutilities.c
thirdparty/manymouse/manymouse.c
thirdparty/manymouse/manymouse.h
thirdparty/manymouse/windows_wminput.c
thirdparty/manymouse/x11_xinput2.c
)
# ===== Target =====
add_executable(${CMAKE_PROJECT_NAME}
${SINGE_SOURCE}
${EMBEDDED_HEADERS}
${GENERATED_DIR}/version.h
${ARG_PARSER_SOURCE}
${LUA_SOURCE}
${LUA_FILESYSTEM_SOURCE}
${LUA_MD5_SOURCE}
${LUA_BITOP_SOURCE}
${LUA_RANDOM_SOURCE}
${LUA_CJSON_SOURCE}
${LPEG_SOURCE}
${SQLITE_SOURCE}
${LUA_SOCKET_SOURCE}
${LUASEC_SOURCE}
${LUA_RS232_SOURCE}
${UTHASH_SOURCE}
${MANYMOUSE_SOURCE}
${SPEEXDSP_SOURCE}
)
if(WIN32)
if(SINGE_ZIG_TARGET)
# CMake archives every object into objects.a for MinGW links, and zig's linker cannot take a
# compiled resource from inside an archive; compiled on its own it links as a plain input.
add_custom_command(
OUTPUT ${GENERATED_DIR}/singe.res
COMMAND ${CMAKE_RC_COMPILER} /fo ${GENERATED_DIR}/singe.res ${GENERATED_DIR}/singe.rc
DEPENDS ${GENERATED_DIR}/singe.rc ${GENERATED_DIR}/icon.ico
COMMENT "Compiling resources"
VERBATIM
)
add_custom_target(singeResources DEPENDS ${GENERATED_DIR}/singe.res)
add_dependencies(${CMAKE_PROJECT_NAME} singeResources)
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE ${GENERATED_DIR}/singe.res)
else()
enable_language(RC)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${GENERATED_DIR}/singe.rc ${GENERATED_DIR}/icon.ico)
endif()
endif()
# Output name matches the release artifact: Singe-v3.00-Linux-x86_64
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
# Warnings apply to our code only; the vendored libraries are not ours to fix.
set_source_files_properties(${SINGE_SOURCE} PROPERTIES COMPILE_OPTIONS "-Wall;-Wextra")
# librs232 stamps its build date into the module; clang under zig makes that an error by default.
set_source_files_properties(thirdparty/librs232/bindings/lua/luars232.c PROPERTIES COMPILE_OPTIONS "-Wno-date-time")
# The Basis transcoder is warning-heavy third-party C++; it is compiled quietly.
set_source_files_properties(thirdparty/basis_universal/transcoder/basisu_transcoder.cpp PROPERTIES COMPILE_OPTIONS "-w")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
# The platform part of the release name (Linux-x86_64), so a script can ask which release it
# is and fetch the right one.
SINGE_PLATFORM="${osInitial}${osRest}-${KANGAROO_ARCH}"
RS232_STATIC
BASISD_SUPPORT_KTX2=1
BASISD_SUPPORT_KTX2_ZSTD=1
BASISD_SUPPORT_PVRTC1=0
BASISD_SUPPORT_PVRTC2=0
BASISD_SUPPORT_ATC=0
BASISD_SUPPORT_FXT1=0
BASISD_SUPPORT_ETC2_EAC_A8=0
BASISD_SUPPORT_ETC2_EAC_RG11=0
SQLITE_DQS=0
SQLITE_THREADSAFE=1
SQLITE_DEFAULT_MEMSTATUS=0
SQLITE_OMIT_SHARED_CACHE
)
if(WIN32)
# _WIN32_WINNT=0x0600 sets the minimum compatible version of Windows to Vista.
# The function inet_pton() does not exist before then.
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
_WIN32_WINNT=0x0600
)
endif()
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE
-Wno-deprecated-declarations
)
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
${CMAKE_BINARY_DIR}
${GENERATED_DIR}/include
${BUILD_DIR}
${BUILD_DIR}/include
thirdparty/lua/src
thirdparty/sqlite
thirdparty/luasec/src
thirdparty/librs232/include
thirdparty/basis_universal/transcoder
# Opus is the one SDL3_mixer builds for Ogg Opus music (libopus.a in STATIC_LIBS), so the voice
# codec (voice.c) and the music decoder are one copy of one version.
thirdparty/SDL3_mixer/external/opus/include
thirdparty/speexdsp/include
)
target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE
${BUILD_DIR}/lib
)
# ===== Libraries =====
# Platform specific system libraries.
if(KANGAROO_OS STREQUAL "linux")
if(KANGAROO_ARCH MATCHES "^(aarch64|armhf)$")
set(SYSTEM_LIBS -ldl)
# An ARM board decodes video through its V4L2 memory-to-memory device (videoPlayer.c).
# Nothing here is particular to a Raspberry Pi: any board whose kernel offers one is served.
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_DECODE)
# The stateless decoders are a separate interface again, and FFmpeg builds them only when its
# own dependencies were met, so the engine follows what FFmpeg actually did.
if(EXISTS ${BUILD_DIR}/build/ffmpeg/config.h)
file(READ ${BUILD_DIR}/build/ffmpeg/config.h ffmpegConfig)
if(ffmpegConfig MATCHES "#define CONFIG_V4L2_REQUEST 1")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_REQUEST_DECODE)
message(STATUS "FFmpeg carries the V4L2 stateless decoders; enabling them in the player")
endif()
endif()
else()
# ffmpeg's vdpau hardware context is always compiled in on X11 hosts.
set(SYSTEM_LIBS -lX11 -lvdpau -ldl -rdynamic) # -rdynamic gives the crash backtrace function names
endif()
# Release binaries are stripped; the crash backtrace reads the dynamic symbols -rdynamic exports,
# which stripping keeps.
if(CMAKE_BUILD_TYPE STREQUAL "Release")
list(APPEND SYSTEM_LIBS -s)
endif()
elseif(KANGAROO_OS STREQUAL "macos")
set(SYSTEM_LIBS
-Wl,-framework,CoreVideo
-Wl,-framework,Cocoa
-Wl,-framework,IOKit
-Wl,-framework,ForceFeedback
-Wl,-framework,Carbon
-Wl,-framework,CoreAudio
-Wl,-framework,AudioToolbox
-Wl,-framework,AVFoundation
-Wl,-framework,Foundation
-Wl,-framework,Security
-Wl,-weak_framework,GameController
-Wl,-weak_framework,Metal
-Wl,-weak_framework,QuartzCore
-Wl,-weak_framework,CoreHaptics
-Wl,-weak_framework,CoreMIDI
-Wl,-weak_framework,UniformTypeIdentifiers
-liconv
-lc++
)
if(NOT SINGE_ZIG_TARGET)
# osxcross builds need clang's runtime library named; zig carries its own.
if(NOT DEFINED OSXCROSS_TARGET_DIR)
message(FATAL_ERROR "OSXCROSS_TARGET_DIR must be set for macOS builds without zig.")
endif()
list(APPEND SYSTEM_LIBS ${OSXCROSS_TARGET_DIR}/darwin/libclang_rt.osx.a)
endif()
elseif(KANGAROO_OS STREQUAL "windows")
# A GUI-subsystem executable, so no console window opens; zig's linker wants the flag spelled out.
if(SINGE_ZIG_TARGET)
set(windowsSubsystem -Wl,--subsystem,windows)
else()
set(windowsSubsystem -mwindows)
endif()
set(SYSTEM_LIBS
${windowsSubsystem}
-static
-lmingw32
-latomic
-lole32
-luser32
-lkernel32
-lgdi32
-lwinmm
-limm32
-loleaut32
-lversion
-luuid
-lrpcrt4
-ladvapi32
-lsetupapi
-lshell32
-ldinput8
-lws2_32
-lbcrypt
-lcrypt32
)
if(NOT SINGE_ZIG_TARGET)
# MinGW's stack protector runtime; zig's compiler-rt carries it itself.
list(APPEND SYSTEM_LIBS -lssp)
endif()
else()
message(FATAL_ERROR "Unknown KANGAROO_OS: ${KANGAROO_OS}")
endif()
# FFmpeg's hardware decoding backends (VA-API, VDPAU, DRM, V4L2, VideoToolbox) are whatever the
# superbuild configured; its pkg-config files name the system libraries and frameworks they need, so
# read them back.
if(NOT KANGAROO_OS STREQUAL "windows")
foreach(pc libavutil libavcodec)
set(pcFile ${BUILD_DIR}/lib/pkgconfig/${pc}.pc)
if(EXISTS ${pcFile})
file(STRINGS ${pcFile} pcLibs REGEX "^Libs:")
string(REGEX REPLACE "^Libs: *" "" pcLibs "${pcLibs}")
separate_arguments(pcLibs)
set(framework FALSE)
foreach(token ${pcLibs})
if(framework)
list(APPEND SYSTEM_LIBS -Wl,-framework,${token})
set(framework FALSE)
elseif(token STREQUAL "-framework")
set(framework TRUE)
elseif(token MATCHES "^-l" AND NOT token MATCHES "^-l(avutil|avcodec|z|m|atomic|pthread|iconv)$")
list(APPEND SYSTEM_LIBS ${token})
endif()
endforeach()
endif()
endforeach()
list(REMOVE_DUPLICATES SYSTEM_LIBS)
endif()
# Static third party libraries built by build-all.sh.
set(STATIC_LIBS
${BUILD_DIR}/lib/libavcodec.a
${BUILD_DIR}/lib/libavdevice.a
${BUILD_DIR}/lib/libavfilter.a
${BUILD_DIR}/lib/libavformat.a
${BUILD_DIR}/lib/libavutil.a
${BUILD_DIR}/lib/libfreetype.a
${BUILD_DIR}/lib/libopus.a
${BUILD_DIR}/lib/libopusfile.a
${BUILD_DIR}/lib/libogg.a
${BUILD_DIR}/lib/libSDL3.a
${BUILD_DIR}/lib/libSDL3_image.a
${BUILD_DIR}/lib/libSDL3_mixer.a
${BUILD_DIR}/lib/libSDL3_ttf.a
${BUILD_DIR}/lib/libswresample.a
${BUILD_DIR}/lib/libswscale.a
${BUILD_DIR}/lib/libwavpack.a
${BUILD_DIR}/lib/libwebp.a
${BUILD_DIR}/lib/libwebpdemux.a
${BUILD_DIR}/lib/libwebpmux.a
${BUILD_DIR}/lib/libsharpyuv.a
${BUILD_DIR}/lib/libxmp.a
${BUILD_DIR}/lib/libz.a
${BUILD_DIR}/lib/libzstd.a
${BUILD_DIR}/lib/libcrypto.a
${BUILD_DIR}/lib/libssl.a
)
# Not every archive exists in every tree: SDL3_image builds its own jpeg and png decoders where the
# platform has none (macOS uses ImageIO for jpeg), brotli arrives with FreeType's WOFF2 support, and
# libgme with the chiptune decoder. Link whichever ones are there.
foreach(optionalLib libjpeg.a libpng16.a libbrotlidec.a libbrotlicommon.a libgme.a libdav1d.a libjxl_dec.a libhwy.a)
if(EXISTS ${BUILD_DIR}/lib/${optionalLib})
list(APPEND STATIC_LIBS ${BUILD_DIR}/lib/${optionalLib})
endif()
endforeach()
# The scene shaders: src/shaders/scene.hlsl compiled at build time into a header of SPIR-V, DXIL
# and MSL blobs by SDL_shadercross, which the superbuild builds for the host (cmake/hostTools.cmake)
# and names in SINGE_SHADERCROSS; configuring this project alone finds one on the PATH.
if(NOT SINGE_SHADERCROSS)
find_program(SINGE_SHADERCROSS shadercross)
if(NOT SINGE_SHADERCROSS)
message(FATAL_ERROR "SDL_shadercross is needed to compile the scene shaders; build through the superbuild (cmake --preset ...) or set SINGE_SHADERCROSS.")
endif()
endif()
# SPIRV-Cross turns the same SPIR-V into GLSL ES for the GLES backend (PLAN.md section 56). The
# superbuild builds it beside shadercross and hands it over, so there it is fatal when missing: a
# warning let every release binary ship with null ES strings and no GLES backend, unnoticed.
# Configuring this project alone it is only a warning; the header then carries null ES strings,
# the GLES backend declines to start, and every other platform builds exactly as before.
if(SINGE_FROM_SUPERBUILD)
if((NOT SINGE_SPIRV_CROSS) OR (NOT EXISTS ${SINGE_SPIRV_CROSS}))
message(FATAL_ERROR "The superbuild did not supply spirv-cross (SINGE_SPIRV_CROSS='${SINGE_SPIRV_CROSS}'); without it there are no OpenGL ES shaders.")
endif()
else()
if(NOT SINGE_SPIRV_CROSS)
find_program(SINGE_SPIRV_CROSS spirv-cross)
endif()
if(NOT SINGE_SPIRV_CROSS)
message(WARNING "spirv-cross was not found; this build will have no OpenGL ES shaders and so no GLES backend.")
endif()
endif()
set(shaderHeader ${CMAKE_BINARY_DIR}/generated/shaders/sceneShaders.h)
add_custom_command(
OUTPUT ${shaderHeader}
COMMAND ${CMAKE_COMMAND} -DSHADERCROSS=${SINGE_SHADERCROSS} -DSPIRVCROSS=${SINGE_SPIRV_CROSS} -DSOURCE=${CMAKE_SOURCE_DIR}/src/shaders/scene.hlsl -DOUTPUT=${shaderHeader} -P ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/shaders/scene.hlsl ${CMAKE_SOURCE_DIR}/src/sceneShared.h ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
COMMENT "Compiling the scene shaders"
)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${shaderHeader})
# The GUI shaders the same way: src/shaders/gui.hlsl into guiShaders.h for guiRender.cpp. The
# entry list is a CMake list, so it is quoted as one argument and VERBATIM keeps the semicolons.
set(guiShaderHeader ${CMAKE_BINARY_DIR}/generated/shaders/guiShaders.h)
set(guiShaderEntries "guiVertex:vertex;guiFragmentColor:fragment;guiFragmentTexture:fragment;guiFragmentFilter:fragment;guiFragmentBlur:fragment;guiFragmentColorMatrix:fragment;guiFragmentGradient:fragment")
add_custom_command(
OUTPUT ${guiShaderHeader}
COMMAND ${CMAKE_COMMAND} -DSHADERCROSS=${SINGE_SHADERCROSS} -DSPIRVCROSS=${SINGE_SPIRV_CROSS} -DSOURCE=${CMAKE_SOURCE_DIR}/src/shaders/gui.hlsl -DOUTPUT=${guiShaderHeader} "-DENTRIES=${guiShaderEntries}" -DPREFIX=guiShader -DTYPE=GuiShaderT -P ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/shaders/gui.hlsl ${CMAKE_SOURCE_DIR}/cmake/shaderHeader.cmake
COMMENT "Compiling the GUI shaders"
VERBATIM
)
target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${guiShaderHeader})
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_BINARY_DIR}/generated)
# 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.
if(EXISTS ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc)
file(READ ${BUILD_DIR}/lib/pkgconfig/libavcodec.pc avcodecPc)
if(avcodecPc MATCHES "rockchip_mpp")
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_RKMPP_DECODE)
message(STATUS "FFmpeg carries the rkmpp decoders; enabling them in the player")
endif()
endif()
# Jolt Physics comes with the compile definitions it was built with (SIMD level, layer bits) through
# its exported target; the one C++ file in the tree (physicsJolt.cpp) needs them. Its C++ runtime is
# zig's own libc++, linked statically, or the host's libstdc++ under gcc; macOS already lists -lc++.
find_package(Jolt CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/Jolt NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
# Recast Navigation the same way (navRecast.cpp): Recast bakes, Detour queries, DetourCrowd steers.
find_package(recastnavigation CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/recastnavigation NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
# RmlUi (gui.cpp, and guiRender.cpp, the SDL_GPU render interface forked from RmlUi's Backends).
# Its package config looks for Freetype::Freetype and Lua::Lua only when they are not already
# targets, so both are declared here from the superbuild's FreeType and the engine's own Lua.
add_library(Freetype::Freetype STATIC IMPORTED)
set_target_properties(Freetype::Freetype PROPERTIES IMPORTED_LOCATION ${BUILD_DIR}/lib/libfreetype.a INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/thirdparty/SDL3_ttf/external/freetype/include)
add_library(Lua::Lua INTERFACE IMPORTED)
set_target_properties(Lua::Lua PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_SOURCE_DIR}/thirdparty/lua/src)
find_package(RmlUi CONFIG REQUIRED PATHS ${BUILD_DIR}/lib/cmake/RmlUi NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF)
# Jolt is built without RTTI, so the file that subclasses its debug renderer must be too, or the
# subclass's vtable wants type information the library never made.
set_source_files_properties(src/physicsJolt.cpp PROPERTIES COMPILE_OPTIONS "-fno-rtti")
if(NOT KANGAROO_OS STREQUAL "macos")
if(SINGE_ZIG_TARGET)
list(APPEND SYSTEM_LIBS -lc++)
else()
list(APPEND SYSTEM_LIBS -lstdc++)
endif()
endif()
# System libraries follow the static ones so their symbols resolve for everything above.
# Apple's linker has no --start-group, so the list is repeated there instead.
if(KANGAROO_OS STREQUAL "macos")
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE ${STATIC_LIBS} ${STATIC_LIBS} ${STATIC_LIBS} Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
else()
target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE -Wl,--start-group ${STATIC_LIBS} -Wl,--end-group Jolt::Jolt RecastNavigation::DetourCrowd RecastNavigation::Detour RecastNavigation::Recast RmlUi::Lua RmlUi::Core ${SYSTEM_LIBS} -pthread -lm)
endif()
install(TARGETS ${CMAKE_PROJECT_NAME})

View file

@ -1,135 +0,0 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Unix Makefiles",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"SINGE_SUPERBUILD": "ON"
}
},
{
"name": "linux-x86_64",
"displayName": "Linux x86_64 (zig, glibc 2.28, release)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-linux-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": "",
"SINGE_TREE": "linux"
}
},
{
"name": "linux-x86_64-gcc",
"displayName": "Linux x86_64 (host gcc, developer build)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux-gcc/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/linux-x86_64.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": "-gcc",
"SINGE_TREE": "linux-gcc"
}
},
{
"name": "windows-x86_64",
"displayName": "Windows x86_64 (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/windows/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-windows-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-w64-mingw32",
"SINGE_CROSS_OS": "mingw32",
"SINGE_SUFFIX": ".exe"
}
},
{
"name": "windows-aarch64",
"displayName": "Windows on ARM64 (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/windows/aarch64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/aarch64-windows-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "aarch64-w64-mingw32",
"SINGE_CROSS_OS": "mingw32",
"SINGE_SUFFIX": ".exe"
}
},
{
"name": "linux-aarch64",
"displayName": "ARM64 Linux (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/linux/aarch64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/aarch64-linux-gnu.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "aarch64-linux-gnu",
"SINGE_CROSS_OS": "linux",
"SINGE_SUFFIX": ""
}
},
{
"name": "macos-aarch64",
"displayName": "macOS Apple silicon (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/macos/aarch64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/aarch64-macos.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "aarch64-apple-darwin22.4",
"SINGE_CROSS_OS": "darwin",
"SINGE_SUFFIX": ""
}
},
{
"name": "macos-x86_64",
"displayName": "macOS Intel (zig)",
"inherits": "base",
"binaryDir": "${sourceDir}/.builddir/macos/x86_64/superbuild",
"toolchainFile": "${sourceDir}/cmake/zig/x86_64-macos.cmake",
"cacheVariables": {
"SINGE_TRIPLE": "x86_64-apple-darwin22.4",
"SINGE_CROSS_OS": "darwin",
"SINGE_SUFFIX": ""
}
}
],
"buildPresets": [
{
"name": "linux-x86_64",
"configurePreset": "linux-x86_64"
},
{
"name": "linux-x86_64-gcc",
"configurePreset": "linux-x86_64-gcc"
},
{
"name": "windows-x86_64",
"configurePreset": "windows-x86_64"
},
{
"name": "windows-aarch64",
"configurePreset": "windows-aarch64"
},
{
"name": "linux-aarch64",
"configurePreset": "linux-aarch64"
},
{
"name": "macos-aarch64",
"configurePreset": "macos-aarch64"
},
{
"name": "macos-x86_64",
"configurePreset": "macos-x86_64"
}
]
}

674
COPYING
View file

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

151
INSTALL
View file

@ -1,151 +0,0 @@
SINGE 3.00
==========
(For the latest version of this document, visit https://kangaroopunch.com!
The full manual is extracted to Singe/Manual.pdf on first run, beside
Singe/COPYING and Singe/LICENSES.)
Welcome to Singe! Scott's Increasingly Nifty Game Engine!
INSTALLATION & UPGRADE
======================
For the initial installation, create an empty directory and place the Singe
binary inside it. Run the binary with no command line options to cause it
to generate all the files needed to make Singe work properly. For example:
C:\Singe2\
C:\Singe2\Singe.exe
Once you run Singe.exe you'll see additional files and directories:
C:\Singe2\Singe\ (Several files created in this folder.)
C:\Singe2\Menu.bat (Or .sh on UNIX-ish OSs.)
To install games, simply unpack them and place their directory inside the
directory you created. For example, ActionMax.7z contains a folder named
"ActionMax". Place it here:
C:\Singe2\ActionMax\
For all Singe 2 and later games, it will automatically appear in the menu.
To upgrade, back up any changes you may have made to files inside the Singe
subdirectory that was generated during installation. (You really shouldn't
be changing things in there!) Delete the Singe subdirectory, Menu.bat (or
.sh) and run the new Singe binary with no command line arguments to generate
the new files.
COMMAND LINE
============
Run the binary with -h (or --help) for the option summary. Every option is
described in the "Command Line Options" chapter of Singe/Manual.pdf, which
is extracted on first run.
BUILDING FROM SOURCE
====================
On a Debian based system a checkout builds with one command:
./build-all.sh linux x86_64
It installs the host packages it needs with apt (asks for your sudo
password once), then runs the CMake superbuild, which builds every vendored
library (zlib, zstd, SDL3 and its satellites, OpenSSL, FFmpeg) into
.builddir/<os>/<arch> and then Singe against them. The finished binary is
copied to .builddir/Singe-v<version>-<Os>-<arch>.
Host packages (what build-all.sh installs): build-essential cmake
git-lfs pkg-config perl nasm llvm autoconf automake libtool meson
ninja-build imagemagick ffmpeg lua5.4 asciidoctor ruby-asciidoctor-pdf
libva-dev libvdpau-dev libdrm-dev libgl-dev libegl-dev libgles-dev
libgbm-dev libasound2-dev libpulse-dev libpipewire-0.3-dev
libjack-jackd2-dev libsndio-dev libudev-dev libdbus-1-dev
libibus-1.0-dev libxkbcommon-dev libx11-dev libxext-dev libxfixes-dev
libxi-dev libxcursor-dev libxrandr-dev libxss-dev libxtst-dev
libwayland-dev wayland-protocols libdecor-0-dev.
The artwork, the font, the models, and the menu video in assets/ and
media/ are stored with Git LFS. Clone with git-lfs present, or those
files arrive as small text pointers and the build embeds the pointers
instead of the assets; a checkout already made without it is repaired
with "git lfs install && git lfs pull". build-all.sh stops with an
error rather than build a binary around them.
The Linux release build and the Windows build use zig as the compiler:
the build fetches a pinned zig release (version and SHA-256 in
cmake/zig/zig.cmake) into .builddir/toolchains on first use. For Linux it
targets glibc 2.28, so the binary runs on distributions back to Debian 10
and Ubuntu 18.04 while linking the host's platform libraries (X11, ALSA,
VA-API, VDPAU, DRM); for Windows zig carries the headers and import
libraries itself, so nothing else is installed:
./build-all.sh windows x86_64
./build-all.sh windows aarch64 Windows on ARM64
The ARM64 Windows build needs llvm-nm (Debian's llvm package) for FFmpeg's
configure, as the macOS build does, since binutils cannot read ARM64 Windows
objects. OpenSSL has no MinGW target for ARM64 of its own;
cmake/opensslMingwArm64.conf gives its Configure one.
A developer build with the host's own gcc, glibc and symbols is the
linux-x86_64-gcc preset (binary Singe-v3.00-Linux-x86_64-gcc).
macOS (Apple silicon, macOS 13 or newer) builds with zig too, but needs
an Apple SDK for the frameworks.
Apple only ships it inside Xcode, so by default the build fetches the
MacOSX15.5 SDK tarball from the community mirror osxcross users share
(github.com/joseluisq/macosx-sdks), checks its SHA-256, and unpacks it to
.builddir/toolchains/MacOSX.sdk on first use. To avoid the download,
put a tarball holding a single MacOSX*.sdk directory at
.builddir/toolchains/MacOSX.sdk.tar.xz
(on a Mac with Xcode, osxcross's tools/gen_sdk_package.sh makes exactly
that), or point SINGE_MACOS_SDK at an unpacked SDK. SINGE_MACOS_SDK_URL
and SINGE_MACOS_SDK_SHA256 pick a different mirror. Then:
./build-all.sh macos universal
builds the Apple silicon and Intel presets (macos-aarch64, macos-x86_64)
and joins them with llvm-lipo into Singe-v3.00-Macos-universal. Either
architecture alone is ./build-all.sh macos aarch64 or x86_64.
The 3D scene's shaders are HLSL, compiled when Singe is built. The
superbuild builds SDL_shadercross and SPIRV-Cross for the host machine
once (into .builddir/toolchains/host, shared by every preset) and fetches
Microsoft's prebuilt DirectX Shader Compiler, which SDL_shadercross
drives, into .builddir/toolchains/dxc. That prebuilt package exists for
x86_64 Linux hosts; on another host, unpack a DirectX Shader Compiler
release (lib/libdxcompiler.so, lib/libdxil.so, include/dxc/dxcapi.h) at
that path yourself, or build it from source there.
The ARM64 Linux build targets any ARM64 Linux system with glibc 2.31 or
newer, the 64-bit Raspberry Pi OS among them. It is not Pi specific: the
decoder it builds talks to any V4L2 memory-to-memory device, so one binary
also serves Amlogic, Exynos, Qualcomm and other boards whose kernel offers
one, and it carries Rockchip's own decoders besides. 3D games need a Pi 4
or later, which has OpenGL ES 3.1 (and Vulkan); the Pi 3 has only OpenGL
ES 2.0 and plays 2D games only. The build
uses zig. The platform headers and libraries it links against are Debian
bookworm arm64 packages listed in cmake/zig/arm64Packages.cmake, fetched
from snapshot.debian.org and unpacked with dpkg-deb into
.builddir/toolchains/sysroot-aarch64-linux-gnu on first use:
./build-all.sh linux aarch64
Useful forms (anything after the platform goes to cmake --build):
./build-all.sh linux x86_64 --target singe Singe alone
./build-all.sh linux x86_64 --target rebuild-ffmpeg redo one library
Without the wrapper:
cmake --preset linux-x86_64 && cmake --build --preset linux-x86_64
Presets: linux-x86_64, linux-x86_64-gcc, windows-x86_64, windows-aarch64,
linux-aarch64, macos-aarch64, macos-x86_64 (CMakePresets.json).

View file

@ -1,89 +0,0 @@
Singe was made possible by a number of third-party software libraries
generously provided as open source by their respective authors. Without
these tools, Singe would not exist.
The version column is the release vendored under thirdparty/ (or, for the
libraries bundled inside the SDL trees and the host tools, the copy those
trees carry). Projects without releases are listed by snapshot date.
arg_parser 1.21 BSD-2-Clause http://savannah.nongnu.org/projects/arg-parser
basis_universal 2.50 Apache-2.0 https://github.com/BinomialLLC/basis_universal
binaryheap.lua 0.4 MIT http://tieske.github.io/binaryheap.lua
brotli 1.2.0 MIT https://github.com/google/brotli
bump.lua 3.1.7 MIT https://github.com/kikito/bump.lua
cgltf 1.15 MIT https://github.com/jkuhlmann/cgltf
copas 4.12.0 MIT https://lunarmodules.github.io/copas
dav1d 1.5.4 BSD-2-Clause https://code.videolan.org/videolan/dav1d
DirectXShaderCompiler 1.10.2605.37 NCSA https://github.com/microsoft/DirectXShaderCompiler (prebuilt, downloaded at build time; host tool only)
ffmpeg 9.0.1 LGPL-2.1 https://ffmpeg.org
freetype 2.13.2 FTL https://freetype.org (bundled with SDL3_ttf)
game-music-emu 0.6.6 LGPL-2.1 https://github.com/libgme/game-music-emu (bundled with SDL3_mixer)
harfbuzz 14.4.0 MIT https://harfbuzz.github.io (bundled with SDL3_ttf)
highway 1.4.0 Apache-2.0 https://github.com/google/highway (bundled with libjxl)
inspect.lua 3.1.0 MIT https://github.com/kikito/inspect.lua
JoltPhysics 5.6.0 MIT https://github.com/jrouwe/JoltPhysics
json.lua 0.1.2 MIT https://github.com/rxi/json.lua
libjpeg 9f IJG https://ijg.org (bundled with SDL3_image)
libjxl 0.7.3-SDL BSD-3-Clause https://github.com/libsdl-org/libjxl (bundled with SDL3_image)
libogg 1.3.5 BSD-3-Clause https://xiph.org/ogg (bundled with SDL3_mixer)
libpng 1.6.58 libpng-2.0 http://www.libpng.org (bundled with SDL3_image)
librs232 1.0.4 MIT https://github.com/srdgame/librs232
libwebp 1.3.2 BSD-3-Clause https://developers.google.com/speed/webp (bundled with SDL3_image)
libxmp 4.7.0 MIT https://github.com/libxmp/libxmp (bundled with SDL3_mixer)
lsqlite3 0.9.7 MIT http://lua.sqlite.org
lua 5.4.9 MIT https://www.lua.org
luafilesystem 1.9.0 MIT https://lunarmodules.github.io/luafilesystem
luasec 1.3.2 MIT https://github.com/lunarmodules/luasec
luasocket 3.1.0 MIT https://lunarmodules.github.io/luasocket
lume 2.3.0 MIT https://github.com/rxi/lume
manymouse 0.0.3 Zlib https://icculus.org/manymouse
middleclass 4.1.1 MIT https://github.com/kikito/middleclass
MobDebug 0.805 MIT https://github.com/pkulchenko/MobDebug
openssl 3.5.8 Apache-2.0 https://www.openssl.org
opus 1.4 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer; also Singe's voice codec)
opusfile 0.12 BSD-3-Clause https://opus-codec.org (bundled with SDL3_mixer)
plutosvg 0.0.8 MIT https://github.com/sammycage/plutosvg (bundled with SDL3_ttf)
plutovg 1.3.3 MIT https://github.com/sammycage/plutovg (bundled with SDL3_ttf)
recastnavigation 1.6.0 Zlib https://github.com/recastnavigation/recastnavigation
RmlUi 6.3 MIT https://github.com/mikke89/RmlUi
SDL3 3.4.16 Zlib https://www.libsdl.org
SDL3_image 3.4.6 Zlib https://www.libsdl.org
SDL3_mixer 3.2.4 Zlib https://www.libsdl.org
SDL3_ttf 3.2.2 Zlib https://www.libsdl.org
SDL_shadercross main 2026-09 Zlib https://github.com/libsdl-org/SDL_shadercross (host tool only)
skcms 0.7.3-SDL BSD-3-Clause https://skia.googlesource.com/skcms (bundled with libjxl)
speexdsp 1.2.1 BSD-3-Clause https://www.speex.org
SPIRV-Cross main 2026-09 Apache-2.0 https://github.com/KhronosGroup/SPIRV-Cross (host tool only)
sqlite 3.53.4 Public-Domain https://sqlite.org
timerwheel.lua 1.0.2 MIT https://tieske.github.io/timerwheel.lua
TinySoundFont 2026-07-19 MIT https://github.com/schellingb/TinySoundFont
uthash 2.4.0 BSD-1-Clause https://troydhanson.github.io/uthash
vlc 3.0 headers LGPL-2.1 https://www.videolan.org/vlc (two language-code headers only)
wavpack 5.9.0 BSD-3-Clause https://www.wavpack.com (bundled with SDL3_mixer)
zlib 1.3.2 Zlib https://zlib.net
zstd 1.5.7 BSD-3-Clause https://facebook.github.io/zstd
Fonts
-----
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
Sounds
------
The menu's intro (assets/menuIntro.flac, written by util/makeMenuSound.py) is built on three
recordings from SoundBible, each under the Creative Commons Attribution 3.0 licence
(https://creativecommons.org/licenses/by/3.0/) and kept as FLAC under media/samples:
Explosion Ultra Bass Mark DiAngelo CC BY 3.0 https://soundbible.com/1807-Explosion-Ultra-Bass.html
Large Fireball Mike Koenig CC BY 3.0 https://soundbible.com/1348-Large-Fireball.html
Dragon Fire Breath and Roar Daniel Simon CC BY 3.0 https://soundbible.com/2127-Dragon-Fire-Breath-and-Roar.html
Data
----
Mozilla CA list 20260601 (Debian ca-certificates) MPL-2.0 https://wiki.mozilla.org/CA (assets/cacerts.pem)

View file

@ -1,713 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu's backdrop: the intro and the grid the menu sits on, as one scene.
--
-- A charge goes off, the Singe dragon and name fly out of it toward the camera, the tagline fades
-- in underneath, the dragon rears and roars, then rears again and breathes flame -- and then the
-- grid comes up out of the dark
-- beneath the logo while the smoke is still clearing, the logo lifts away, and what is left is the
-- backdrop the menu sits on. One scene from the first frame to the last: no cut, and nothing
-- loads at the join.
--
-- It lives here rather than inside Singe/MenuDocument.singe because two things draw it: the menu,
-- and util/renderMenuVideo.py, which records it so a machine with no GPU can play what a machine
-- with one draws. A second copy would be a second thing to keep in step, and the recording would
-- quietly stop matching the menu.
--
-- Needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the path for a machine
-- without one.
-- The backdrop: the intro and the grid the menu sits on, as one scene. Every number here came
-- from testScripts/menuIntro.singe, which is where the pacing was worked out; changing one is
-- best done there first, where a contact sheet of the whole eight seconds is one command away.
local BLAST_Z = -30.0 -- Where the charge goes off.
local BLAST_Y = 2.5 -- and how high, which is where the logo comes from.
local REST_Z = -8.0 -- Where the logo ends up.
local CAM_Z = 2.0
local FLY_START = 0.30 -- Seconds: the logo leaves the blast.
local FLY_END = 1.90 -- and has settled.
local TAG_START = 2.10 -- The tagline begins to appear.
local TAG_END = 3.00
local SPINS = 2.0 -- Turns the logo makes on the way out.
local IDLE_TURN = 7.0 -- Degrees it drifts either side once it has settled.
local IDLE_RATE = 0.9 -- Radians a second of that drift.
local GRID_UP = 4.30 -- Seconds: the grid starts to come up out of the dark.
local GRID_LIT = 5.60 -- and is fully lit.
local LIFT_FROM = 5.90 -- The logo starts to leave.
local LIFT_TO = 7.30 -- and is gone; the menu has the screen from here.
local LOOP_AT = 9.90 -- Where the recording of all this begins its looping section, and
-- where Singe/menuIntro.flac, which Singe/Menu.singe plays over
-- either renderer, has finished fading out. The menu has had the
-- screen since LIFT_TO; these last seconds are the grid running on
-- under it while the music leaves, which is why this is not simply
-- LIFT_TO. It is a whole number of frames -- and of keyframes -- at
-- the step util/renderMenuVideo.py records at, which checks that
-- rather than taking it on trust.
local LIFT_RISE = 13.0 -- World units it climbs on the way out.
local MEASURE_FROM = 2.0 -- Seconds: the backdrop is timed from here, the logo settled and flapping,
local MEASURE_TO = 4.0 -- to here, before the grid comes up, and the average sets this machine's
-- tier (PLAN.md section 57). The menu is the benchmark: it runs anyway.
local TIER_MS = { 2.5, 6.0, 20.0 } -- A frame of it at or under these milliseconds is tier 3, 2, or 1;
-- slower is 0. First guesses from this machine and the design's
-- reference hardware, to be set again on the real parts.
-- The backdrop, which is the grid the menu sits on. Same numbers as testScripts/gridBackground.
local CELL = 6.0
local LINE_W = 0.12
local CROSS_W = 0.18
local SPREAD = 16.0
local HALF_X = 12
local DEPTH = 14
-- The grid repeats itself every GRID_PERIOD, because it is slid by one cell and wrapped. The
-- period is the constant and the speed follows from it, rather than the other way round, so that
-- a recording of the loop can be a whole number of frames: at 6 units and 9 a second the period
-- was 2/3 of a second, which is not a whole number of frames at any integer step, and the recorded
-- loop could never quite meet itself.
local GRID_PERIOD = 0.66
local GRID_SPEED = CELL / GRID_PERIOD
local FOG_R = 8
local FOG_G = 3
local FOG_B = 20
local FOG_NEAR = 12
local FOG_FAR = 72
local SUN_R = 10.5
local SUN_Y = 7.6
local SUN_Z = -34.0
local SUN_SLICES = 6
local SUN_SEGMENTS = 72
local HORIZ_W = 70.0
local HORIZ_H = 0.5
local HORIZ_Z = -38.0
local HORIZ_D = 2.2
local CAM_Y = 2.7 -- Eye height, shared by the logo and the grid: the backdrop's
-- perspective is tuned to it, and at the intro's old 1.2 the grid came
-- up flattened with its nearest line a slab across the bottom.
local LOGO_Y = 0.15 -- Where the logo settles. It is the dragon that is centered on the
-- sun, not the pair of them: this puts the dragon's middle on the
-- sun's middle, which leaves the name below it crossing the horizon
-- rather than sitting across the sun's face and hiding it. Measured
-- rather than judged by eye -- a still with the dragon hidden gives
-- the sun's disc, one with it shown gives the dragon's -- so it is
-- worth re-measuring if either model changes.
-- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head,
-- jaw, neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the
-- model's origin though, so turning a wing node would swing the wing round the dragon's middle
-- rather than its shoulder. The joints are baked into the model now; coordinates below are the
-- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z.
local MOUTH = { 2.88, 5.46, 0.00 } -- Where the flame leaves, in model coordinates: in the gap
-- between the snout and the jaw once the jaw has dropped, back
-- from the nose so the fire comes out of the mouth, not the nose.
local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see media/DragonPivots.json.
local FLAP_MAX = 34.0 -- Degrees a wing rises and falls.
local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast.
local FLAP_SLOW = 3.4 -- and once it has settled.
local ROAR_BACK = 2.20 -- Seconds: the head starts to rear for the roar, which builds in
-- media/samples/dragonFireBreathAndRoar.flac from here,
local ROAR_TOP = 2.50 -- is fully back as the roar reaches its loudest,
local ROAR_HOLD = 2.85 -- held there until the roar falls away,
-- and down again by REAR_BACK, so the rear for the fire reads as a
-- second one rather than as the roar carrying on.
local ROAR_ANGLE = 24.0 -- Degrees the head tips back to roar.
local JAW_OPEN = -30.0 -- Degrees the lower jaw drops, for the roar and for the fire.
local NECK_SHARE = 0.35 -- How much of the head's pitch the neck takes as well.
local REAR_BACK = 3.05 -- Seconds: the head starts to rear for the fire.
local REAR_TOP = 3.40 -- fully back.
local REAR_STRIKE = 3.58 -- and thrown forward.
local BREATH_START = 3.46
local BREATH_END = 4.35
local REAR_DONE = 4.90 -- Back to where it started.
local REAR_ANGLE = 34.0 -- Degrees the head tips back.
local STRIKE_ANGLE = -14.0 -- and forward through the strike.
local DRAGON_YAW = -148.0 -- The dragon faces left, turned toward the camera so the wingbeat is
-- seen three quarters on rather than edge on; the name stays square
-- to the camera because it has to be read.
local TAGLINE = "Scott's Increasingly Nifty Game Engine"
local backdropStarted = 0 -- singeGetTicks() when the backdrop's clock began.
local backdropTagX = 0
local backdropTagY = 0
local blown = false -- Whether the charge has gone off.
local gridLit = false -- Whether the grid has finished coming up and its colors are final.
local gridShown = false -- Whether the grid, the horizon and the sun are drawn at all yet.
local backdropParts = {} -- Those nodes: hidden through the intro, shown as the grid comes up.
local backdropFont = nil
local measuring = false -- Whether the backdrop is being timed right now.
local measured = false -- Whether this run has timed it, or has no intro to time.
-- ===== The backdrop ==========================================================================
--
-- The intro and the grid are one scene: the blast, the logo out of it, the tagline, and then the
-- grid rising out of the dark beneath the logo while the smoke is still clearing, the logo lifting
-- away, and the backdrop left running under the menu. There is no cut and nothing loads at the
-- join, which is the whole point of doing it this way rather than playing a video and switching.
-- The scene itself. Opaque black to begin with: the intro happens in the dark, and the disc
-- behind it is stopped rather than shown through, so this is the whole picture from the first
-- frame until the grid comes up and the background goes with it.
sceneEnable(true)
sceneSetBackground(0, 0, 0, 255)
sceneSetAmbient(14, 12, 16)
sceneSetBloom(1.2, 0.22)
sceneSetTonemap(TONEMAP_ACES)
-- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from
-- media/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without
-- anything being rigged here. This used to build them at run time from bounding boxes, which
-- worked but left every future use of the model to re-derive the same thing.
local function joint(root, name, parent)
local part = nodeFind(name, root)
if (part ~= nil) and (parent ~= nil) then
local px, py, pz = nodeGetPosition(part)
local qx, qy, qz = nodeGetPosition(parent)
-- Both parts carry their own baked joint, measured from the model's origin. Hanging one
-- off the other makes the child's offset count from the parent's, so the two would add up
-- and the head would fly off above the neck -- which is exactly what it did.
nodeSetParent(part, parent)
nodeSetPosition(part, px - qx, py - qy, pz - qz)
end
return part
end
-- Eases in: slow to start, quick away. The logo leaves this way so the move begins as a drift.
local function easeIn(t)
return t * t * t
end
-- Eases out: fast off the mark, slow into the stop.
local function easeOut(t)
return 1 - (1 - t) * (1 - t) * (1 - t)
end
-- How far through a span the time is, 0 before it and 1 after.
local function span(t, from, to)
return math.max(math.min((t - from) / (to - from), 1), 0)
end
local blast = nodeNew()
nodeSetPosition(blast, 0, BLAST_Y, BLAST_Z)
local smoke = emitterNew(blast)
emitterSetMax(smoke, 400)
emitterSetBlend(smoke, PARTICLE_ALPHA)
emitterSetRadius(smoke, 1.6)
emitterSetSpread(smoke, 360)
emitterSetSpeed(smoke, 2.5, 9.0)
emitterSetDrag(smoke, 1.3)
emitterSetGravity(smoke, 0, 3.2, 0)
emitterSetLife(smoke, 1.2, 2.4)
emitterSetSize(smoke, 3.2, 12.0)
emitterSetSpin(smoke, -40, 40)
emitterSetColor(smoke, 64, 46, 38, 170, 10, 9, 12, 0)
local core = emitterNew(blast)
emitterSetMax(core, 500)
emitterSetBlend(core, PARTICLE_ADD)
emitterSetRadius(core, 1.1)
emitterSetSpread(core, 360)
emitterSetSpeed(core, 5.0, 20.0)
emitterSetDrag(core, 1.7)
emitterSetGravity(core, 0, 5.0, 0)
emitterSetLife(core, 0.6, 1.3)
emitterSetSize(core, 2.0, 8.2)
emitterSetColor(core, 255, 232, 150, 255, 190, 35, 0, 0)
local flash = emitterNew(blast)
emitterSetMax(flash, 20)
emitterSetBlend(flash, PARTICLE_ADD)
emitterSetRadius(flash, 0.4)
emitterSetSpread(flash, 360)
emitterSetSpeed(flash, 0, 1.5)
emitterSetLife(flash, 0.10, 0.22)
emitterSetSize(flash, 6.0, 15.0)
emitterSetColor(flash, 255, 255, 235, 255, 255, 150, 40, 0)
local sparks = emitterNew(blast)
emitterSetMax(sparks, 300)
emitterSetBlend(sparks, PARTICLE_ADD)
emitterSetRadius(sparks, 0.5)
emitterSetSpread(sparks, 360)
emitterSetSpeed(sparks, 12.0, 34.0)
emitterSetDrag(sparks, 0.25)
emitterSetGravity(sparks, 0, -15.0, 0)
emitterSetLife(sparks, 0.5, 1.5)
emitterSetSize(sparks, 0.30, 0.05)
emitterSetSpin(sparks, -200, 200)
emitterSetTrail(sparks, 8, 0.10)
emitterSetColor(sparks, 255, 226, 160, 255, 255, 80, 15, 0)
-- The blast's own light, so the logo is lit by the fire it comes out of.
local fireLight = lightNew(LIGHT_POINT)
nodeSetParent(fireLight, blast)
lightSetColor(fireLight, 255, 150, 60)
lightSetRange(fireLight, 60)
lightSetIntensity(fireLight, 0)
-- A key light for the logo once the fire has died, from over the camera's shoulder.
local key = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(key, 4, 6, 10)
nodeLookAt(key, 0, 0, REST_Z)
lightSetIntensity(key, 1.15)
-- A magenta rim from behind and to the left, the color the grid behind the menu is drawn in, so
-- the logo is not a flat white cut-out and the intro and the menu share a palette.
local rim = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(rim, -7, 3, REST_Z - 9)
nodeLookAt(rim, 0, 1.5, REST_Z)
lightSetColor(rim, 255, 70, 200)
lightSetIntensity(rim, 2.6)
-- The dragon over the name, assembled the way testScripts/scene30.singe does it.
local logo = nodeNew()
local text = modelInstance(modelLoad("Singe/SingeText.glb"))
nodeSetParent(text, logo)
local dragon = modelInstance(modelLoad("Singe/DragonModel.glb"))
nodeSetParent(dragon, logo)
nodeSetPosition(dragon, 0, 4.85, 0)
nodeSetRotation(dragon, 0, DRAGON_YAW, 0)
nodeSetScale(dragon, 0.9)
nodeSetScale(logo, 0.5)
nodeSetVisible(logo, false)
-- The rig. The head hangs off the neck so that rearing the neck carries the head with it, and
-- the jaw off the head for the same reason. Tip first: joint measures the parent where it sits,
-- so the jaw has to be hung on the head while the head is still at its own baked joint rather than
-- already moved to be relative to the neck.
local wingLeft = joint(dragon, "wingL")
local wingRite = joint(dragon, "wingR")
local neck = joint(dragon, "neck")
local jaw = joint(dragon, "jaw", nodeFind("head", dragon))
local head = joint(dragon, "head", neck)
-- The flame, on a node at the dragon's mouth so it follows the head round.
local jet = nodeNew()
nodeSetParent(jet, head)
-- Relative to the head's own joint, which is where its node now sits.
nodeSetPosition(jet, MOUTH[1] - HEAD_AT[1], MOUTH[2] - HEAD_AT[2], MOUTH[3] - HEAD_AT[3])
-- The flame's own light, so the dragon is lit by what it is breathing.
local breathLight = lightNew(LIGHT_POINT)
nodeSetParent(breathLight, jet)
lightSetColor(breathLight, 255, 160, 60)
lightSetRange(breathLight, 14)
lightSetIntensity(breathLight, 0)
local breath = emitterNew(jet)
emitterSetMax(breath, 400)
emitterSetBlend(breath, PARTICLE_ADD)
emitterSetRadius(breath, 0.05)
emitterSetSpread(breath, 13)
emitterSetSpeed(breath, 4.5, 9.5)
emitterSetDrag(breath, 3.0)
emitterSetLife(breath, 0.28, 0.55)
emitterSetSize(breath, 0.10, 1.30)
emitterSetRate(breath, 260)
emitterSetColor(breath, 255, 244, 200, 255, 235, 60, 0, 0)
-- The tagline is measured in backdropBegin instead, once a font has been selected: at this point
-- the document renderer has not chosen one, and fontToSprite ends the game without it.
-- ===== The backdrop ==========================================================================
--
-- Built now and left unlit. Bringing it up is a ramp on the emissive colors rather than anything
-- appearing, so the grid rises out of the dark the way a light comes on instead of cutting in.
local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
for _, c in ipairs({ { x0, z0 }, { x1, z1 }, { x2, z2 }, { x3, z3 } }) do
p[#p + 1] = c[1]
p[#p + 1] = 0
p[#p + 1] = c[2]
end
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
i[#i + 1] = base + n
end
end
-- A line of constant width thins to nothing as it recedes and breaks into crawling dots; widening
-- it with distance holds it at about the same width on screen the whole way to the horizon.
local function spread(z)
return 1 + (-z) / SPREAD
end
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row.
local function horizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
local function gridMesh()
local p = {}
local i = {}
local far = -DEPTH * CELL
local wide = HALF_X * CELL
local hN = LINE_W / 2
local hF = hN * spread(far)
for n = -HALF_X, HALF_X do
local x = n * CELL
quad(p, i, x - hN, CELL, x + hN, CELL, x + hF, far, x - hF, far)
end
for n = -1, DEPTH do
local z = -n * CELL
local h = CROSS_W / 2 * spread(z)
quad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h)
end
return meshNew(p, nil, nil, i)
end
-- The sun: the part of a disc above yClip, as a fan from the middle of the chord.
local function sunMesh(yClip)
local p = { 0, yClip, 0 }
local i = {}
local a0 = math.asin(math.max(math.min(yClip / SUN_R, 1), -1))
for n = 0, SUN_SEGMENTS do
local a = a0 + (n / SUN_SEGMENTS) * (math.pi - a0 * 2)
p[#p + 1] = math.cos(a) * SUN_R
p[#p + 1] = math.sin(a) * SUN_R
p[#p + 1] = 0
end
for n = 1, SUN_SEGMENTS do
i[#i + 1] = 1
i[#i + 1] = n + 1
i[#i + 1] = n + 2
end
return meshNew(p, nil, nil, i)
end
-- Unlit, and brought up by their base color rather than by emissive. The intro has a key light
-- and a rim for the logo, and a lit material with a black base still catches their specular: the
-- grid and the sun showed as a gray sheen from the first frame, lit before they were meant to
-- exist. Unlit takes the lights out of it entirely and the ramp below is the only thing that
-- decides when the backdrop appears.
local gridMat = materialNew()
materialSetColor(gridMat, 0, 0, 0)
materialSetUnlit(gridMat, true)
materialSetDoubleSided(gridMat, true)
local sunMat2 = materialNew()
materialSetColor(sunMat2, 0, 0, 0)
materialSetUnlit(sunMat2, true)
materialSetDoubleSided(sunMat2, true)
local horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetUnlit(horizMat, true)
materialSetDoubleSided(horizMat, true)
local sliceMat = materialNew()
materialSetColor(sliceMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(sliceMat, true)
materialSetDoubleSided(sliceMat, true)
local backdrop = nodeNew()
local grid = nodeNew()
nodeSetMesh(grid, gridMesh(), gridMat)
nodeSetParent(grid, backdrop)
backdropParts[#backdropParts + 1] = grid
local horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdrop)
backdropParts[#backdropParts + 1] = horizon
local sunCut = horizonY(SUN_Z) - SUN_Y
local backSun = nodeNew()
nodeSetMesh(backSun, sunMesh(sunCut), sunMat2)
nodeSetPosition(backSun, 0, SUN_Y, SUN_Z)
nodeSetParent(backSun, backdrop)
backdropParts[#backdropParts + 1] = backSun
for n = 1, SUN_SLICES do
local slice = nodeNew()
local height = 0.62 - (n - 1) * 0.07
local y = sunCut + 0.5 + (n - 1) * 1.3
local w = math.sqrt(math.max(SUN_R * SUN_R - (math.abs(y) + height / 2) ^ 2, 0))
nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
backdropParts[#backdropParts + 1] = slice
nodeSetParent(slice, backdrop)
end
local camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
cameraSet(camera)
-- The backdrop is the disc for the intro and the 3D scene after it. The grid section of
-- Singe/menuBackground.mkv is never reached: this renderer has the GPU device the document needs,
-- which is the same one the scene draws with, so the grid is drawn rather than played.
-- One frame of the backdrop's timeline.
function backdropFrame()
local t = (singeGetTicks() - backdropStarted) / 1000.0
local fly = span(t, FLY_START, FLY_END)
local tag = span(t, TAG_START, TAG_END)
if not blown then
blown = true
emitterBurst(smoke, 85)
emitterBurst(core, 110)
emitterBurst(flash, 5)
emitterBurst(sparks, 130)
end
-- The machine's tier, from how long this very scene takes to draw.
if not measured then
if (not measuring) and (t >= MEASURE_FROM) then
sceneMeasure(true)
measuring = true
elseif measuring and (t >= MEASURE_TO) then
local ms = sceneMeasure(false)
local tier = 0
measuring = false
measured = true
if ms then
for index, limit in ipairs(TIER_MS) do
if (tier == 0) and (ms <= limit) then
tier = #TIER_MS + 1 - index
end
end
if singeCapabilities().tier ~= tier then
singeSaveTier(tier)
end
debugPrint(string.format("Backdrop: %.2f ms a frame, tier %d", ms, tier))
end
end
end
-- The fire's light dies over the first second and a half.
lightSetIntensity(fireLight, 400 * (1 - span(t, 0.05, 1.5)))
-- The grid comes up out of the dark while the smoke is still clearing, so the two overlap and
-- there is no moment where one thing has ended and the next has not begun.
local lit = span(t, GRID_UP, GRID_LIT)
-- Until then the grid, the horizon and the sun are not drawn at all. Black and unlit, they
-- were still there: a black line across the blast's glow is a line, and the whole grid showed
-- through the explosion as its outline.
if (lit > 0) and not gridShown then
gridShown = true
for _, part in ipairs(backdropParts) do
nodeSetVisible(part, true)
end
end
if (lit > 0) and not gridLit then
materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit)
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
materialSetColor(sunMat2, 255 * lit, 130 * lit, 55 * lit)
-- The sky stops being black at the same time, or the grid would rise into a void.
sceneSetBackground(FOG_R * lit, FOG_G * lit, FOG_B * lit, 255)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
-- Once fully lit the colors are final, so they are not written again every frame.
gridLit = (lit >= 1)
end
if lit > 0 then
-- Scrolling from the moment it is visible, so it is already alive when it arrives.
nodeSetPosition(grid, 0, 0, ((t - GRID_UP) * GRID_SPEED) % CELL)
end
if fly > 0 then
local e = easeOut(fly)
-- Once it has arrived it keeps turning slowly, so the held frame is not a still picture.
local idle = IDLE_TURN * math.sin((t - FLY_END) * IDLE_RATE) * fly
local yaw = 360 * SPINS * (1 - e) + idle
-- Out of the blast, then up and away once the grid has taken over. Rising rather than
-- fading: a model has no alpha to fade without touching every material it came with.
local leave = easeIn(span(t, LIFT_FROM, LIFT_TO))
nodeSetVisible(logo, leave < 1)
nodeSetPosition(logo, 0, LOGO_Y * e + LIFT_RISE * leave, BLAST_Z + (REST_Z - BLAST_Z) * e)
nodeSetRotation(logo, 0, yaw, 0)
-- Wings: beating hard on the way out, easing to a hover once it has arrived.
local rate = FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * e
local flap = FLAP_MAX * math.sin(t * rate)
nodeSetRotation(wingLeft, -flap, 0, 0)
nodeSetRotation(wingRite, flap, 0, 0)
-- The head rears to roar and comes down; then it rears again, snaps forward, and settles,
-- and the flame goes with the strike.
local pitch = 0
if t < ROAR_TOP then
pitch = ROAR_ANGLE * easeOut(span(t, ROAR_BACK, ROAR_TOP))
elseif t < REAR_BACK then
pitch = ROAR_ANGLE * (1 - span(t, ROAR_HOLD, REAR_BACK))
elseif t < REAR_TOP then
pitch = REAR_ANGLE * span(t, REAR_BACK, REAR_TOP)
elseif t < REAR_STRIKE then
local k = span(t, REAR_TOP, REAR_STRIKE)
pitch = REAR_ANGLE + (STRIKE_ANGLE - REAR_ANGLE) * k
else
pitch = STRIKE_ANGLE * (1 - easeOut(span(t, REAR_STRIKE, REAR_DONE)))
end
nodeSetRotation(head, 0, 0, pitch)
nodeSetRotation(neck, 0, 0, pitch * NECK_SHARE)
-- The mouth opens as the head goes back for the roar and shuts as it comes down, then
-- opens again for the fire and stays open until the flame is spent.
local roarJaw = span(t, ROAR_BACK, ROAR_TOP) * (1 - span(t, ROAR_HOLD, REAR_BACK))
local fireJaw = span(t, REAR_BACK, BREATH_START) * (1 - span(t, BREATH_END, REAR_DONE))
local jawPitch = JAW_OPEN * math.max(roarJaw, fireJaw)
nodeSetRotation(jaw, 0, 0, jawPitch)
-- The flame leaves the mouth along the dragon's own forward, which the whole logo has been
-- turning; a 3D emitter takes its direction in world axes, so it is turned here by hand.
-- The head rides on the neck, so its pitch in the world is its own plus the neck's, and the
-- flame follows the middle of the open mouth, half the jaw's drop below the head's line.
local aim = math.rad(pitch * (1 + NECK_SHARE) + jawPitch / 2)
local spin = math.rad(yaw + DRAGON_YAW)
emitterSetDirection(breath, math.cos(aim) * math.cos(spin), math.sin(aim), -math.cos(aim) * math.sin(spin))
if t >= BREATH_START and t < BREATH_END then
emitterStart(breath)
lightSetIntensity(breathLight, 26)
else
emitterStop(breath)
lightSetIntensity(breathLight, 0)
end
end
-- The tagline goes with the logo. It is drawn into the overlay, which the menu clears every
-- frame, so it has to be redrawn here rather than left standing.
local gone = span(t, LIFT_FROM, LIFT_TO - 0.4)
if (tag > 0) and (gone < 1) then
colorForeground(235, 225, 240, math.floor(255 * tag * (1 - gone)))
fontPrint(backdropTagX, backdropTagY, TAGLINE)
end
end
-- Starts the backdrop's clock. showIntro false starts it past the end of the intro, so returning
-- from a game lands on the settled grid and the same timeline runs on from there.
function backdropBegin(showIntro)
-- A font of its own. The document renderer draws everything else through RmlUi and never
-- selects one, so the tagline -- the only thing put straight into the overlay -- brings it.
-- Thirty points on the menu's 480 rows, and in step with the overlay elsewhere: a recording
-- made at a larger size (util/renderMenuAd.py) lays the tagline out at that size rather than
-- scaling the menu's up.
backdropFont = fontLoad("Singe/FreeSansBold.ttf", math.floor(30 * overlayGetHeight() / 480 + 0.5))
fontSelect(backdropFont)
fontQuality(FONT_QUALITY_BLENDED)
-- fontPrint has no centering, so the tagline is measured once through a throwaway sprite; it
-- is drawn rather than kept as a sprite because a sprite cannot be tinted and the fade needs
-- the foreground color's alpha.
local measure = fontToSprite(TAGLINE)
backdropTagX = math.floor((overlayGetWidth() - spriteGetWidth(measure)) / 2)
backdropTagY = math.floor(overlayGetHeight() * 0.80)
spriteUnload(measure)
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
blown = not showIntro
measured = not showIntro
measuring = false
gridLit = false
gridShown = false
for _, part in ipairs(backdropParts) do
nodeSetVisible(part, false)
end
if not showIntro then
nodeSetVisible(logo, false)
end
end
-- Seconds since the backdrop began.
function backdropTime()
return (singeGetTicks() - backdropStarted) / 1000.0
end
-- Whether the intro is over and the screen belongs to whatever sits on the backdrop.
function backdropReady()
return backdropTime() >= LIFT_TO
end
-- How long the whole thing lasts, and how long one turn of the grid takes, so a recording knows
-- where the intro ends and how much of the loop to keep.
function backdropIntroSeconds()
return LIFT_TO
end
function backdropLoopSeconds()
return GRID_PERIOD
end
-- Where the intro ends and the loop begins, which is where the sting hands over to the bed.
function backdropLoopAt()
return LOOP_AT
end
function backdropFinish()
if backdropFont then
fontUnload(backdropFont)
backdropFont = nil
end
end

View file

@ -1,36 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Singe stopped here because an error is about to end your game. This only happens under --debug,
-- with a debugger such as ZeroBrane Studio attached.
--
-- What went wrong is in "message": hover over it, or look in the Stack window. Where it went wrong
-- is in the Stack window too, a frame or two below this one -- pick your own function there to see
-- its local variables as they were. Continue, and the game ends just as it would have.
local message = ...
require("mobdebug").pause()
return message

BIN
assets/DragonModel.glb (Stored with Git LFS)

Binary file not shown.

View file

@ -1,768 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Singe 2.xx Features -------------------------------------------------------
-- SINGE_FRAMEWORK_VERSION, SWITCH_*, FONT_QUALITY_*, MODE_*, MOUSE_*, OVERLAY_*,
-- RENDER_*, DISC_*, SOUND_ERROR_* and the SINGE_* input code layout are all
-- pushed by the engine before this file runs, so they are defined in exactly
-- one place: singe.c.
if singeGetScriptPath ~= nil then
DIR = singeGetScriptPath():match("(.*[/\\])") or "./"
end
function utilDeepCopy(original)
local copy
if type(original) == 'table' then
copy = {}
for key, value in next, original, nil do
copy[utilDeepCopy(key)] = utilDeepCopy(value)
end
setmetatable(copy, utilDeepCopy(getmetatable(original)))
else -- number, string, boolean, etc
copy = original
end
return copy
end
function utilDump(o)
if type(o) == 'table' then
local s = '{ '
for k,v in pairs(o) do
if type(k) ~= 'number' then
k = '"'..k..'"'
end
s = s .. '['..k..'] = ' .. utilDump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
function utilGetTableSize(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
-- Returns a copy of a { NAME = { name = NAME, value = N } } table with every value shifted.
function utilOffsetTable(source, offset)
local copy = utilDeepCopy(source)
for _, entry in pairs(copy) do
entry.value = entry.value + offset
end
return copy
end
function utilTrim(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end
SCANCODE = {
A = { name = "A", value = 4 },
B = { name = "B", value = 5 },
C = { name = "C", value = 6 },
D = { name = "D", value = 7 },
E = { name = "E", value = 8 },
F = { name = "F", value = 9 },
G = { name = "G", value = 10 },
H = { name = "H", value = 11 },
I = { name = "I", value = 12 },
J = { name = "J", value = 13 },
K = { name = "K", value = 14 },
L = { name = "L", value = 15 },
M = { name = "M", value = 16 },
N = { name = "N", value = 17 },
O = { name = "O", value = 18 },
P = { name = "P", value = 19 },
Q = { name = "Q", value = 20 },
R = { name = "R", value = 21 },
S = { name = "S", value = 22 },
T = { name = "T", value = 23 },
U = { name = "U", value = 24 },
V = { name = "V", value = 25 },
W = { name = "W", value = 26 },
X = { name = "X", value = 27 },
Y = { name = "Y", value = 28 },
Z = { name = "Z", value = 29 },
MAIN_1 = { name = "MAIN_1", value = 30 },
MAIN_2 = { name = "MAIN_2", value = 31 },
MAIN_3 = { name = "MAIN_3", value = 32 },
MAIN_4 = { name = "MAIN_4", value = 33 },
MAIN_5 = { name = "MAIN_5", value = 34 },
MAIN_6 = { name = "MAIN_6", value = 35 },
MAIN_7 = { name = "MAIN_7", value = 36 },
MAIN_8 = { name = "MAIN_8", value = 37 },
MAIN_9 = { name = "MAIN_9", value = 38 },
MAIN_0 = { name = "MAIN_0", value = 39 },
RETURN = { name = "RETURN", value = 40 },
ESCAPE = { name = "ESCAPE", value = 41 },
BACKSPACE = { name = "BACKSPACE", value = 42 },
TAB = { name = "TAB", value = 43 },
SPACE = { name = "SPACE", value = 44 },
MINUS = { name = "MINUS", value = 45 },
EQUALS = { name = "EQUALS", value = 46 },
LEFTBRACKET = { name = "LEFTBRACKET", value = 47 },
RIGHTBRACKET = { name = "RIGHTBRACKET", value = 48 },
BACKSLASH = { name = "BACKSLASH", value = 49 },
NONUSHASH = { name = "NONUSHASH", value = 50 },
SEMICOLON = { name = "SEMICOLON", value = 51 },
APOSTROPHE = { name = "APOSTROPHE", value = 52 },
GRAVE = { name = "GRAVE", value = 53 },
COMMA = { name = "COMMA", value = 54 },
PERIOD = { name = "PERIOD", value = 55 },
SLASH = { name = "SLASH", value = 56 },
CAPSLOCK = { name = "CAPSLOCK", value = 57 },
F1 = { name = "F1", value = 58 },
F2 = { name = "F2", value = 59 },
F3 = { name = "F3", value = 60 },
F4 = { name = "F4", value = 61 },
F5 = { name = "F5", value = 62 },
F6 = { name = "F6", value = 63 },
F7 = { name = "F7", value = 64 },
F8 = { name = "F8", value = 65 },
F9 = { name = "F9", value = 66 },
F10 = { name = "F10", value = 67 },
F11 = { name = "F11", value = 68 },
F12 = { name = "F12", value = 69 },
PRINTSCREEN = { name = "PRINTSCREEN", value = 70 },
SCROLLLOCK = { name = "SCROLLLOCK", value = 71 },
PAUSE = { name = "PAUSE", value = 72 },
INSERT = { name = "INSERT", value = 73 },
HOME = { name = "HOME", value = 74 },
PAGEUP = { name = "PAGEUP", value = 75 },
DELETE = { name = "DELETE", value = 76 },
END = { name = "END", value = 77 },
PAGEDOWN = { name = "PAGEDOWN", value = 78 },
RIGHT = { name = "RIGHT", value = 79 },
LEFT = { name = "LEFT", value = 80 },
DOWN = { name = "DOWN", value = 81 },
UP = { name = "UP", value = 82 },
NUMLOCKCLEAR = { name = "NUMLOCKCLEAR", value = 83 },
KP_DIVIDE = { name = "KP_DIVIDE", value = 84 },
KP_MULTIPLY = { name = "KP_MULTIPLY", value = 85 },
KP_MINUS = { name = "KP_MINUS", value = 86 },
KP_PLUS = { name = "KP_PLUS", value = 87 },
KP_ENTER = { name = "KP_ENTER", value = 88 },
KP_1 = { name = "KP_1", value = 89 },
KP_2 = { name = "KP_2", value = 90 },
KP_3 = { name = "KP_3", value = 91 },
KP_4 = { name = "KP_4", value = 92 },
KP_5 = { name = "KP_5", value = 93 },
KP_6 = { name = "KP_6", value = 94 },
KP_7 = { name = "KP_7", value = 95 },
KP_8 = { name = "KP_8", value = 96 },
KP_9 = { name = "KP_9", value = 97 },
KP_0 = { name = "KP_0", value = 98 },
KP_PERIOD = { name = "KP_PERIOD", value = 99 },
NONUSBACKSLASH = { name = "NONUSBACKSLASH", value = 100 },
APPLICATION = { name = "APPLICATION", value = 101 },
POWER = { name = "POWER", value = 102 },
KP_EQUALS = { name = "KP_EQUALS", value = 103 },
F13 = { name = "F13", value = 104 },
F14 = { name = "F14", value = 105 },
F15 = { name = "F15", value = 106 },
F16 = { name = "F16", value = 107 },
F17 = { name = "F17", value = 108 },
F18 = { name = "F18", value = 109 },
F19 = { name = "F19", value = 110 },
F20 = { name = "F20", value = 111 },
F21 = { name = "F21", value = 112 },
F22 = { name = "F22", value = 113 },
F23 = { name = "F23", value = 114 },
F24 = { name = "F24", value = 115 },
EXECUTE = { name = "EXECUTE", value = 116 },
HELP = { name = "HELP", value = 117 },
MENU = { name = "MENU", value = 118 },
SELECT = { name = "SELECT", value = 119 },
STOP = { name = "STOP", value = 120 },
AGAIN = { name = "AGAIN", value = 121 },
UNDO = { name = "UNDO", value = 122 },
CUT = { name = "CUT", value = 123 },
COPY = { name = "COPY", value = 124 },
PASTE = { name = "PASTE", value = 125 },
FIND = { name = "FIND", value = 126 },
MUTE = { name = "MUTE", value = 127 },
VOLUMEUP = { name = "VOLUMEUP", value = 128 },
VOLUMEDOWN = { name = "VOLUMEDOWN", value = 129 },
KP_COMMA = { name = "KP_COMMA", value = 133 },
KP_EQUALSAS400 = { name = "KP_EQUALSAS400", value = 134 },
INTERNATIONAL1 = { name = "INTERNATIONAL1", value = 135 },
INTERNATIONAL2 = { name = "INTERNATIONAL2", value = 136 },
INTERNATIONAL3 = { name = "INTERNATIONAL3", value = 137 },
INTERNATIONAL4 = { name = "INTERNATIONAL4", value = 138 },
INTERNATIONAL5 = { name = "INTERNATIONAL5", value = 139 },
INTERNATIONAL6 = { name = "INTERNATIONAL6", value = 140 },
INTERNATIONAL7 = { name = "INTERNATIONAL7", value = 141 },
INTERNATIONAL8 = { name = "INTERNATIONAL8", value = 142 },
INTERNATIONAL9 = { name = "INTERNATIONAL9", value = 143 },
LANG1 = { name = "LANG1", value = 144 },
LANG2 = { name = "LANG2", value = 145 },
LANG3 = { name = "LANG3", value = 146 },
LANG4 = { name = "LANG4", value = 147 },
LANG5 = { name = "LANG5", value = 148 },
LANG6 = { name = "LANG6", value = 149 },
LANG7 = { name = "LANG7", value = 150 },
LANG8 = { name = "LANG8", value = 151 },
LANG9 = { name = "LANG9", value = 152 },
ALTERASE = { name = "ALTERASE", value = 153 },
SYSREQ = { name = "SYSREQ", value = 154 },
CANCEL = { name = "CANCEL", value = 155 },
CLEAR = { name = "CLEAR", value = 156 },
PRIOR = { name = "PRIOR", value = 157 },
RETURN2 = { name = "RETURN2", value = 158 },
SEPARATOR = { name = "SEPARATOR", value = 159 },
OUT = { name = "OUT", value = 160 },
OPER = { name = "OPER", value = 161 },
CLEARAGAIN = { name = "CLEARAGAIN", value = 162 },
CRSEL = { name = "CRSEL", value = 163 },
EXSEL = { name = "EXSEL", value = 164 },
KP_00 = { name = "KP_00", value = 176 },
KP_000 = { name = "KP_000", value = 177 },
THOUSANDSSEPARATOR = { name = "THOUSANDSSEPARATOR", value = 178 },
DECIMALSEPARATOR = { name = "DECIMALSEPARATOR", value = 179 },
CURRENCYUNIT = { name = "CURRENCYUNIT", value = 180 },
CURRENCYSUBUNIT = { name = "CURRENCYSUBUNIT", value = 181 },
KP_LEFTPAREN = { name = "KP_LEFTPAREN", value = 182 },
KP_RIGHTPAREN = { name = "KP_RIGHTPAREN", value = 183 },
KP_LEFTBRACE = { name = "KP_LEFTBRACE", value = 184 },
KP_RIGHTBRACE = { name = "KP_RIGHTBRACE", value = 185 },
KP_TAB = { name = "KP_TAB", value = 186 },
KP_BACKSPACE = { name = "KP_BACKSPACE", value = 187 },
KP_A = { name = "KP_A", value = 188 },
KP_B = { name = "KP_B", value = 189 },
KP_C = { name = "KP_C", value = 190 },
KP_D = { name = "KP_D", value = 191 },
KP_E = { name = "KP_E", value = 192 },
KP_F = { name = "KP_F", value = 193 },
KP_XOR = { name = "KP_XOR", value = 194 },
KP_POWER = { name = "KP_POWER", value = 195 },
KP_PERCENT = { name = "KP_PERCENT", value = 196 },
KP_LESS = { name = "KP_LESS", value = 197 },
KP_GREATER = { name = "KP_GREATER", value = 198 },
KP_AMPERSAND = { name = "KP_AMPERSAND", value = 199 },
KP_DBLAMPERSAND = { name = "KP_DBLAMPERSAND", value = 200 },
KP_VERTICALBAR = { name = "KP_VERTICALBAR", value = 201 },
KP_DBLVERTICALBAR = { name = "KP_DBLVERTICALBAR", value = 202 },
KP_COLON = { name = "KP_COLON", value = 203 },
KP_HASH = { name = "KP_HASH", value = 204 },
KP_SPACE = { name = "KP_SPACE", value = 205 },
KP_AT = { name = "KP_AT", value = 206 },
KP_EXCLAM = { name = "KP_EXCLAM", value = 207 },
KP_MEMSTORE = { name = "KP_MEMSTORE", value = 208 },
KP_MEMRECALL = { name = "KP_MEMRECALL", value = 209 },
KP_MEMCLEAR = { name = "KP_MEMCLEAR", value = 210 },
KP_MEMADD = { name = "KP_MEMADD", value = 211 },
KP_MEMSUBTRACT = { name = "KP_MEMSUBTRACT", value = 212 },
KP_MEMMULTIPLY = { name = "KP_MEMMULTIPLY", value = 213 },
KP_MEMDIVIDE = { name = "KP_MEMDIVIDE", value = 214 },
KP_PLUSMINUS = { name = "KP_PLUSMINUS", value = 215 },
KP_CLEAR = { name = "KP_CLEAR", value = 216 },
KP_CLEARENTRY = { name = "KP_CLEARENTRY", value = 217 },
KP_BINARY = { name = "KP_BINARY", value = 218 },
KP_OCTAL = { name = "KP_OCTAL", value = 219 },
KP_DECIMAL = { name = "KP_DECIMAL", value = 220 },
KP_HEXADECIMAL = { name = "KP_HEXADECIMAL", value = 221 },
LCTRL = { name = "LCTRL", value = 224 },
LSHIFT = { name = "LSHIFT", value = 225 },
LALT = { name = "LALT", value = 226 },
LGUI = { name = "LGUI", value = 227 },
RCTRL = { name = "RCTRL", value = 228 },
RSHIFT = { name = "RSHIFT", value = 229 },
RALT = { name = "RALT", value = 230 },
RGUI = { name = "RGUI", value = 231 },
MODE = { name = "MODE", value = 257 },
AUDIONEXT = { name = "AUDIONEXT", value = 258 },
AUDIOPREV = { name = "AUDIOPREV", value = 259 },
AUDIOSTOP = { name = "AUDIOSTOP", value = 260 },
AUDIOPLAY = { name = "AUDIOPLAY", value = 261 },
AUDIOMUTE = { name = "AUDIOMUTE", value = 262 },
MEDIASELECT = { name = "MEDIASELECT", value = 263 },
WWW = { name = "WWW", value = 264 },
MAIL = { name = "MAIL", value = 265 },
CALCULATOR = { name = "CALCULATOR", value = 266 },
COMPUTER = { name = "COMPUTER", value = 267 },
AC_SEARCH = { name = "AC_SEARCH", value = 268 },
AC_HOME = { name = "AC_HOME", value = 269 },
AC_BACK = { name = "AC_BACK", value = 270 },
AC_FORWARD = { name = "AC_FORWARD", value = 271 },
AC_STOP = { name = "AC_STOP", value = 272 },
AC_REFRESH = { name = "AC_REFRESH", value = 273 },
AC_BOOKMARKS = { name = "AC_BOOKMARKS", value = 274 },
BRIGHTNESSDOWN = { name = "BRIGHTNESSDOWN", value = 275 },
BRIGHTNESSUP = { name = "BRIGHTNESSUP", value = 276 },
DISPLAYSWITCH = { name = "DISPLAYSWITCH", value = 277 },
KBDILLUMTOGGLE = { name = "KBDILLUMTOGGLE", value = 278 },
KBDILLUMDOWN = { name = "KBDILLUMDOWN", value = 279 },
KBDILLUMUP = { name = "KBDILLUMUP", value = 280 },
EJECT = { name = "EJECT", value = 281 },
SLEEP = { name = "SLEEP", value = 282 },
APP1 = { name = "APP1", value = 283 },
APP2 = { name = "APP2", value = 284 },
AUDIOREWIND = { name = "AUDIOREWIND", value = 285 },
AUDIOFASTFORWARD = { name = "AUDIOFASTFORWARD", value = 286 }
}
MODIFIER = {
NONE = { name = "NONE", value = 0x0000 },
LSHIFT = { name = "LSHIFT", value = 0x0001 },
RSHIFT = { name = "RSHIFT", value = 0x0002 },
LCTRL = { name = "LCTRL", value = 0x0040 },
RCTRL = { name = "RCTRL", value = 0x0080 },
LALT = { name = "LALT", value = 0x0100 },
RALT = { name = "RALT", value = 0x0200 },
LGUI = { name = "LGUI", value = 0x0400 },
RGUI = { name = "RGUI", value = 0x0800 },
NUM = { name = "NUM", value = 0x1000 },
CAPS = { name = "CAPS", value = 0x2000 },
MODE = { name = "MODE", value = 0x4000 },
SCROLL = { name = "SCROLL", value = 0x8000 },
SHIFT = { name = "SHIFT", value = 0x0001 + 0x0002 },
CTRL = { name = "CTRL", value = 0x0040 + 0x0080 },
ALT = { name = "ALT", value = 0x0100 + 0x0200 },
GUI = { name = "GUI", value = 0x0400 + 0x0800 }
}
-- Controller codes are built from the engine's layout: SINGE_GAMEPAD_BASE plus
-- SINGE_GAMEPAD_STRIDE per controller; each axis takes SINGE_AXIS_STRIDE codes
-- (axis, negative direction, positive direction) and buttons follow at
-- SINGE_GAMEPAD_BUTTON_OFFSET in SDL's button order.
local function gamepadAxisCodes(axis, name, negative, positive)
local base = SINGE_GAMEPAD_BASE + axis * SINGE_AXIS_STRIDE
return {
{ name, base }, { name .. "_" .. negative, base + 1 }, { name .. "_" .. positive, base + 2 }
}
end
local gamepadCodes = {}
for _, entry in ipairs(gamepadAxisCodes(0, "AXIS_LEFT_X", "L", "R")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(1, "AXIS_LEFT_Y", "U", "D")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(2, "AXIS_RIGHT_X", "L", "R")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(3, "AXIS_RIGHT_Y", "U", "D")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(4, "AXIS_LEFT_TRIGGER", "N", "P")) do gamepadCodes[#gamepadCodes + 1] = entry end
for _, entry in ipairs(gamepadAxisCodes(5, "AXIS_RIGHT_TRIGGER", "N", "P")) do gamepadCodes[#gamepadCodes + 1] = entry end
local gamepadButtons = {
"BUTTON_A", "BUTTON_B", "BUTTON_X", "BUTTON_Y", "BUTTON_BACK", "BUTTON_GUIDE", "BUTTON_START",
"BUTTON_LEFT_STICK", "BUTTON_RIGHT_STICK", "BUTTON_LEFT_BUMPER", "BUTTON_RIGHT_BUMPER",
"DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT"
}
for index, name in ipairs(gamepadButtons) do
gamepadCodes[#gamepadCodes + 1] = { name, SINGE_GAMEPAD_BASE + SINGE_GAMEPAD_BUTTON_OFFSET + index - 1 }
end
GAMEPAD_0 = {}
for _, entry in ipairs(gamepadCodes) do
GAMEPAD_0[entry[1]] = { name = entry[1], value = entry[2] }
end
GAMEPAD_1 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE)
GAMEPAD_2 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE * 2)
GAMEPAD_3 = utilOffsetTable(GAMEPAD_0, SINGE_GAMEPAD_STRIDE * 3)
GAMEPAD_AXIS_LEFT_X = 0
GAMEPAD_AXIS_LEFT_Y = 1
GAMEPAD_AXIS_RIGHT_X = 2
GAMEPAD_AXIS_RIGHT_Y = 3
GAMEPAD_AXIS_LEFT_TRIGGER = 4
GAMEPAD_AXIS_RIGHT_TRIGGER = 5
-- Mouse codes: SINGE_MOUSE_BASE plus SINGE_MOUSE_STRIDE per mouse; buttons then wheel.
local mouseNames = { "BUTTON_LEFT", "BUTTON_RIGHT", "BUTTON_MIDDLE", "BUTTON_X1", "BUTTON_X2", "WHEEL_UP", "WHEEL_DOWN" }
MOUSE_0 = {}
for index, name in ipairs(mouseNames) do
MOUSE_0[name] = { name = name, value = SINGE_MOUSE_BASE + index - 1 }
end
MOUSE_1 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE)
MOUSE_2 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE * 2)
MOUSE_3 = utilOffsetTable(MOUSE_0, SINGE_MOUSE_STRIDE * 3)
-- Older names for engine functions that gained a setter.
if mouseSetEnabled ~= nil then
mouseEnable = function() mouseSetEnabled(true) end
mouseDisable = function() mouseSetEnabled(false) end
end
if singeSetPauseKeyEnabled ~= nil then
singeEnablePauseKey = function() singeSetPauseKeyEnabled(true) end
singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end
end
-- Hypseus Singe's name for the quit key setting, with the opposite sense: catching the
-- key means the engine stops quitting on it.
if singeSetQuitKeyEnabled ~= nil then
keyboardCatchQuit = function(catch) singeSetQuitKeyEnabled(not catch) end
end
-- Hypseus Singe Compatibility ------------------------------------------------
-- Names Hypseus Singe registers in its own engine and Singe answers from here.
-- Every one is guarded, so an engine that grows a real implementation wins.
-- Hypseus extension. Chooses an overlay resolution and hands it to
-- overlaySetResolution, which is all Hypseus's own call does: 1 is the disc's
-- video size, 3 is 360x240, 4 is the size given (ignored without one, as in
-- Hypseus) and anything else, 2 included, is half the disc on both axes. Singe's
-- discGetWidth and discGetHeight report the canvas size when there is no disc,
-- which is the space a disc-less game draws into, so no special case is needed.
local OVERLAY_PRESET_FULL = 1
local OVERLAY_PRESET_NONSQUARE = 3
local OVERLAY_PRESET_CUSTOM = 4
local OVERLAY_NONSQUARE_WIDTH = 360
local OVERLAY_NONSQUARE_HEIGHT = 240
local OVERLAY_HALF = 2
if setOverlaySize == nil and overlaySetResolution ~= nil then
function setOverlaySize(preset, width, height)
if preset == OVERLAY_PRESET_FULL then
overlaySetResolution(discGetWidth(), discGetHeight())
elseif preset == OVERLAY_PRESET_NONSQUARE then
overlaySetResolution(OVERLAY_NONSQUARE_WIDTH, OVERLAY_NONSQUARE_HEIGHT)
elseif preset == OVERLAY_PRESET_CUSTOM then
if width ~= nil and height ~= nil then
overlaySetResolution(width, height)
end
else
overlaySetResolution(discGetWidth() // OVERLAY_HALF, discGetHeight() // OVERLAY_HALF)
end
end
end
-- Hypseus extension. Hypseus registers one C function under two names and Singe
-- already has the other one, so this is a pure alias of overlaySetResolution.
if setOverlayResolution == nil and overlaySetResolution ~= nil then
setOverlayResolution = overlaySetResolution
end
-- Hypseus extension. True in Hypseus while it is rewriting a game's asset paths
-- for an -espath install. Singe's virtual file system reaches a game's files
-- wherever they were installed, so there is nothing to rewrite and false is the
-- right answer: the games that ask keep the MYDIR they already have.
if rewriteStatus == nil then
function rewriteStatus() return false end
end
-- Singe 3.00 moved the sprite handle to the first argument. Games written for
-- 2.10 can set SINGE_LEGACY_SPRITE_ARGS = true (or LEGACY_SPRITE_ARGS = true in
-- games.dat) to keep calling the old way.
if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil and not SINGE_LEGACY_SPRITE_WRAPPED then
-- Once only: a game may load this file more than once, and a second wrap would undo the first.
SINGE_LEGACY_SPRITE_WRAPPED = true
local newSpriteDraw = spriteDraw
spriteDraw = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteDraw(id, table.unpack(args))
end
local newSpriteLoop = spriteLoop
spriteLoop = function(loop, id) return newSpriteLoop(id, loop) end
local newSpriteQuality = spriteQuality
spriteQuality = function(smooth, id) return newSpriteQuality(id, smooth) end
local newSpriteRotate = spriteRotate
spriteRotate = function(angle, id) return newSpriteRotate(id, angle) end
local newSpriteRotateAndScale = spriteRotateAndScale
spriteRotateAndScale = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteRotateAndScale(id, table.unpack(args))
end
local newSpriteScale = spriteScale
spriteScale = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteScale(id, table.unpack(args))
end
local newSpriteSetFrame = spriteSetFrame
spriteSetFrame = function(frame, id) return newSpriteSetFrame(id, frame) end
-- Hypseus extensions ship in the old order too: (x, y, frame, spr),
-- (x, y, scale, frame, spr) or (x, y, scaleX, scaleY, frame, spr).
local newSpriteDrawFrame = spriteDrawFrame
spriteDrawFrame = function(...)
local args = { ... }
local id = table.remove(args)
local frame = table.remove(args)
return newSpriteDrawFrame(id, args[1], args[2], frame, table.unpack(args, 3, #args))
end
local newSpriteResetColorKey = spriteResetColorKey
spriteResetColorKey = function(enabled, id) return newSpriteResetColorKey(id, enabled) end
local newSpriteRotateFrame = spriteRotateFrame
spriteRotateFrame = function(degrees, frame, id) return newSpriteRotateFrame(id, degrees, frame) end
local newSpriteDrawRotatedFrame = spriteDrawRotatedFrame
spriteDrawRotatedFrame = function(...)
local args = { ... }
local id = table.remove(args)
return newSpriteDrawRotatedFrame(id, table.unpack(args))
end
-- spriteDrawGrid is the one Hypseus writes with the handle in the middle,
-- (x, y, spr, rectX, rectY, width, height), not on the end.
local newSpriteDrawGrid = spriteDrawGrid
spriteDrawGrid = function(x, y, id, ...)
return newSpriteDrawGrid(id, x, y, ...)
end
end
-- Hypseus names for calls Singe already has, and nothing more: each is a plain
-- alias, so it takes exactly the arguments the Singe call takes. They are set
-- after the legacy wrapping above, which means the six spriteAnim ones follow
-- SINGE_LEGACY_SPRITE_ARGS with the rest of the sprite family -- old order when
-- a game asked for it, handle first when it did not -- and a game never has two
-- argument orders in play at once.
if hypseusGetHeight == nil and singeGetHeight ~= nil then
hypseusGetHeight = singeGetHeight
end
if hypseusGetWidth == nil and singeGetWidth ~= nil then
hypseusGetWidth = singeGetWidth
end
if takeScreenshot == nil and singeScreenshot ~= nil then
takeScreenshot = singeScreenshot
end
-- Hypseus counts real mice apart from the ones it derives from a gamepad; Singe
-- has no derived mice, so mouseHowMany is already the real count.
if mouseHowManyReal == nil and mouseHowMany ~= nil then
mouseHowManyReal = mouseHowMany
end
if spriteAnimGetFrame == nil and spriteGetFrame ~= nil then
spriteAnimGetFrame = spriteGetFrame
end
if spriteAnimIsPlaying == nil and spriteIsPlaying ~= nil then
spriteAnimIsPlaying = spriteIsPlaying
end
if spriteAnimLoop == nil and spriteLoop ~= nil then
spriteAnimLoop = spriteLoop
end
if spriteAnimPause == nil and spritePause ~= nil then
spriteAnimPause = spritePause
end
if spriteAnimPlay == nil and spritePlay ~= nil then
spriteAnimPlay = spritePlay
end
if spriteAnimSetFrame == nil and spriteSetFrame ~= nil then
spriteAnimSetFrame = spriteSetFrame
end
if videoGetLanguageDescription ~= nil then
discGetLanguageDescription = videoGetLanguageDescription
end
-- Singe 1.xx Features -------------------------------------------------------
-- Make old random number calls still work
random = {}
random.new = math.random
-- Map old Daphne calls to Singe calls
if singeGetHeight ~= nil then
daphneGetHeight = singeGetHeight
daphneGetWidth = singeGetWidth
daphneScreenshot = singeScreenshot
end
-- Singe 2.10 Threaded Application Support ------------------------------------
if singeMain ~= nil then
local singeThread = coroutine.create(singeMain)
onOverlayUpdate = function()
local ok, err = coroutine.resume(singeThread)
if not ok then
error(err, 0)
end
if coroutine.status(singeThread) == "dead" then
singeQuit()
end
return OVERLAY_UPDATED
end
singeYield = coroutine.yield
end
-- ===== Online high scores =====
--
-- A thin layer over Singe/Master.singe so a game says scoreSubmit(1000) and nothing else. The
-- account, the server address and the player's name belong to the machine, not to the game, and
-- were set up once in the menu's Online Account tool.
--
-- Nothing here waits on the network. A score is written to a queue on disk and sent when there is
-- a connection, because a cabinet is often offline and a game must never stall on the wire to show
-- its own board. scoreUpdate() has to run once a frame for any of it to progress.
local masterLoaded = false
local scoreLowest = {} -- The boards this game ranks the lowest score first, by full name
local function scoreLoad()
if not masterLoaded then
-- Loaded on first use rather than at startup: a game that never posts a score should not
-- pay for the module or open the file.
dofile("Singe/Master.singe")
masterLoaded = true
end
end
local function scoreReady()
scoreLoad()
return masterSignedIn()
end
-- The id a game is known by, from its games.dat entry. A game without one can be played but not
-- ranked: there is nothing stable to key a board on, and inventing one here would make a different
-- board every time the file moved.
function scoreGameId()
return singeGetGameId()
end
-- The longest board name the service takes (BOARD_MAX in its lib/scores.lua).
local SCORE_BOARD_MAX = 32
-- A board name in the form the service takes: lower case, and letters, digits, '-', '_' and '.',
-- anything else made '-'.
local function scoreBoardClean(text)
local clean = tostring(text):lower():gsub("[^%w%-_%.]", "-")
return clean
end
-- The board a score goes on. Every title in a download shares the download's GAME_ID, so boards are
-- kept apart by the script that is running: its file name without the extension is the board when
-- the game names none, and comes before the name the game gives when it does, so "hard" from
-- Raid.singe is raid.hard and the hard modes of two games in one collection stay apart. A game
-- offered twice (two languages, say: one script, two games.dat entries) keeps one set of boards.
-- Past SCORE_BOARD_MAX the script's part is shortened first, so the mode's name survives.
local function scoreBoardFor(board)
local script = singeGetScriptPath() or ""
local name = scoreBoardClean(((script:match("([^/\\]+)$") or script):gsub("%.[^.]*$", "")))
local mode = nil
if name == "" then
name = "default"
end
if (board == nil) or (board == "") then
return name:sub(1, SCORE_BOARD_MAX)
end
mode = scoreBoardClean(board):sub(1, SCORE_BOARD_MAX)
if #mode >= SCORE_BOARD_MAX - 1 then
return mode
end
return name:sub(1, SCORE_BOARD_MAX - #mode - 1) .. "." .. mode
end
-- Say that a board ranks the lowest score first: fewest strokes, deaths, or seconds. Called once,
-- before the board's first score, because a board's first score fixes its direction for good and the
-- service refuses a score sent the other way afterwards. board as scoreSubmit takes it.
function scoreLowestWins(board)
scoreLowest[scoreBoardFor(board)] = true
end
-- Say that a play is starting. Optional, and worth calling: the service then times the play by its
-- own clock, which is the one thing in a submission that has not been through this machine. A
-- cabinet with no connection simply records no time.
function scoreBegin()
local id = scoreGameId()
if not id or not scoreReady() then
return false
end
masterPlayStart(id, function() end)
return true
end
-- Queue a score. Returns false when the cabinet has no account or the game has no id, so a game
-- can decide whether to say anything about it.
function scoreSubmit(value, board, meta)
local id = scoreGameId()
local name = scoreBoardFor(board)
if not id or not scoreReady() then
return false
end
masterSubmitScore(id, value, name, meta, scoreLowest[name])
return true
end
-- The board, as a callback: onDone(ok, result) where result.top is the list, result.standing is
-- this player's place, and result.order is "lowest" or "highest", the way the board ranks. A cabinet
-- that is not signed in reads it too, for a game the service offers everyone, so a game can show what
-- signing in would post to; it has no standing.
function scoreBoard(onDone, board)
local id = scoreGameId()
local name = scoreBoardFor(board)
if not id then
onDone(false, "this game has no GAME_ID")
return false
end
scoreLoad()
masterBoard(id, name, onDone, scoreLowest[name])
return true
end
function scorePlayerName()
return scoreReady() and MASTER.name or nil
end
-- Whether this cabinet is signed in to an online account, so a game showing the boards can ask a
-- player to sign in when it is not.
function scoreSignedIn()
return scoreReady()
end
function scoreWaiting()
return masterLoaded and masterQueueLength() or 0
end
-- Once a frame, from onOverlayUpdate. Safe to call in a game that never posts anything.
function scoreUpdate()
if masterLoaded then
masterPump()
end
end

BIN
assets/FreeSansBold.ttf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,522 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The master service, from the cabinet's side.
--
-- One place that knows the server address, the signed-in account and the player's name, so the menu
-- and every game agree about them. It lives in the data root rather than a game's own directory
-- because an account belongs to the machine: a game posting a score needs the same token the menu
-- signed in with.
--
-- Everything here is asynchronous -- see Singe/Net.singe for why -- so every call takes a function
-- and calls it back. masterPump() must run once a frame for any of it to make progress.
dofile("Singe/Net.singe")
local cjson = require("cjson")
MASTER_DEFAULT_URL = "https://master.singeengine.com"
MASTER = {
url = MASTER_DEFAULT_URL,
token = nil,
email = nil,
name = nil,
queue = {}, -- scores waiting for a connection
}
local settingsPath = nil
local flushing = false
local lastFlush = 0
local flushDue = false
local FLUSH_EVERY = 30000
-- master.dat holds a session token and an email address, so it is not kept as plain text. A cabinet
-- signs in unattended, so whatever reads the file back is on the machine too: this keeps the token
-- and the address from being read at a glance or copied out of a text editor, and an edited file
-- reads as no account at all, but it is no protection against someone with the file and this source.
-- The file is SEAL_MAGIC, a random nonce, a tag, and the JSON XORed with a keystream: SHA-256 of the
-- key, the nonce, and a block counter. The tag is SHA-256 of the key, the nonce, and the result.
local SEAL_MAGIC = "SMD1"
local SEAL_KEY = "Singe master.dat" -- Not a secret: see above
local SEAL_NONCE = 16 -- Bytes
local SEAL_TAG = 16 -- Bytes
local SEAL_BLOCK = 32 -- Bytes of keystream a SHA-256 gives
local function path()
if not settingsPath then
settingsPath = singeGetSystemInfo().dataRoot .. "master.dat"
end
return settingsPath
end
-- SHA-256 as bytes rather than hex.
local function sealDigest(data)
return (utilSha256(data):gsub("%x%x", function(pair) return string.char(tonumber(pair, 16)) end))
end
-- text XORed with the keystream for nonce: the same call seals and unseals.
local function sealStream(nonce, text)
local out = {}
for block = 0, (#text - 1) // SEAL_BLOCK do
local key = sealDigest(SEAL_KEY .. nonce .. string.pack(">I4", block))
local piece = text:sub(block * SEAL_BLOCK + 1, (block + 1) * SEAL_BLOCK)
local bytes = {}
for i = 1, #piece do
bytes[i] = string.char(piece:byte(i) ~ key:byte(i))
end
out[#out + 1] = table.concat(bytes)
end
return table.concat(out)
end
local function sealTag(nonce, sealed)
return sealDigest(SEAL_KEY .. "tag" .. nonce .. sealed):sub(1, SEAL_TAG)
end
-- text as master.dat keeps it, with a fresh nonce each time.
local function seal(text)
local nonce = sealDigest(tostring(os.time()) .. os.clock() .. tostring({}) .. math.random()):sub(1, SEAL_NONCE)
local sealed = sealStream(nonce, text)
return SEAL_MAGIC .. nonce .. sealTag(nonce, sealed) .. sealed
end
-- The text master.dat holds: unsealed, or as it is when it is still plain JSON (from before files
-- were sealed); nil when the tag does not match.
local function unseal(data)
local nonce = data:sub(#SEAL_MAGIC + 1, #SEAL_MAGIC + SEAL_NONCE)
local tag = data:sub(#SEAL_MAGIC + SEAL_NONCE + 1, #SEAL_MAGIC + SEAL_NONCE + SEAL_TAG)
local sealed = data:sub(#SEAL_MAGIC + SEAL_NONCE + SEAL_TAG + 1)
if data:sub(1, #SEAL_MAGIC) ~= SEAL_MAGIC then
return data
end
if (#nonce ~= SEAL_NONCE) or (sealTag(nonce, sealed) ~= tag) then
return nil
end
return sealStream(nonce, sealed)
end
function masterLoad()
local file = io.open(path(), "rb")
local data = nil
local text = nil
local ok = false
local stored = nil
if not file then
return
end
data = file:read("a")
file:close()
text = unseal(data)
if text == nil then
return
end
ok, stored = pcall(cjson.decode, text)
if ok and type(stored) == "table" then
MASTER.url = stored.url or MASTER_DEFAULT_URL
MASTER.token = stored.token
MASTER.email = stored.email
MASTER.name = stored.name
MASTER.queue = stored.queue or {}
-- A file from before sealing is sealed as soon as it is read.
if text == data then
masterSave()
end
end
end
function masterSave()
local file = io.open(path(), "wb")
if not file then
return false
end
file:write(seal(cjson.encode({ url = MASTER.url, token = MASTER.token, email = MASTER.email,
name = MASTER.name, queue = MASTER.queue })))
file:close()
return true
end
function masterSignedIn()
return MASTER.token ~= nil
end
-- Every call goes through here so the token, the content type and the JSON decoding are in one
-- place, and so a 401 always means the same thing: whatever we were holding is no longer a
-- session, and pretending otherwise only produces a second failure later.
local function request(method, endpoint, body, onDone, opts)
opts = opts or {}
local headers = { ["Content-Type"] = "application/json" }
if MASTER.token and not opts.anonymous then
headers["Authorization"] = "Bearer " .. MASTER.token
end
if opts.bodyFile then
headers["Content-Type"] = opts.contentType or "application/octet-stream"
end
netRequest({
url = MASTER.url .. endpoint, method = method, headers = headers,
body = body and cjson.encode(body) or nil, bodyFile = opts.bodyFile,
toFile = opts.toFile, onProgress = opts.onProgress,
}, function(ok, result)
if not ok then
return onDone(false, result.error)
end
local parsed = {}
if result.body and result.body ~= "" then
local decoded, value = pcall(cjson.decode, result.body)
if decoded and type(value) == "table" then
parsed = value
end
end
if result.status == 401 and not opts.anonymous then
MASTER.token = nil
masterSave()
return onDone(false, "signed out by the server", parsed, result)
end
if result.status >= 400 then
return onDone(false, parsed.message or ("the server said " .. result.status), parsed, result)
end
onDone(true, parsed, parsed, result)
end)
end
MASTER_REQUEST = request
function masterSignIn(email, password, onDone)
request("POST", "/v1/account/login", { email = email, password = password }, function(ok, reply)
if not ok then
return onDone(false, reply)
end
MASTER.token = reply.token
MASTER.email = reply.email
masterSave()
-- The display name is not part of signing in, so it is fetched once and kept: a game
-- about to post a score should not have to discover it has none mid-play.
masterWhoAmI(function() end)
onDone(true, reply)
end, { anonymous = true })
end
function masterRegister(email, password, onDone)
request("POST", "/v1/account/register", { email = email, password = password }, onDone, { anonymous = true })
end
function masterRecover(email, onDone)
request("POST", "/v1/account/recover", { email = email }, onDone, { anonymous = true })
end
function masterSignOut(onDone)
local had = MASTER.token
MASTER.token = nil
MASTER.email = nil
MASTER.name = nil
masterSave()
if had then
-- Told after the fact: the cabinet is signed out either way, and a server that cannot be
-- reached must not leave somebody looking signed in.
MASTER.token = had
request("POST", "/v1/account/logout", nil, function() end)
MASTER.token = nil
end
if onDone then onDone(true) end
end
function masterWhoAmI(onDone)
request("GET", "/v1/account", nil, function(ok, reply)
if ok then
MASTER.email = reply.email or MASTER.email
masterSave()
end
onDone(ok, reply)
end)
end
function masterSetName(name, onDone)
request("POST", "/v1/player/name", { name = name }, function(ok, reply)
if ok then
MASTER.name = reply.name
masterSave()
end
onDone(ok, reply)
end)
end
function masterCatalogue(onDone)
request("GET", "/v1/catalogue", nil, function(ok, reply)
if ok then
for _, game in ipairs(reply.games or {}) do
game.version = game.version and math.floor(game.version) or nil
game.installed = game.installed and math.floor(game.installed) or nil
game.size = game.size and math.floor(game.size) or nil
-- Every title the download holds, in its games.dat order.
for _, entry in ipairs(game.entries or {}) do
entry.entry = entry.entry and math.floor(entry.entry) or nil
entry.year = entry.year and math.floor(entry.year) or nil
entry.tier = entry.tier and math.floor(entry.tier) or nil
end
end
end
onDone(ok, ok and reply.games or reply)
end)
end
-- Download a game to a file and check it against the digest the catalogue published. A download
-- that arrives wrong is worse than one that fails: it installs, and then does not run.
function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
-- JSON has one number type and Lua 5.4 has two, so a version that went out as 1 comes back as
-- 1.0 and would build a path ending "/1.0" that the service does not recognize.
local path = string.format("/v1/download/%s/%d", gameId, math.floor(version))
request("GET", path, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress })
end
-- What this machine holds, and what is newer. The list is the menu's own, read off every
-- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which
-- of these have a newer version and which it has never heard of. No sign-in needed to ask.
function masterVersions(held, onDone)
local games = {}
for _, entry in ipairs(held) do
games[#games + 1] = { id = entry.id, version = math.floor(entry.version or 0) }
end
request("POST", "/v1/versions", { games = games }, function(ok, reply)
if ok then
for _, update in ipairs(reply.updates or {}) do
update.installed = update.installed and math.floor(update.installed) or 0
update.latest = update.latest and math.floor(update.latest) or nil
update.size = update.size and math.floor(update.size) or nil
end
end
onDone(ok, ok and (reply.updates or {}) or reply, ok and (reply.unknown or {}) or nil)
end)
end
-- Send a packed game up for review. The file goes as the body, from disk, so a big game is not
-- held in memory; onProgress(sent, total) says how it goes, and onDone(ok, reply) ends with the
-- version number the service gave it and the state it starts in, which is "pending".
function masterUpload(file, notes, onProgress, onDone)
local endpoint = "/v1/upload"
if notes and notes ~= "" then
endpoint = endpoint .. "?notes=" .. notes:gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", c:byte())
end)
end
request("POST", endpoint, nil, function(ok, reply)
if ok and reply.version then
reply.version = math.floor(reply.version)
end
onDone(ok, reply)
end, { bodyFile = file, onProgress = onProgress })
end
-- Is there a newer Singe for this machine? Public, no sign-in: the answer is the newest release
-- for the platform when it is newer than the version running, or newer = false.
function masterRelease(platform, running, onDone)
request("GET", "/v1/release/" .. platform .. "?version=" .. tostring(running), nil, function(ok, reply)
onDone(ok, reply)
end, { anonymous = true })
end
-- Fetch one release to a file, checked against the digest the service published, exactly as a
-- game is; a binary that arrives wrong must not be put beside the one that works.
function masterDownloadRelease(platform, version, toFile, expectSha, onProgress, onDone)
request("GET", "/v1/release/" .. platform .. "/" .. version, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress, anonymous = true })
end
-- This account's own games, every version with the review's word on it.
function masterMine(onDone)
request("GET", "/v1/catalogue/mine", nil, function(ok, reply)
onDone(ok, ok and (reply.games or {}) or reply)
end)
end
-- One board. lowest asks for a board that ranks the lowest score first; a board keeps the direction
-- its first score fixed, and the reply's order says which it is.
function masterBoard(gameId, board, onDone, lowest)
local query = {}
if board then
query[#query + 1] = "board=" .. board
end
if lowest then
query[#query + 1] = "order=lowest"
end
request("GET", "/v1/scores/" .. gameId .. (#query > 0 and ("?" .. table.concat(query, "&")) or ""), nil, function(ok, reply)
-- JSON has one number type and Lua 5.4 has two, so a score that went out as 4242 comes back
-- as 4242.0 and would be shown to a player with a decimal point on it.
if ok then
for _, row in ipairs(reply.top or {}) do
row.value = math.floor(row.value)
row.at = row.at and math.floor(row.at) or nil
end
if reply.standing then
reply.standing.value = math.floor(reply.standing.value)
reply.standing.rank = math.floor(reply.standing.rank)
reply.standing.players = math.floor(reply.standing.players)
end
end
onDone(ok, reply)
end)
end
-- Tell the service a play is starting. What comes back travels with the score, so the server can
-- say how long the play took by its own clock; a cabinet that is offline simply has no play id and
-- the score carries no time, which is honest rather than broken.
function masterPlayStart(gameId, onDone)
if not masterSignedIn() then
MASTER.play = nil
if onDone then onDone(false, "not signed in") end
return
end
request("POST", "/v1/play/start", { game = gameId }, function(ok, reply)
MASTER.play = ok and reply.play and math.floor(reply.play) or nil
if onDone then onDone(ok, reply) end
end)
end
-- A score is queued first and sent afterwards. A cabinet is often offline, and a game must never
-- wait on the wire to show its own board; the queue is written to disk so a power cut between the
-- play and the send does not lose it. lowest sends it to a board that ranks the lowest first; one
-- queued without it (as every score was before boards had a direction) goes the highest first.
function masterSubmitScore(gameId, value, board, meta, lowest)
MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value),
board = board or "default", meta = meta,
play = MASTER.play, order = lowest and "lowest" or nil }
masterSave()
-- Send it on the next pump rather than waiting out the retry interval: a player who has just
-- finished wants to see themselves on the board, and the interval exists for retrying what
-- failed, not for delaying what has not been tried. This is a flag rather than a timestamp:
-- singeGetTicks() counts from engine start, so "lastFlush = 0" would mean "once the engine has
-- been up FLUSH_EVERY", which early in a run is never.
flushDue = true
end
function masterQueueLength()
return #MASTER.queue
end
-- Hand the queue to the server one at a time. A refusal that is the score's own fault (the game is
-- unknown, the value is nonsense, the board ranks the other way) drops it, because retrying it
-- forever would block everything behind it; anything else leaves it in place to try again.
local function flushQueue()
if flushing or #MASTER.queue == 0 or not MASTER.token then
return
end
flushing = true
local entry = MASTER.queue[1]
request("POST", "/v1/scores/" .. entry.game, { value = entry.value, board = entry.board, meta = entry.meta, play = entry.play, order = entry.order },
function(ok, reply, parsed, result)
local status = result and result.status or 0
if ok or (status >= 400 and status < 500 and status ~= 429) then
table.remove(MASTER.queue, 1)
masterSave()
-- A backlog should drain, not trickle out one per interval.
if #MASTER.queue > 0 then
flushDue = true
end
end
flushing = false
end)
end
function masterPump()
netPump()
if flushDue or (singeGetTicks() - lastFlush > FLUSH_EVERY) then
flushDue = false
lastFlush = singeGetTicks()
flushQueue()
end
end
masterLoad()

View file

@ -1,50 +0,0 @@
<rml>
<!--
Singe 3
Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
The bundled game menu: the document Singe/Menu.singe fills and drives. Every region is placed
by the script (it owns the geometry so the attract video, drawn into the overlay underneath,
lands exactly in the #video hole); Singe/menu.rcss only says what the regions look like.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>Menu</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<link type="text/rcss" href="Singe/menu.rcss"/>
</head>
<body id="menu">
<div id="games">
<div id="list" class="list"></div>
<div id="poster" class="art"></div>
<div id="marquee" class="art"></div>
<div id="video"></div>
<div id="info" class="panel">
<div id="title"></div>
<div id="credits" class="line"></div>
<div id="developer" class="line"></div>
<div id="publisher" class="line"></div>
<div id="port" class="line"></div>
<div id="notice" class="line"></div>
<div id="description"></div>
</div>
<div id="footer">
<div id="hints">Up / Down: select &nbsp; Left / Right: page &nbsp; PgUp / PgDn: details &nbsp; Start or button: play &nbsp; Service: tools</div>
</div>
</div>
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
rows and Singe/MenuDocument.singe turns them into the divs below it -- the rows into the
body, the key hints into the footer, and on the list of tools the chosen tool's note into
the column beside the list. -->
<div id="tool" class="tool hidden">
<div id="toolCard" class="card">
<h1 id="toolTitle"></h1>
<div id="toolColumns" class="columns">
<div id="toolBody"></div>
<div id="toolNote" class="hidden"></div>
</div>
<div id="toolKeys"></div>
</div>
</div>
</body>
</rml>

View file

@ -1,644 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The bundled game menu. Everything the menu *is* lives here: the game list read from every
-- games.dat, the selection and how it moves, the attract video, launching a game and coming back,
-- and the service tools. Nothing here draws.
--
-- Drawing is a renderer, chosen once at startup by whether this machine has a GPU device:
--
-- Singe/MenuDocument.singe an RmlUi document, which needs the GPU device the 3D scene uses
-- Singe/MenuOverlay.singe sprites and fontPrint straight into the overlay, which needs nothing
--
-- The two draw the same menu and take the same keys. A renderer provides the MENU_RENDER table;
-- see the top of either file for what is in it.
dofile("Singe/Framework.singe")
dofile("Singe/Master.singe")
dofile("Singe/Tools.singe")
local lfs = require("lfs")
function cleanTitle(a)
local output = string.lower(a)
output = string.gsub(output, '%p', ' ')
output = string.gsub(output, '^ +', '')
output = string.gsub(output, '^the ', '')
output = string.gsub(output, '^a ', '')
output = string.gsub(output, '^an ', '')
return output
end
function compareTitles(a, b)
return cleanTitle(a.TITLE) < cleanTitle(b.TITLE)
end
-- What the page shows when there is nothing to choose: the way to get something.
MENU_EMPTY = {
EMPTY = true,
TITLE = "No games yet",
DESCRIPTION = "Press SERVICE for the service tools. Sign in under Online Account, then choose " ..
"what to fetch in Get Games. What you fetch is listed here as soon as you leave the tools.",
}
-- Makes a game the selection, wrapping at either end of the list, and shows it. An empty library
-- has no selection, only MENU_EMPTY.
function gameSelect(index)
unloadGameAssets()
if GAME_COUNT == 0 then
GAME_SELECTED = nil
MENU_RENDER.showGame(MENU_EMPTY)
menuRefreshNotice()
return
end
if index < 1 then
index = GAME_COUNT
elseif index > GAME_COUNT then
index = 1
end
GAME_SELECTED = index
loadGameAssets()
end
-- Starts the selected game. The menu comes back from the top when it ends. A game this machine
-- cannot run says why instead of failing part way in.
function gameStart()
local game = GAME_LIST[GAME_SELECTED]
if game.CANNOT_RUN then
menuNotice(game.MACHINE_FIT)
return
end
SHUTDOWN_FROM_PUSH = true
scriptPush(game)
end
function loadGameAssets()
local game = GAME_LIST[GAME_SELECTED]
-- An entry without an attract clip, or naming one that is not there, shows its art and no
-- video rather than taking the whole menu down with a videoLoad that cannot open it.
if menuFileExists(game.ATTRACT) then
VIDEO_ATTRACT = videoLoad(game.ATTRACT)
if game.AUDIO_TRACK then
videoSetAudioTrack(VIDEO_ATTRACT, game.AUDIO_TRACK)
end
videoPlay(VIDEO_ATTRACT)
videoSeek(VIDEO_ATTRACT, game.ATTRACT_START or 0)
else
debugPrint("Menu: " .. tostring(game.TITLE) .. " has no attract clip to play.")
end
MENU_RENDER.showGame(game)
menuRefreshNotice()
end
-- Where a game's art should be when it is not there. Extracted with the other support files, so
-- it is always present whatever a game does or does not ship.
MENU_MISSING_ART = "Singe/missing.png"
-- Whether a name is a file the engine can open. Asked of the index rather than of the file:
-- io.open, which this used to use, has to hand Lua a real file it can read with the C library, so
-- every picture it was asked about was unpacked out of the .game into data/<game>/cache first --
-- and the menu asks about two of them for every game in the library, none of which it then opens
-- that way: the sprite loader and the document's texture loader both read straight out of the
-- database. lfs.attributes goes through the same virtual filesystem and answers from the index,
-- opening nothing.
function menuFileExists(file)
local path = tostring(file or "")
local found = (path ~= "") and lfs.attributes(path) or nil
return (found ~= nil) and (found.mode ~= "directory")
end
-- A readable art path: the game's own when it can be opened, the missing-art picture when not.
function menuArtPath(file)
local path = tostring(file or "")
if not menuFileExists(path) then
if path ~= "" then
debugPrint("Menu: cannot read " .. path .. "; showing the missing-art picture.")
end
return MENU_MISSING_ART
end
return path
end
-- How far left and right move. A page is a screenful, which is what the renderer answers, but in a
-- library smaller than a screenful every press then lands on the first game or the last and nothing
-- in between can be reached that way at all. A third of the library is never more than three
-- presses from one end to the other, whatever size it is.
function pageStep()
return math.max(1, math.min(MENU_RENDER.pageSize(), math.floor(GAME_COUNT / 3)))
end
-- The game page takes input once the backdrop has finished the intro and settled on the grid.
function menuActive()
return (not toolsActive()) and MENU_RENDER.backdropReady()
end
-- In MODE_FULL every key arrives here as its keysym rather than as a switch, which is what the
-- account tool's typing needs; the switches it still cares about are the pad's, which keep working.
function onKeyPressed(keysym, scancode)
if toolsActive() then
toolsTyped(keysym)
end
end
function onInputPressed(what)
-- The service tools take every switch while they are up, including the one that closes them.
if toolsActive() then
toolsInput(what)
if MENU_LIBRARY_CHANGED and not toolsActive() then
menuRescanGames()
end
return
end
-- Are we displaying the grid background? With no games, SERVICE is all there is to press.
if menuActive() then
if what == SWITCH_SERVICE then
toolsBegin()
elseif GAME_COUNT == 0 then
return
elseif what == SWITCH_UP then
gameSelect(GAME_SELECTED - 1)
elseif what == SWITCH_DOWN then
gameSelect(GAME_SELECTED + 1)
elseif what == SWITCH_LEFT then
-- A page back, wrapping to the end from the first game.
if GAME_SELECTED == 1 then
gameSelect(GAME_COUNT)
else
gameSelect(math.max(1, GAME_SELECTED - pageStep()))
end
elseif what == SWITCH_RIGHT then
if GAME_SELECTED == GAME_COUNT then
gameSelect(1)
else
gameSelect(math.min(GAME_COUNT, GAME_SELECTED + pageStep()))
end
elseif what == SWITCH_BUTTON3 and menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
menuUpdateGame()
elseif what == SWITCH_START1 or what == SWITCH_START2 or what == SWITCH_BUTTON1 or what == SWITCH_BUTTON2 or what == SWITCH_BUTTON3 or what == SWITCH_BUTTON4 then
gameStart()
end
end
end
function onOverlayUpdate()
-- The intro, then the grid behind the menu, and the overlay cleared ready to be drawn into.
MENU_RENDER.backdrop()
-- The network never blocks the frame; it makes progress here instead.
masterPump()
if toolsActive() then
toolsUpdate()
MENU_RENDER.tools()
return(OVERLAY_UPDATED)
end
-- Are we displaying the grid background?
if menuActive() then
-- The page keys take the details over from the walk they do by themselves; the walk picks
-- up from wherever they leave it once its pause is up. They arrive as a scancode rather
-- than a switch because they are not one: a cabinet has no page keys and loses nothing.
local key = keyboardGetLastDown()
if key == SCANCODE.PAGEDOWN.value then
MENU_RENDER.scrollDetails(1)
elseif key == SCANCODE.PAGEUP.value then
MENU_RENDER.scrollDetails(-1)
end
-- The attract clip runs between the two frames the game's entry names.
if VIDEO_ATTRACT and GAME_LIST[GAME_SELECTED].ATTRACT_END then
if videoGetFrame(VIDEO_ATTRACT) > GAME_LIST[GAME_SELECTED].ATTRACT_END then
videoSeek(VIDEO_ATTRACT, GAME_LIST[GAME_SELECTED].ATTRACT_START or 0)
end
end
MENU_RENDER.frame()
end
return(OVERLAY_UPDATED)
end
function onShutdown()
unloadGameAssets()
saveConfig(not SHUTDOWN_FROM_PUSH)
MENU_RENDER.finish()
if SND_INTRO_VOICE ~= SOUND_ERROR_INVALID then
-- A game started before the intro's sound had finished; it wants the speakers to itself.
soundStop(SND_INTRO_VOICE)
end
if SND_INTRO then
soundUnload(SND_INTRO)
end
if SND_CLICK then
soundUnload(SND_CLICK)
end
end
function saveConfig(showIntro)
if GAME_COUNT > 0 then
-- Save what game we're currently viewing
local cfg = io.open(CONFIG_FILE, "w")
if cfg then
cfg:write("GAME_SELECTED = " .. GAME_SELECTED .. "\n")
cfg:write("SHOW_INTRO = " .. tostring(showIntro) .. "\n")
-- The file is written whole, so a renderer forced here has to be written back or it
-- would last exactly one run.
if MENU_OVERLAY then
cfg:write("MENU_OVERLAY = true\n")
end
cfg:close()
else
debugPrint("Unable to write " .. CONFIG_FILE)
end
end
end
function unloadGameAssets()
-- Nothing is loaded when there are no games.
if VIDEO_ATTRACT then
videoUnload(VIDEO_ATTRACT)
VIDEO_ATTRACT = nil
end
MENU_RENDER.hideGame()
end
-- What this machine can run (PLAN.md section 57), asked once, and what each capability is called
-- when a game needs one the machine lacks.
local MACHINE = singeCapabilities()
local CAPABILITY_WORDS = { SCENE = "3D", GUI = "a GPU for its screens", SHADOWS = "shadows", POST = "post effects", SKIN = "skinned models", MORPH = "morph targets" }
-- How a game suits this machine: a line for the notice, or nil when it simply runs, and whether it
-- cannot run here at all. The list annotates; it does not hide. A game missing from a list gets
-- asked about, and one that says why it will not run does not. The Get Games tool asks it of the
-- catalogue's entries too, which carry the same REQUIRES and TIER.
function menuMachineFit(game)
local missing = {}
for _, name in ipairs(type(game.REQUIRES) == "table" and game.REQUIRES or {}) do
if not MACHINE[name] then
missing[#missing + 1] = CAPABILITY_WORDS[name] or name
end
end
if #missing > 0 then
return "Needs " .. table.concat(missing, " and ") .. ", and this machine has none.", true
end
if MACHINE.tier and ((tonumber(game.TIER) or 0) > MACHINE.tier) then
return "Wants a faster machine than this one. It may run slowly.", false
end
return nil, false
end
-- Adds the entries of one games.dat. container is the game database the file came from, or nil.
local function loadGamesDat(source, container)
GAMES = {}
local ok, err = pcall(dofile, source)
if not ok then
debugPrint(source .. ": " .. tostring(err) .. " Skipped.")
GAMES = {}
return
end
for _, value in pairs(GAMES or {}) do
if container then
-- Packed game: the engine opens its files through the database, and so does the menu.
value.CONTAINER = container
for _, key in ipairs({ "CABINET", "MARQUEE", "ATTRACT" }) do
if value[key] and value[key]:sub(1, 6):lower() ~= "singe/" then
value[key] = container .. "/" .. value[key]
end
end
end
value.MACHINE_FIT, value.CANNOT_RUN = menuMachineFit(value)
table.insert(GAME_LIST, value)
GAME_COUNT = GAME_COUNT + 1
end
GAMES = {}
end
-- Search for games.dat files in subdirectories and inside .game databases, starting the list over.
--
-- The extension is tested before the mode, and that order matters: the engine presents a packed
-- game through the VFS, so lfs.attributes reports a .game as a *directory*, not a file. Testing
-- the mode first sent every container down the loose-directory branch, which loads its games.dat
-- with no container -- and then every CABINET, MARQUEE and ATTRACT path in it named a file that
-- exists only inside the database. A packed library could not show a single piece of its art.
--
-- What this machine holds, by the id and version each games.dat carries, is rebuilt with it.
-- VERSION is stamped into a packed game by the service when it is published; a game without one
-- is version 0. MENU_VERSIONS is the only record of installed versions anywhere: the service
-- keeps none.
function menuScanGames()
GAME_LIST = {}
GAME_COUNT = 0
MENU_VERSIONS = {}
for dir in lfs.dir(".") do
if dir ~= "." and dir ~= ".." then
local dirattr = lfs.attributes(dir)
if dir:sub(-5):lower() == ".game" then
loadGamesDat(dir .. "/games.dat", dir)
elseif dirattr and (dirattr.mode == "directory") then
for file in lfs.dir(dir .. "/.") do
if file == "games.dat" then
loadGamesDat(dir .. "/games.dat", nil)
end
end
end
end
end
table.sort(GAME_LIST, compareTitles)
-- A download holding several titles lists each of them here, all with the one GAME_ID. The
-- highest VERSION any of them says is the download's, so a title that does not say one cannot
-- make an up to date copy look old.
for _, game in ipairs(GAME_LIST) do
if game.GAME_ID then
MENU_VERSIONS[game.GAME_ID] = math.max(MENU_VERSIONS[game.GAME_ID] or 0, math.floor(tonumber(game.VERSION) or 0))
end
end
end
-- One games.dat entry, told apart from every other: the titles of one download share its GAME_ID,
-- and a game offered twice shares its SCRIPT, so it takes all three.
local function entryKey(game)
return tostring(game.GAME_ID) .. "\n" .. tostring(game.SCRIPT) .. "\n" .. tostring(game.TITLE)
end
-- The Get Games tool added or removed a game: the list is read again once the tools close, and
-- the game that was selected stays selected when it is still here.
function menuRescanGames()
local was = GAME_SELECTED and GAME_LIST[GAME_SELECTED] or nil
local key = was and entryKey(was) or nil
local at = 1
MENU_LIBRARY_CHANGED = false
unloadGameAssets()
GAME_SELECTED = nil
menuScanGames()
for index, game in ipairs(GAME_LIST) do
if key and (entryKey(game) == key) then
at = index
break
end
end
MENU_RENDER.buildList()
gameSelect(at)
menuCheckVersions()
end
menuScanGames()
MENU_UPDATES = {}
-- Asks the service which of the games here have a newer version. No sign-in is needed to ask;
-- the answer arrives whenever it arrives and the renderers pick it up. Called once at startup
-- and again whenever the Get Games tool opens.
function menuCheckVersions()
local held = {}
for id, version in pairs(MENU_VERSIONS) do
held[#held + 1] = { id = id, version = version }
end
if #held == 0 then
return
end
masterVersions(held, function(ok, updates)
if not ok then
return
end
MENU_UPDATES = {}
for _, update in ipairs(updates) do
MENU_UPDATES[update.id] = update
end
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
end)
end
-- The newer version the service offers for a game, or nil.
function menuUpdateFor(game)
return game.GAME_ID and MENU_UPDATES[game.GAME_ID] or nil
end
-- Fetches the newer version of the selected game into place, saying how it goes where the
-- renderers show a notice. The download needs a sign-in, which the Online Account tool does.
function menuUpdateGame()
local game = GAME_LIST[GAME_SELECTED]
local update = menuUpdateFor(game)
if not update then
return false
end
if not masterSignedIn() then
menuNotice("Sign in first, in the Online Account tool (SERVICE), to fetch the update.")
return true
end
if MENU_UPDATING then
return true
end
MENU_UPDATING = true
shopInstall({ id = update.id, slug = update.slug, title = update.title or game.TITLE,
version = update.latest, sha256 = update.sha256, target = game.CONTAINER },
function(text)
menuNotice("Updating: " .. text)
end,
function(ok, text)
MENU_UPDATING = false
-- The list first, since refreshing it resets the notice to whatever the selection
-- warrants; then what happened, which stays until the selection moves.
if MENU_RENDER.refreshList then
MENU_RENDER.refreshList()
end
menuNotice(ok and (text .. " Version " .. update.latest .. " plays from now on.") or text)
end)
return true
end
-- One line the renderers show under the game, for whatever the menu has to say.
MENU_NOTICE = nil
function menuNotice(text)
MENU_NOTICE = text
if MENU_RENDER.notice then
MENU_RENDER.notice(text)
end
end
-- The notice for the selected game when nothing is in flight: how it suits this machine, or the
-- update on offer, or, failing that, the newer Singe on offer, or nothing. A download in progress keeps its own line.
function menuRefreshNotice()
if MENU_UPDATING then
return
end
local game = GAME_SELECTED and GAME_LIST[GAME_SELECTED] or nil
local update = game and menuUpdateFor(game) or nil
if game and game.MACHINE_FIT then
menuNotice(game.MACHINE_FIT)
elseif update then
menuNotice(string.format("Version %d is available. Button 3 fetches it.", update.latest))
elseif MENU_RELEASE then
menuNotice(string.format("Singe %s is available. SERVICE, then Engine Update.", MENU_RELEASE.version))
else
menuNotice(nil)
end
end
-- Once a day, unless the settings say never: is there a newer Singe for this machine? The
-- answer is only a line on screen and a service tool; nothing is fetched or changed until a
-- person opens that tool and says so. The last look is remembered in the save store.
MENU_RELEASE = nil
local RELEASE_CHECK_EVERY = 24 * 60 * 60
function menuCheckRelease(force)
if not force and SINGE_UPDATE_CHECK == false then
return
end
local info = singeGetSystemInfo()
local last = tonumber(saveGet("releaseCheckedAt", 0)) or 0
if not force and (os.time() - last) < RELEASE_CHECK_EVERY then
MENU_RELEASE = saveGet("releaseOffered", nil)
if type(MENU_RELEASE) ~= "table" then
MENU_RELEASE = nil
end
menuRefreshNotice()
return
end
masterRelease(info.platform, info.version, function(ok, reply)
if not ok then
return
end
saveSet("releaseCheckedAt", os.time())
MENU_RELEASE = reply.newer and reply or nil
saveSet("releaseOffered", MENU_RELEASE or false)
menuRefreshNotice()
end)
end
-- An empty library still gets the menu: the service tools are how games are fetched.
do
overlaySetResolution(discGetWidth(), discGetHeight())
-- No disc: the menu is laid out on the canvas, 720x480 unless --canvas says otherwise, and the
-- renderer that needs Singe/menuBackground.mkv loads it itself.
--
-- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the
-- grid section it loops begins, and its last frame. The first two are not the same moment --
-- the menu comes up while the music is still fading out behind it, and only then does the
-- picture start repeating. All three come from util/renderMenuVideo.py, which prints them
-- when it makes the video; the grid section is a whole number of turns of the grid, so
-- playing it over and over has no seam in it. A renderer that draws its own grid uses none
-- of them: it knows where its own logo went.
DISC_MENU_FRAME = 222
DISC_GRID_START = 300
DISC_LAST_FRAME = 419
SHUTDOWN_FROM_PUSH = false
-- The service tools share this click with the audio delay tool.
SND_CLICK = soundLoad("Singe/click.wav")
-- The intro's sound. It belongs to the menu rather than to either renderer: one draws the
-- intro and the other plays a recording of it, and both want the same sound over the top at
-- the same moment. Keeping it out of the video is also what stops it being stored twice.
SND_INTRO = soundLoad("Singe/menuIntro.flac")
SND_INTRO_VOICE = SOUND_ERROR_INVALID
-- Load configuration
SHOW_INTRO = true
CONFIG_FILE = singeGetDataPath() .. "menu.dat"
local confattr = lfs.attributes(CONFIG_FILE)
local wanted = 1
if confattr then
dofile(CONFIG_FILE)
-- A damaged menu.dat must not take the menu down with it. What it asks for is held here
-- until gameSelect places it: nothing is selected yet, so there is nothing to let go of.
wanted = math.min(tonumber(GAME_SELECTED) or 1, GAME_COUNT)
end
GAME_SELECTED = nil
-- The renderer. A GUI document is drawn through the GPU device the 3D scene uses, so without
-- one there is no document to build and guiNew would end the script rather than answer false;
-- the overlay renderer needs nothing but the overlay every game already draws into. Set
-- MENU_OVERLAY = true in menu.dat to use it on a machine that has a GPU, which is how the
-- overlay path gets tested.
if singeHasGpu() and not MENU_OVERLAY then
dofile("Singe/MenuDocument.singe")
else
dofile("Singe/MenuOverlay.singe")
end
MENU_RENDER.begin()
menuCheckVersions()
menuCheckRelease(false)
MENU_RENDER.layout()
MENU_RENDER.buildList()
MENU_RENDER.backdropBegin(SHOW_INTRO)
if SHOW_INTRO then
SND_INTRO_VOICE = soundPlay(SND_INTRO)
end
-- Prime the pump
gameSelect(wanted)
end

View file

@ -1,461 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu drawn as an RmlUi document: Singe/Menu.rml styled by Singe/menu.rcss, composited over
-- the overlay, with the attract video drawn into the overlay underneath the hole the document
-- leaves for it. This needs the GPU device the 3D scene uses; Singe/MenuOverlay.singe is the one
-- for a machine without one. Singe/Menu.singe loads exactly one of the two and calls it through
-- MENU_RENDER:
--
-- begin() build what drawing needs
-- finish() give it back
-- backdropBegin(showIntro) start the background
-- backdrop() advance it, and clear the overlay ready to be drawn into
-- backdropReady() the intro is over and the menu may take input
-- layout() place the regions, from the overlay size
-- buildList() the whole game list is known
-- showGame(game) this is the selection now, or MENU_EMPTY when there are no games
-- hideGame() let go of the last selection
-- pageSize() how many rows left and right should move by
-- frame() draw the game page
-- tools() draw the service tools page
-- Layout, in overlay pixels: the columns' widths and the space around them; everything else
-- follows from the overlay size in layout(). The marquee and video keep the overlay menu's
-- 320x75 and 320x200 proportions.
dofile("Singe/Backdrop.singe")
-- Layout constants follow.
local MARGIN = 10
local GAP = 8
local LIST_W = 200
local POSTER_W = 180
local FOOTER_H = 24
local MARQUEE_ASPECT_W = 320
local MARQUEE_ASPECT_H = 75
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local scrollPending = false
local detailScroll = 0 -- How far down the description has walked, in pixels,
local detailDown = true -- and which way it is walking.
local detailClock = 0
local toolsCardShown = false -- The document shows the service tools' card, not the game list.
local SCROLL_HOLD = 3.0 -- Seconds it rests at each end before moving, as in the overlay
local SCROLL_STEP = 1.1 -- renderer, and seconds a step while it is moving.
local SCROLL_LINE = 16 -- How far a step goes: a line of the description.
function menuElement(id)
return rmlui.contexts["gui" .. GUI].documents["menu"]:GetElementById(id)
end
-- Text on its way into the document, with RML's special characters made harmless.
function menuEscape(text)
local output = tostring(text or "")
output = string.gsub(output, "&", "&amp;")
output = string.gsub(output, "<", "&lt;")
output = string.gsub(output, ">", "&gt;")
return output
end
-- Fits a game's picture inside one of the art regions. The document lives in Singe/, so a game's
-- own file is reached from there through "../"; the engine's files are named as they are.
--
-- Art that is not there shows as art that is not there, which menuArtPath decides -- a games.dat
-- naming a file that was never packed, or a path with a typo in it, is otherwise a silent hole in
-- the page, and it used to be worse than silent: an empty path took the whole engine down through
-- the texture loader. MENU_MISSING_ART is an engine file, so the "singe/" test below leaves it
-- alone.
local function menuArt(id, file)
local path = menuArtPath(file)
if path:sub(1, 6):lower() ~= "singe/" then
path = "../" .. path
end
menuElement(id).style.decorator = 'image("' .. path .. '" contain)'
end
-- One "Label: value" fragment of a details line.
local function menuLabel(label, value)
return '<span class="label">' .. label .. ':</span> ' .. menuEscape(value) .. ' &nbsp; '
end
local function menuPlace(id, x, y, w, h)
local element = menuElement(id)
element:SetClass("placed", true)
element.style.left = x .. "px"
element.style.top = y .. "px"
element.style.width = w .. "px"
element.style.height = h .. "px"
end
local function menuRow(index)
return menuElement("game" .. index)
end
-- A click on a row selects its game; a click on the selected row starts it.
local function menuRowClick(gui, document, id, event, value)
local index = tonumber(id:sub(5))
if not menuActive() then
return
end
if index == GAME_SELECTED then
gameStart()
else
gameSelect(index)
end
end
-- Brings the selected row into view once the document has been laid out.
local function menuScrollToSelection()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
local top = row.offset_top - list.scroll_top
if list.client_height <= 0 then
return
end
if top < 0 then
row:ScrollIntoView(true)
elseif top + row.offset_height > list.client_height then
row:ScrollIntoView(false)
end
scrollPending = false
end
-- A tool lines its columns up with runs of spaces, which RML would collapse to one, so they are
-- made non-breaking on the way in. Only the tools do this: a game's description is meant to wrap.
local function menuSpaces(text)
return (string.gsub(menuEscape(text), " +", function(run) return string.rep("&nbsp;", #run) end))
end
-- One row of a service tool, as RML. The kinds are the ones Singe/Tools.singe builds.
local function toolRow(row)
if row.kind == "pair" then
return string.format("<div class='pair'><span class='label'>%s</span><span class='detail'>%s</span></div>",
menuEscape(row.label), menuSpaces(row.value))
elseif row.kind == "sub" then
return "<div class='sub'>" .. menuSpaces(row.text) .. "</div>"
elseif row.kind == "keys" then
return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>"
elseif row.kind == "item" then
local detail = row.detail and ("<span class='detail'>" .. menuSpaces(tostring(row.detail)) .. "</span>") or ""
return string.format("<div class='%s'><span class='text'>%s</span>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text), detail)
elseif row.kind == "blank" then
return "<div class='blank'></div>"
end
return "<div class='row'>" .. menuSpaces(row.text) .. "</div>"
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
-- The document: the size of the overlay, composited over it every frame, and first in line
-- for the mouse and the switches so its rows can be clicked.
GUI = guiNew(overlayGetWidth(), overlayGetHeight())
DOCUMENT = guiLoad(GUI, "Singe/Menu.rml")
guiSetInput(GUI, true)
end
MENU_RENDER.finish = function()
if GUI then
guiDelete(GUI)
GUI = nil
end
backdropFinish()
sceneEnable(false)
end
-- The backdrop is its own file, because util/renderMenuVideo.py draws the same thing to record it.
MENU_RENDER.backdropBegin = function(showIntro)
backdropBegin(showIntro)
end
MENU_RENDER.backdrop = function()
-- Transparent, so the scene behind the overlay is what shows.
colorBackground(0, 0, 0, 0)
overlayClear()
backdropFrame()
end
MENU_RENDER.backdropReady = function()
-- The menu takes the screen once the logo has left, not when a video frame number passes.
return backdropReady()
end
MENU_RENDER.layout = function()
local width = overlayGetWidth()
local height = overlayGetHeight()
local sideX = MARGIN + LIST_W + GAP + POSTER_W + GAP
local sideW = width - MARGIN - sideX
local listH = height - MARGIN * 2 - FOOTER_H - GAP
local posterX = MARGIN + LIST_W + GAP
local footerY = height - MARGIN - FOOTER_H
local marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
local infoY = 0
videoX = sideX
videoY = MARGIN + marqueeH + GAP
videoW = sideW
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
infoY = videoY + videoH + GAP
menuPlace("list", MARGIN, MARGIN, LIST_W, listH)
menuPlace("poster", posterX, MARGIN, POSTER_W, videoY + videoH - MARGIN)
menuPlace("marquee", sideX, MARGIN, sideW, marqueeH)
menuPlace("video", videoX, videoY, videoW, videoH)
menuPlace("info", posterX, infoY, width - MARGIN - posterX, MARGIN + listH - infoY)
menuPlace("footer", MARGIN, footerY, width - MARGIN * 2, FOOTER_H)
end
-- Fills the game list with one row per game and routes their clicks here.
MENU_RENDER.buildList = function()
local rows = {}
for index, game in ipairs(GAME_LIST) do
local badge = menuUpdateFor(game) and '<span class="badge">update</span>' or ""
-- The title in a span of its own, so a long one can give way to the mark (menu.rcss).
rows[index] = '<div id="game' .. index .. '"><span class="title">' .. menuEscape(game.TITLE) .. '</span>' .. badge .. '</div>'
end
menuElement("list").inner_rml = table.concat(rows)
for index = 1, GAME_COUNT do
guiSetHandler(GUI, DOCUMENT, "game" .. index, "click", menuRowClick)
end
end
-- The service answered about versions, or an update landed: the badges and the notice change.
MENU_RENDER.refreshList = function()
MENU_RENDER.buildList()
if GAME_SELECTED then
menuRow(GAME_SELECTED):SetClass("selected", true)
menuRefreshNotice()
end
end
MENU_RENDER.notice = function(text)
guiSetValue(GUI, DOCUMENT, "notice", menuEscape(text or ""))
end
-- MENU_EMPTY, the page for an empty library, has no row, no art, and no facts: only its title and
-- what to do.
MENU_RENDER.showGame = function(game)
if game.EMPTY then
menuElement("poster").style.decorator = "none"
menuElement("marquee").style.decorator = "none"
guiSetValue(GUI, DOCUMENT, "credits", "")
guiSetValue(GUI, DOCUMENT, "developer", "")
guiSetValue(GUI, DOCUMENT, "publisher", "")
guiSetValue(GUI, DOCUMENT, "port", "")
else
menuRow(GAME_SELECTED):SetClass("selected", true)
scrollPending = true
menuArt("poster", game.CABINET)
menuArt("marquee", game.MARQUEE)
guiSetValue(GUI, DOCUMENT, "credits", menuLabel("Year", game.YEAR) .. menuLabel("Genre", game.GENRE) .. menuLabel("Platform", game.PLATFORM))
guiSetValue(GUI, DOCUMENT, "developer", menuLabel("Developer", game.DEVELOPER))
guiSetValue(GUI, DOCUMENT, "publisher", menuLabel("Publisher", game.PUBLISHER))
guiSetValue(GUI, DOCUMENT, "port", menuLabel("Singe port", game.CREATOR) .. menuLabel("Source", game.SOURCE))
end
guiSetValue(GUI, DOCUMENT, "title", menuEscape(game.TITLE))
guiSetValue(GUI, DOCUMENT, "description", menuEscape(game.DESCRIPTION))
menuElement("description").scroll_top = 0
detailScroll = 0
detailDown = true
detailClock = 0
end
MENU_RENDER.hideGame = function()
-- The document keeps nothing of its own per game; only the highlight has to come off.
if GAME_SELECTED then
menuRow(GAME_SELECTED):SetClass("selected", false)
end
end
-- Rows that fit in the list at once: what left and right page by.
MENU_RENDER.pageSize = function()
local list = menuElement("list")
local row = menuRow(GAME_SELECTED)
if row.offset_height <= 0 then
return 1
end
return math.max(1, math.floor(list.client_height / row.offset_height))
end
-- The description walks by itself when there is more of it than fits -- down to the end, a rest,
-- and back up to the start -- the same way the overlay renderer's details do. The style sheet
-- gives this box a scroll bar, but nothing drives one from a joystick and up and down belong to the
-- game list, so a long description was simply unreadable past its first few lines.
local function menuScrollDetails()
local element = menuElement("description")
local room = element.scroll_height - element.client_height
if room <= 0 then
return
end
-- The clock starts when there is something to scroll and the menu is on screen, not when the
-- game was selected: the menu is behind the intro for the first seven seconds.
if detailClock == 0 then
detailClock = singeGetTicks()
end
local resting = (detailScroll <= 0) or (detailScroll >= room)
if (singeGetTicks() - detailClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then
if detailDown and (detailScroll >= room) then
detailDown = false
elseif not detailDown and (detailScroll <= 0) then
detailDown = true
end
detailScroll = math.max(math.min(detailScroll + (detailDown and SCROLL_LINE or -SCROLL_LINE), room), 0)
detailClock = singeGetTicks()
element.scroll_top = detailScroll
end
end
-- Page up and page down, by as much as is on show less a line to read across. The walk resumes
-- from where this leaves it, in the direction it was sent, after its usual rest.
MENU_RENDER.scrollDetails = function(direction)
local element = menuElement("description")
local room = element.scroll_height - element.client_height
if room > 0 then
local page = math.max(element.client_height - SCROLL_LINE, SCROLL_LINE)
detailScroll = math.max(math.min(detailScroll + direction * page, room), 0)
detailDown = (direction > 0)
detailClock = singeGetTicks()
element.scroll_top = detailScroll
end
end
MENU_RENDER.frame = function()
-- Back from the service tools: the document still shows their card, since only the tools page
-- swaps the two, and it stops being drawn the moment the tools close. The game list comes back
-- here, on the first frame of the game page.
if toolsCardShown then
toolsCardShown = false
menuElement("tool"):SetClass("hidden", true)
menuElement("games"):SetClass("hidden", false)
end
-- Attract Mode Video, under the hole the document leaves for it
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
if scrollPending then
menuScrollToSelection()
end
menuScrollDetails()
guiDraw(GUI)
end
MENU_RENDER.tools = function()
local rows = {}
-- The tools redraw only when they say something changed; the document keeps what it was given.
if TOOL_DIRTY then
TOOL_DIRTY = false
-- The same window the overlay renderer draws, so the two show the same page rather than one
-- of them quietly showing more. The style sheet can scroll this box, but nothing drives a
-- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen.
local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
-- The key hints go to the card's footer and a note to the column beside the list;
-- everything else is the body.
local keys = {}
local note = {}
if more then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
local seenItem = false
for _, row in ipairs(visible) do
-- The marker belongs under the last item rather than under the key hints below it.
if below and seenItem and row.kind ~= "item" then
rows[#rows + 1] = "<div class='sub'>...</div>"
below = false
end
if row.kind == "item" then
seenItem = true
end
if row.kind == "keys" then
keys[#keys + 1] = toolRow(row)
elseif row.kind == "note" then
note[#note + 1] = toolRow(row)
else
rows[#rows + 1] = toolRow(row)
end
end
if below then
rows[#rows + 1] = "<div class='sub'>...</div>"
end
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
guiSetValue(GUI, DOCUMENT, "toolNote", table.concat(note))
guiSetValue(GUI, DOCUMENT, "toolKeys", table.concat(keys))
menuElement("toolNote"):SetClass("hidden", #note == 0)
menuElement("games"):SetClass("hidden", toolsActive())
menuElement("tool"):SetClass("hidden", not toolsActive())
toolsCardShown = toolsActive()
menuElement("tool"):SetClass("clear", TOOL_CLEAR)
menuElement("tool"):SetClass("flash", TOOL_FLASH)
end
guiDraw(GUI)
end

View file

@ -1,724 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The menu drawn with sprites and fontPrint straight into the overlay: the look Singe 2 had, and
-- the one that works on a machine whose GPU device never came up, where there is no GUI document to
-- build. Singe/MenuDocument.singe is the other renderer; Singe/Menu.singe loads exactly one of the
-- two and calls it through MENU_RENDER. See the top of MenuDocument.singe for what is in it.
--
-- One game is on screen at a time, as it always was here, so there is no list to page through
-- visually: up and down move one game, left and right move a page, and the line above the details
-- says where in the library the selection is. An entry with more to say than fits walks down by
-- itself a line at a time, rests, and walks back up again: up and down belong to the library, and
-- a cabinet has no key to spare for scrolling a paragraph. Page up and page down override it for
-- whoever does have a keyboard.
local MARGIN = 10 -- Around the page, and between the regions on it.
local GAP = 8
local HINT_H = 24 -- The line of key hints along the bottom.
local SIDE_FRAC = 0.40 -- How much of the width the marquee and the attract video take.
local MARQUEE_ASPECT_W = 320 -- What the artwork and the clips are authored at; both regions keep
local MARQUEE_ASPECT_H = 75 -- these proportions whatever the overlay's size turns out to be.
local VIDEO_ASPECT_W = 320
local VIDEO_ASPECT_H = 200
local LINE_HEIGHT = 24
local LABEL_W = 150
local SUB_INDENT = 20
local CARD_PAD = 14 -- The card the tools draw on: inside its border to its text,
local TITLE_H = 36 -- its title bar, and its footer of key hints, one line each plus
local KEY_LINE = 18 -- its padding. The colors are the game page's: the panel, the
local KEY_PAD = 6 -- selection's gradient, the blue-gray border, the gold, the quiet
local BORDER = 2 -- blue-gray of a label.
local ITEM_INSET = 8
local CLEAR_FRAC = 0.70 -- How wide a strip a page that lets the overlay through may take.
local NOTE_FRAC = 0.42 -- How much of the card a note beside the list takes.
local PANEL = { 24, 38, 64, 216 }
local PANEL_DEEP = { 12, 20, 36, 216 }
local FOOT = { 16, 24, 40, 208 }
local TITLE_BAR = { 51, 80, 122, 255 }
local TITLE_BAR_L = { 74, 110, 168, 255 }
local EDGE = { 106, 134, 184, 255 }
local GOLD = { 255, 211, 90, 255 }
local INK = { 240, 240, 240, 255 }
local QUIET = { 143, 162, 196, 255 }
local SOFT = { 200, 210, 230, 255 }
local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] "
local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools"
local font = nil
local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line
-- of them ran off the end of a 720 pixel overlay.
local titleFont = nil -- Larger: a tool's title on its bar.
local introDone = false
local backdrop = nil -- Singe/menuBackground.mkv, played here rather than as the menu's disc
local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0
local textX, textY, textW, textH = 0, 0, 0, 0
local hintsX, hintsY = 0, 0
local cabinetX, cabinetY, cabinetW, cabinetH = 0, 0, 0, 0
local spriteCabinet = nil
local spriteMarquee = nil
local textSprites = {}
local textShadows = {}
local noticeText = nil -- One line under the details: an update on offer, or how a fetch is going.
local textFirst = 0 -- The first line of the details on show, which walks by itself.
local textDown = true -- and which way it is walking.
local textClock = 0
local SHADOW = 2 -- How far the shadow sits down and across from the text.
local SCROLL_HOLD = 3.0 -- Seconds the details rest at each end before moving.
local SCROLL_STEP = 1.1 -- and seconds a line while they are moving.
local textHeight = 0
local textLimit = 0
-- A games.dat entry need not carry every field, and one that is missing must not take the menu
-- down; the document renderer is tolerant the same way, through menuEscape.
local function field(value)
return tostring(value or "")
end
-- Shrinks a sprite to fit inside a region, keeping its proportions. spriteDraw blits at the
-- sprite's own size and a games.dat's artwork is whatever size the person who drew it chose, so
-- without this a big cabinet picture is drawn straight over whatever the page put beside it. Only
-- ever smaller: blowing a small picture up to fill the space makes it soft for nothing.
local function fitSprite(handle, boxW, boxH)
local width = spriteGetWidth(handle)
local height = spriteGetHeight(handle)
if (width > 0) and (height > 0) then
local scale = math.min(boxW / width, boxH / height)
if scale < 1.0 then
spriteScale(handle, scale)
end
end
end
-- Splits text into the lines that fit the width given, measuring with the selected font. The only
-- way to measure a string is to render it, so this is slow enough to be worth doing once when
-- something changes rather than once a frame. WRAP_BREAK is a word the caller puts in to force a
-- line ending.
--
-- The space between words is kept as it was written rather than squeezed to one, because a tool
-- lines its columns up with runs of spaces and there is no other way here to say "a wide gap".
local function fitLines(text, maxWidth)
local lines = {}
local line = ""
local pending = ""
local trimBreak = utilTrim(WRAP_BREAK)
for word, space in (text .. WRAP_BREAK):gmatch("(%S+)(%s*)") do
if word == trimBreak then
lines[#lines + 1] = line
line = ""
pending = ""
else
local candidate = line .. pending .. word
local measured = fontToSprite(candidate)
local tooWide = spriteGetWidth(measured) > maxWidth
spriteUnload(measured)
if tooWide and line ~= "" then
lines[#lines + 1] = line
line = word
else
line = candidate
end
pending = space
end
end
if line ~= "" then
lines[#lines + 1] = line
end
return lines
end
-- The service tools, laid out. Each entry is one line of the page and each line is a list of
-- pieces: where the piece starts, what it says and which color it wants. The colors are roles
-- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint
-- takes the color at the moment it draws -- unlike fontToSprite, which bakes it in.
local toolLines = {}
local bodyX, bodyW = 0, 0 -- The card's body, where wrapped text is measured against.
local function toolLine(pieces)
toolLines[#toolLines + 1] = pieces
end
-- Text too long for the page wraps under itself rather than running off the edge.
local function toolWrapped(text, x, role)
if text == nil or text == "" then
-- Still a line: a tool that says nothing this frame should not have its page jump about.
toolLine({})
return
end
for _, line in ipairs(fitLines(text, bodyX + bodyW - x)) do
toolLine({ { x = x, text = line, role = role } })
end
end
-- The card the tools draw on: its edges, and where its body and its columns begin. A page that
-- lets the overlay through has no card; its strip is measured once its lines are known.
local toolKeyLines = {} -- The key hints, for the card's footer.
local toolNoteLines = {} -- What the chosen tool is for, beside the list of tools.
local cardX, cardY, cardW, cardH = 0, 0, 0, 0
local noteX, noteW = 0, 0
local function toolNote(pieces)
toolNoteLines[#toolNoteLines + 1] = pieces
end
local function toolsLayout()
local width = overlayGetWidth()
local height = overlayGetHeight()
-- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the
-- service is big and would otherwise run off the bottom of the screen.
local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
cardX, cardY, cardW, cardH = MARGIN, MARGIN, width - MARGIN * 2, height - MARGIN * 2
if TOOL_CLEAR then
cardW = math.floor(width * CLEAR_FRAC)
cardX = math.floor((width - cardW) / 2)
end
bodyX = cardX + BORDER + CARD_PAD
bodyW = cardW - (BORDER + CARD_PAD) * 2
local fullW = bodyW
-- A page with a note has a second column for it beside the list.
noteX, noteW = nil, 0
for _, row in ipairs(rows) do
if row.kind == "note" then
noteW = math.floor(cardW * NOTE_FRAC) - (BORDER + CARD_PAD) * 2
noteX = cardX + cardW - BORDER - CARD_PAD - noteW
bodyW = noteX - CARD_PAD - BORDER - bodyX
end
end
local valueX = bodyX + LABEL_W
toolLines = {}
toolKeyLines = {}
toolNoteLines = {}
if more then
toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
end
local seenItem = false
for _, row in ipairs(rows) do
-- The "more below" marker belongs immediately under the last item, not at the very bottom
-- of the page under the key hints, which are not part of the list.
if below and seenItem and row.kind ~= "item" then
toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
below = false
end
if row.kind == "item" then
seenItem = true
end
if row.kind == "blank" then
toolLine({})
elseif row.kind == "pair" then
-- The label and the first line of the value share a line; the rest of the value wraps
-- under itself, in its own column.
local lines = fitLines(row.value, bodyX + bodyW - valueX)
toolLine({ { x = bodyX, text = row.label, role = "quiet" },
{ x = valueX, text = lines[1] or "", role = "ink" } })
for index = 2, #lines do
toolLine({ { x = valueX, text = lines[index], role = "ink" } })
end
elseif row.kind == "sub" then
for _, line in ipairs(fitLines(row.text, bodyW - SUB_INDENT)) do
toolLine({ { x = bodyX + SUB_INDENT, text = line, role = "soft" } })
end
elseif row.kind == "keys" then
-- The footer, in the smaller font, across the whole card.
fontSelect(hintFont)
for _, line in ipairs(fitLines(row.text, fullW)) do
toolKeyLines[#toolKeyLines + 1] = line
end
fontSelect(font)
elseif row.kind == "item" then
local pieces = { { x = bodyX + ITEM_INSET, text = row.text, role = row.selected and "pick" or "ink", bar = row.selected } }
-- The detail against the right edge of the row, so a column of them lines up. An
-- empty one -- a field not filled in yet -- is nothing to measure or draw.
local detail = row.detail and tostring(row.detail) or ""
if detail ~= "" then
local measured = fontToSprite(detail)
pieces[#pieces + 1] = { x = bodyX + bodyW - ITEM_INSET - spriteGetWidth(measured), text = detail, role = pieces[1].role }
spriteUnload(measured)
end
toolLine(pieces)
elseif row.kind == "note" then
for _, line in ipairs(fitLines(row.text, noteW)) do
toolNote({ { x = noteX, text = line, role = "soft" } })
end
else
toolWrapped(row.text, bodyX, "ink")
end
end
-- A list that runs to the very end of the page has no row after it to hang the marker on.
if below then
toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
end
-- A strip along the bottom is as tall as its lines.
if TOOL_CLEAR then
cardH = BORDER * 2 + TITLE_H + CARD_PAD + #toolLines * LINE_HEIGHT + CARD_PAD + KEY_PAD * 2 + #toolKeyLines * KEY_LINE
cardY = height - MARGIN - cardH
end
end
MENU_RENDER = {}
MENU_RENDER.begin = function()
font = fontLoad("Singe/FreeSansBold.ttf", 18)
hintFont = fontLoad("Singe/FreeSansBold.ttf", 13)
titleFont = fontLoad("Singe/FreeSansBold.ttf", 22)
fontQuality(FONT_QUALITY_BLENDED)
fontSelect(font)
end
MENU_RENDER.finish = function()
for _, handle in ipairs({ font, hintFont, titleFont }) do
if handle then
fontUnload(handle)
end
end
font, hintFont, titleFont = nil, nil, nil
if backdrop then
videoUnload(backdrop)
backdrop = nil
end
end
-- The backdrop is a recording: the engine intro, then the grid section played over and over. A
-- machine with no GPU device has nothing to draw a grid with, so this renderer plays the one in
-- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead. It is loaded here
-- rather than handed to the menu as its disc, so the renderer that draws its own never opens it.
MENU_RENDER.backdropBegin = function(showIntro)
introDone = not showIntro
-- No GPU device, no 3D: tier 0, which is all this machine will ever measure (PLAN.md section 57).
if singeCapabilities().tier ~= 0 then
singeSaveTier(0)
end
backdrop = videoLoad("Singe/menuBackground.mkv")
videoPlay(backdrop)
if not showIntro then
videoSeek(backdrop, DISC_GRID_START)
end
end
MENU_RENDER.backdrop = function()
-- Loop the grid. A machine too slow to show every frame can step over the last one, after which
-- the player starts the video again from the top; once the intro has played, that is a loop.
local frame = videoGetFrame(backdrop)
if frame >= DISC_LAST_FRAME or (introDone and frame < DISC_GRID_START) then
videoSeek(backdrop, DISC_GRID_START)
end
if frame >= DISC_GRID_START then
introDone = true
end
-- The recording fills the overlay, and everything else is drawn over it. Cleared see-through
-- rather than black, which looks the same under a picture that covers everything, so the engine
-- can have the GPU draw the recording instead of copying every frame of it into the overlay.
colorBackground(0, 0, 0, 0)
overlayClear()
videoDraw(backdrop, 0, 0, overlayGetWidth() - 1, overlayGetHeight() - 1)
end
MENU_RENDER.backdropReady = function()
-- The menu comes up where the drawn backdrop's logo leaves, which is before the picture starts
-- repeating: the frames between the two are the grid running on while the music fades out.
return videoGetFrame(backdrop) >= DISC_MENU_FRAME
end
-- The cabinet fills the left; the marquee, the attract video and the details stack down the right.
-- The page: the cabinet on the left, the marquee and the attract video down the right, the game's
-- details across the bottom and the key hints under them. The details used to have the video's
-- column and whatever height was left over, which at 720x480 was four lines -- the description and
-- about one field -- so most of what a games.dat says never reached the screen at all. They get
-- the full width now, and the fields are grouped the way the document renderer groups them, so the
-- whole entry fits. Everything is worked out from the overlay's size rather than written down,
-- because a game may set any size it likes.
MENU_RENDER.layout = function()
local width = overlayGetWidth()
local height = overlayGetHeight()
local sideW = math.floor(width * SIDE_FRAC)
local sideX = width - MARGIN - sideW
marqueeW = sideW
marqueeH = math.floor(sideW * MARQUEE_ASPECT_H / MARQUEE_ASPECT_W)
marqueeX = sideX
marqueeY = MARGIN
videoW = sideW
videoH = math.floor(sideW * VIDEO_ASPECT_H / VIDEO_ASPECT_W)
videoX = sideX
videoY = marqueeY + marqueeH + GAP
hintsX = MARGIN
hintsY = height - MARGIN - HINT_H
textX = MARGIN
textY = videoY + videoH + GAP
textW = width - MARGIN - textX
textH = hintsY - GAP - textY
cabinetX = MARGIN
cabinetY = MARGIN
cabinetW = sideX - GAP - cabinetX
cabinetH = textY - GAP - cabinetY
end
MENU_RENDER.buildList = function()
-- One game is on screen at a time; there is no list to build.
end
-- The service answered about versions, or an update landed. The badge is read every frame, so
-- only the notice has to be brought up to date.
MENU_RENDER.refreshList = function()
menuRefreshNotice()
end
MENU_RENDER.notice = function(text)
noticeText = text
end
-- MENU_EMPTY, the page for an empty library, has no art and no facts: only what to do.
MENU_RENDER.showGame = function(game)
local textBox = field(game.DESCRIPTION)
if not game.EMPTY then
spriteCabinet = spriteLoad(menuArtPath(game.CABINET))
spriteMarquee = spriteLoad(menuArtPath(game.MARQUEE))
fitSprite(spriteCabinet, cabinetW, cabinetH)
fitSprite(spriteMarquee, marqueeW, marqueeH)
-- The details, grouped the way the document renderer groups them: the short fields share a
-- line, and the description comes last because it is the one that can run on. It used to
-- come first, which meant a long description pushed every fact about the game off the bottom.
textBox = "Year: " .. field(game.YEAR) .. " Genre: " .. field(game.GENRE) ..
" Platform: " .. field(game.PLATFORM) .. WRAP_BREAK ..
"Developer: " .. field(game.DEVELOPER) .. WRAP_BREAK ..
"Publisher: " .. field(game.PUBLISHER) .. WRAP_BREAK ..
"Singe port: " .. field(game.CREATOR) .. " Source: " .. field(game.SOURCE) .. WRAP_BREAK ..
WRAP_BREAK .. textBox
end
-- fontToSprite bakes the color in, so it is set before the sprites are made rather than
-- before they are drawn.
-- Twice over: black first, drawn a couple of pixels down and across behind the white, because
-- this page sits on a moving grid and white on magenta is hard to read. The document renderer
-- puts a translucent panel behind its text; nothing here can fill a rectangle, and a shadow
-- costs one more blit a line rather than one more surface a frame.
local lines = fitLines(textBox, textW)
colorForeground(0, 0, 0, 255)
for _, line in ipairs(lines) do
textShadows[#textShadows + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
colorForeground(255, 255, 255, 255)
for _, line in ipairs(lines) do
textSprites[#textSprites + 1] = (string.len(line) > 0) and fontToSprite(line) or -1
end
-- How tall a line is, and how many of them fit under the title. Measured here rather than
-- part way through drawing, because the scroll has to know before the first line is drawn.
textHeight = 0
for _, handle in ipairs(textSprites) do
if (textHeight == 0) and (handle >= 0) then
textHeight = spriteGetHeight(handle) + 1
end
end
textLimit = (textHeight > 0) and math.max(math.floor((textH - LINE_HEIGHT) / textHeight), 1) or 1
textFirst = 0
textDown = true
textClock = 0
end
MENU_RENDER.hideGame = function()
if spriteCabinet then
spriteUnload(spriteCabinet)
spriteCabinet = nil
end
if spriteMarquee then
spriteUnload(spriteMarquee)
spriteMarquee = nil
end
for _, handle in ipairs(textSprites) do
if handle >= 0 then
spriteUnload(handle)
end
end
for _, handle in ipairs(textShadows) do
if handle >= 0 then
spriteUnload(handle)
end
end
textSprites = {}
textShadows = {}
end
-- Page up and page down, by as much as is on show less a line to read across. The walk resumes
-- from where this leaves it, in the direction it was sent, after its usual rest.
MENU_RENDER.scrollDetails = function(direction)
local room = #textSprites - textLimit
if room > 0 then
textFirst = math.max(math.min(textFirst + direction * math.max(textLimit - 1, 1), room), 0)
textDown = (direction > 0)
textClock = singeGetTicks()
end
end
MENU_RENDER.pageSize = function()
return PAGE_ROWS
end
MENU_RENDER.frame = function()
local x = 0
local y = 0
-- Cabinet and marquee images, which an empty library has none of
if spriteCabinet then
x = cabinetX + (cabinetW - spriteGetWidth(spriteCabinet)) * 0.5
y = cabinetY + (cabinetH - spriteGetHeight(spriteCabinet)) * 0.5
spriteDraw(spriteCabinet, x, y)
x = marqueeX + (marqueeW - spriteGetWidth(spriteMarquee)) * 0.5
y = marqueeY + (marqueeH - spriteGetHeight(spriteMarquee)) * 0.5
spriteDraw(spriteMarquee, x, y)
end
-- Attract mode video
if VIDEO_ATTRACT then
videoDraw(VIDEO_ATTRACT, videoX, videoY, videoX + videoW, videoY + videoH)
end
-- Which game this is, since there is no list to look at. It heads the details rather than
-- sitting over the cabinet artwork, where it was unreadable against whatever the picture was.
local title = MENU_EMPTY.TITLE
if GAME_SELECTED then
title = string.format("%s (%d of %d)", field(GAME_LIST[GAME_SELECTED].TITLE), GAME_SELECTED, GAME_COUNT)
if menuUpdateFor(GAME_LIST[GAME_SELECTED]) then
title = title .. " [update]"
end
end
colorForeground(0, 0, 0, 255)
fontPrint(textX + SHADOW, textY + SHADOW, title)
colorForeground(255, 211, 90, 255)
fontPrint(textX, textY, title)
-- What the game is. More of it than fits walks a line at a time, down to the end and back up
-- to the start, so everything an entry says is seen without a key to scroll it -- and without
-- the jump back to the top that reading the last line used to end in.
local room = #textSprites - textLimit
-- The clock starts when the details are first drawn, not when they were built: the menu is
-- behind the intro for the first seven seconds, and a page that had already scrolled itself by
-- the time anybody saw it would appear to start half way down.
if textClock == 0 then
textClock = singeGetTicks()
end
if room > 0 then
local resting = (textFirst <= 0) or (textFirst >= room)
if (singeGetTicks() - textClock) >= ((resting and SCROLL_HOLD or SCROLL_STEP) * 1000) then
if textDown and (textFirst >= room) then
textDown = false
elseif not textDown and (textFirst <= 0) then
textDown = true
end
textFirst = math.max(math.min(textFirst + (textDown and 1 or -1), room), 0)
textClock = singeGetTicks()
end
end
y = textY + LINE_HEIGHT
for index = textFirst + 1, math.min(textFirst + textLimit, #textSprites) do
if textSprites[index] >= 0 then
spriteDraw(textShadows[index], textX + SHADOW, y + SHADOW)
spriteDraw(textSprites[index], textX, y)
end
y = y + textHeight
end
-- Which key does what. The document renderer says the same thing along its footer; here
-- there was nothing at all, so the only way to learn that up and down move one game and left
-- and right move a page was to be told.
fontSelect(hintFont)
if noticeText and noticeText ~= "" then
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY - LINE_HEIGHT + SHADOW, noticeText)
colorForeground(255, 211, 90, 255)
fontPrint(hintsX, hintsY - LINE_HEIGHT, noticeText)
end
colorForeground(0, 0, 0, 255)
fontPrint(hintsX + SHADOW, hintsY + SHADOW, HINTS)
colorForeground(178, 196, 224, 255)
fontPrint(hintsX, hintsY, HINTS)
fontSelect(font)
end
-- A filled rectangle: the overlay has no fill of its own, so it is lines, one a row. The color
-- carries its alpha, which is what makes the card a panel over the moving grid and not a slab.
-- Given a second color it runs from the first at the top to the second at the bottom: every
-- row is its own line already, so the gradient costs nothing more.
local function fillRect(x, y, w, h, color, toColor)
local last = math.max(1, h - 1)
for row = 0, h - 1 do
if toColor then
local t = row / last
colorForeground(math.floor(color[1] + (toColor[1] - color[1]) * t + 0.5),
math.floor(color[2] + (toColor[2] - color[2]) * t + 0.5),
math.floor(color[3] + (toColor[3] - color[3]) * t + 0.5),
math.floor(color[4] + (toColor[4] - color[4]) * t + 0.5))
elseif row == 0 then
colorForeground(color[1], color[2], color[3], color[4])
end
overlayLine(x, y + row, x + w - 1, y + row)
end
end
local function ink(color)
colorForeground(color[1], color[2], color[3], color[4])
end
-- The page: the card with its title bar, the rows in their columns, the chosen row on its bar,
-- and the key hints in the footer. A page that draws its own picture gets a strip along the
-- bottom instead; the audio delay tool's flash is the whole screen white and nothing else.
MENU_RENDER.tools = function()
if TOOL_FLASH then
colorBackground(255, 255, 255, 255)
overlayClear()
return
end
if not TOOL_CLEAR then
colorBackground(0, 0, 0, 0)
overlayClear()
end
-- Measuring text means rendering it, so the page is laid out only when a tool says it changed.
if TOOL_DIRTY then
TOOL_DIRTY = false
toolsLayout()
end
local footH = KEY_PAD * 2 + #toolKeyLines * KEY_LINE
local footY = cardY + cardH - BORDER - footH
local y = cardY + BORDER + TITLE_H + CARD_PAD
-- The card: the panel, its border twice for the width, the title bar, and the footer.
fillRect(cardX, cardY, cardW, cardH, PANEL, PANEL_DEEP)
fillRect(cardX + BORDER, cardY + BORDER, cardW - BORDER * 2, TITLE_H, TITLE_BAR_L, TITLE_BAR)
fillRect(cardX + BORDER, cardY + BORDER + TITLE_H, cardW - BORDER * 2, BORDER, EDGE)
if #toolKeyLines > 0 then
fillRect(cardX + BORDER, footY, cardW - BORDER * 2, footH, FOOT)
fillRect(cardX + BORDER, footY - BORDER, cardW - BORDER * 2, BORDER, EDGE)
end
ink(EDGE)
overlayBox(cardX, cardY, cardX + cardW - 1, cardY + cardH - 1)
overlayBox(cardX + 1, cardY + 1, cardX + cardW - 2, cardY + cardH - 2)
if noteX then
fillRect(noteX - CARD_PAD - BORDER, cardY + BORDER + TITLE_H + BORDER, BORDER, footY - BORDER - (cardY + BORDER + TITLE_H + BORDER), EDGE)
end
fontSelect(titleFont)
ink(GOLD)
fontPrint(cardX + BORDER + CARD_PAD, cardY + BORDER + math.floor((TITLE_H - 26) / 2), TOOL_TITLE)
fontSelect(font)
for _, line in ipairs(toolLines) do
for _, piece in ipairs(line) do
if piece.bar then
fillRect(bodyX, y - 3, bodyW, LINE_HEIGHT - 2, TITLE_BAR_L, TITLE_BAR)
end
if piece.role == "quiet" then
ink(QUIET)
elseif piece.role == "soft" then
ink(SOFT)
elseif piece.role == "pick" then
ink(GOLD)
else
ink(INK)
end
-- fontPrint refuses an empty string, and a tool legitimately produces one: a status
-- line with nothing to say yet is still a line, and it still takes up its height.
if piece.text ~= "" then
fontPrint(piece.x, y, piece.text)
end
end
y = y + LINE_HEIGHT
end
y = cardY + BORDER + TITLE_H + CARD_PAD
for _, line in ipairs(toolNoteLines) do
for _, piece in ipairs(line) do
ink(SOFT)
if piece.text ~= "" then
fontPrint(piece.x, y, piece.text)
end
end
y = y + LINE_HEIGHT
end
fontSelect(hintFont)
ink(QUIET)
y = footY + KEY_PAD
for _, line in ipairs(toolKeyLines) do
if line ~= "" then
fontPrint(bodyX, y, line)
end
y = y + KEY_LINE
end
fontSelect(font)
end

View file

@ -1,600 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Talking to the master service without stopping the game.
--
-- Lua in Singe is not threaded, and ssl.https.request blocks until it finishes: a JSON call would
-- stutter the frame and a several-hundred-megabyte download would look like a hang. So every
-- request here is a state machine driven a slice at a time from netPump(), which the menu calls
-- once a frame. A caller hands in a function and gets called back when the answer arrives.
--
-- On trust. LuaSec's own https.lua ships with verify = "none", which encrypts the connection and
-- authenticates nobody -- anything on the path could read the password going up, the token coming
-- back, and alter the game being downloaded. Nothing here uses that module. A connection is
-- verified against a pinned public key: Singe ships to five platforms and LuaSec can read neither
-- the Windows certificate store nor the macOS keychain, and this client only ever talks to one
-- service, so pinning is both simpler and stronger than carrying a list of every public CA.
--
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
--
-- Without a pin the chain is verified against the union of two CA lists: Mozilla's, which the
-- engine extracts as Singe/cacerts.pem beside the other support files, and the machine's own
-- (the distribution's file on Linux, the root store on Windows, the system anchors on macOS),
-- which netTrustSystem adds, with a cacerts.local.pem from the data directory for a self-hosted
-- server's CA. A connection verifies when either list knows the server's root, so a cabinet
-- whose bundle has gone stale can still reach the release that brings a new one, as long as the
-- machine's own list is current (PLAN.md section 61).
local socket = require("socket")
local ssl = require("ssl")
NET_TIMEOUT = 30000 -- ms with no progress at all before a request is abandoned
NET_CHUNK = 65536 -- bytes read per pump slice
NET_REDIRECT_MAX = 5
-- master.singeengine.com. Two pins: the one in use and its successor, so a key rotation is a
-- release that knows both, then a server that starts using the second. An empty list means the
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
NET_PINS = {}
NET_CA_SHIPPED = "Singe/cacerts.pem" -- The list Singe carries; the machine's own is added to it.
local active = {}
local nextId = 1
local trust = nil -- The one client context every request shares, and what it was built from.
-- The SHA-256 of a peer's public key, in the form the pins are written in.
local function keyPin(connection)
local cert = connection:getpeercertificate()
if not cert then
return nil
end
local pem = cert:pubkey()
if not pem then
return nil
end
return utilSha256(pem)
end
local function finish(request, ok, result)
active[request.id] = nil
if request.file then
request.file:close()
request.file = nil
end
if request.socket then
pcall(function() request.socket:close() end)
request.socket = nil
end
if request.onDone then
request.onDone(ok, result)
end
end
local function fail(request, message)
finish(request, false, { error = message, url = request.url })
end
-- scheme, host, port, path
local function parseUrl(url)
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
if not scheme then
return nil
end
local hostPort, path = rest:match("^([^/]+)(.*)$")
if not hostPort then
return nil
end
local host, port = hostPort:match("^(.-):(%d+)$")
host = host or hostPort
port = tonumber(port) or ((scheme == "https") and 443 or 80)
return scheme, host, port, (path ~= "" and path or "/")
end
local function beginConnect(request)
local sock, err = socket.tcp()
if not sock then
return fail(request, "no socket: " .. tostring(err))
end
sock:settimeout(0)
request.socket = sock
-- A non-blocking connect answers "timeout" immediately and finishes in the background; the
-- socket becoming writable is what says it arrived.
local ok, connectErr = sock:connect(request.host, request.port)
if not ok and connectErr ~= "timeout" and connectErr ~= "already connected" then
return fail(request, "cannot reach " .. request.host .. ": " .. tostring(connectErr))
end
request.state = (request.scheme == "https") and "connecting" or "sending"
request.sent = 0
end
local function buildRequest(request)
local lines = {
string.format("%s %s HTTP/1.1", request.method, request.path),
"Host: " .. request.host,
"Connection: close",
"Accept-Encoding: identity",
"User-Agent: Singe/" .. SINGE_VERSION_STRING,
}
for name, value in pairs(request.headers or {}) do
lines[#lines + 1] = name .. ": " .. value
end
if request.bodyFile then
lines[#lines + 1] = "Content-Length: " .. request.bodySize
elseif request.body then
lines[#lines + 1] = "Content-Length: " .. #request.body
end
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
end
-- The shipped bundle's own name for its version, from its header, so a failure can say how old it
-- is without a second copy of the date to keep in step.
local function bundleVersion()
local file = io.open(NET_CA_SHIPPED, "r")
local line = nil
local seen = nil
if not file then
return "missing"
end
for _ = 1, 5 do
line = file:read("l")
if not line then
break
end
seen = seen or line:match("ca%-certificates (%d+)")
end
file:close()
return seen or "of unknown date"
end
-- What the clock has to do with a failed verification. Certificates are checked against the date,
-- and a board with no battery-backed clock that has not had network time reads a date long past:
-- when the clock reads earlier than the shipped bundle was made, it is certainly wrong, whatever
-- OpenSSL called the failure. Otherwise a "not valid yet" certificate still points at the clock.
local function clockNote(err)
local now = os.time()
local reads = os.date("%Y-%m-%d %H:%M", now)
local y, m, d = bundleVersion():match("^(%d%d%d%d)(%d%d)(%d%d)$")
if y and (now < os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0 })) then
return ". This machine's clock reads " .. reads .. ", before the certificate list Singe ships was made (" .. y .. "-" .. m .. "-" .. d .. "): set the date and time"
end
if tostring(err):find("not yet valid", 1, true) then
return ". The certificate is not valid yet by this machine's clock, which reads " .. reads .. ": check the date and time"
end
return ""
end
-- The client context, built once: the shipped bundle, then the machine's own roots and the local
-- file on top. Rebuilding it per request parsed the whole bundle every connection.
local function trustContext()
if trust then
return trust.context
end
local params = {
mode = "client",
protocol = "any",
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
-- what decides. Without one, the lists are asked -- and if they have nothing to say, the
-- request fails rather than proceeding unverified.
verify = (#NET_PINS > 0) and "none" or "peer",
cafile = NET_CA_SHIPPED,
}
local context, err = ssl.newcontext(params)
if not context then
return nil, err
end
local _, report = netTrustSystem(context)
trust = { context = context, report = report }
return context
end
-- What a failed verification was checked against, for the message a person reads.
local function trustChecked()
local lists = { "shipped " .. NET_CA_SHIPPED .. " (" .. bundleVersion() .. ")" }
for line in ((trust and trust.report) or ""):gmatch("[^\n]+") do
lists[#lists + 1] = line
end
return table.concat(lists, "; ")
end
local function startTls(request)
local context, err = trustContext()
if not context then
return fail(request, "TLS setup failed: " .. tostring(err))
end
local wrapped, why = ssl.wrap(request.socket, context)
if not wrapped then
return fail(request, "TLS setup failed: " .. tostring(why))
end
wrapped:settimeout(0)
if wrapped.sni then
wrapped:sni(request.host)
end
request.socket = wrapped
request.state = "handshake"
end
local function pumpHandshake(request)
local ok, err = request.socket:dohandshake()
if ok then
if #NET_PINS > 0 then
local seen = keyPin(request.socket)
local good = false
for _, pin in ipairs(NET_PINS) do
if seen == pin then
good = true
end
end
if not good then
-- The one failure that must never be shrugged off: a wrong key is either a
-- mis-deployed server or somebody in the middle, and both want a person.
return fail(request, "the server's key is not one this build trusts")
end
end
request.state = "sending"
return
end
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
fail(request, "TLS handshake failed: " .. tostring(err) .. ". Checked: " .. trustChecked() .. clockNote(err) .. ".")
end
end
-- The head goes first as one string. A body from a file follows it a piece at a time, read as
-- it is sent, so a game is never held in memory whole: the same reason a download goes straight
-- to disk.
local NET_SEND_CHUNK = 65536
local function pumpSending(request)
if not request.outgoing then
request.outgoing = buildRequest(request)
end
if request.sent < #request.outgoing then
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
if sent then
request.sent = sent
elseif err == "timeout" then
request.sent = partial or request.sent
else
return fail(request, "send failed: " .. tostring(err))
end
if request.sent < #request.outgoing then
return
end
request.lastProgress = singeGetTicks()
end
if not request.bodyFile then
request.state = "reading"
return
end
-- The body, from the file.
if not request.bodyHandle then
request.bodyHandle = io.open(request.bodyFile, "rb")
request.bodySent = 0
request.chunk = nil
request.chunkSent = 0
if not request.bodyHandle then
return fail(request, "cannot read " .. request.bodyFile)
end
end
if not request.chunk then
request.chunk = request.bodyHandle:read(NET_SEND_CHUNK)
request.chunkSent = 0
if not request.chunk then
request.bodyHandle:close()
request.bodyHandle = nil
request.state = "reading"
return
end
end
local sent, err, partial = request.socket:send(request.chunk, request.chunkSent + 1)
if sent then
request.chunkSent = sent
elseif err == "timeout" then
request.chunkSent = partial or request.chunkSent
else
return fail(request, "send failed: " .. tostring(err))
end
if request.chunkSent > 0 then
request.lastProgress = singeGetTicks()
end
if request.chunkSent >= #request.chunk then
request.bodySent = request.bodySent + #request.chunk
request.chunk = nil
if request.onProgress then
request.onProgress(request.bodySent, request.bodySize)
end
end
end
-- Split the headers off once the blank line arrives, so the body can start going to disk rather
-- than accumulating in memory: a game is far too big to hold twice.
local function splitHead(request)
local at = request.buffer:find("\r\n\r\n", 1, true)
if not at then
return false
end
local head = request.buffer:sub(1, at - 1)
request.buffer = request.buffer:sub(at + 4)
request.status = tonumber(head:match("^HTTP/%d%.%d (%d%d%d)")) or 0
request.responseHeaders = {}
for name, value in head:gmatch("\r\n([^:\r\n]+):%s*([^\r\n]*)") do
request.responseHeaders[name:lower()] = value
end
request.length = tonumber(request.responseHeaders["content-length"])
request.got = 0
-- HTTP/1.1 may send the body in sized pieces instead of announcing a length; those are
-- unwrapped as they arrive and the last, empty, piece is the end of the body.
request.chunked = (request.responseHeaders["transfer-encoding"] or ""):lower():find("chunked", 1, true) ~= nil
request.chunkBuffer = ""
request.chunkLeft = nil
request.chunkDone = false
if request.toFile then
local file, err = io.open(request.toFile, "wb")
if not file then
fail(request, "cannot write " .. request.toFile .. ": " .. tostring(err))
return false
end
request.file = file
end
return true
end
local function consume(request, data)
request.got = request.got + #data
if request.file then
request.file:write(data)
else
request.parts[#request.parts + 1] = data
end
if request.onProgress then
request.onProgress(request.got, request.length)
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 body = request.file and "" or table.concat(request.parts)
-- A redirect is followed only for a plain GET, and only within the same scheme and host: the
-- point of a pin is lost if a 302 can walk the client somewhere else.
local location = request.responseHeaders["location"]
if location and request.status >= 300 and request.status < 400 and request.redirects < NET_REDIRECT_MAX then
local scheme, host = parseUrl(location)
if scheme == request.scheme and host == request.host then
request.redirects = request.redirects + 1
request.url = location
local _, _, port, path = parseUrl(location)
request.port, request.path = port, path
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0
request.chunked, request.chunkBuffer, request.chunkLeft, request.chunkDone = false, "", nil, false
if request.socket then pcall(function() request.socket:close() end) end
request.socket = nil
return beginConnect(request)
end
end
finish(request, true, { status = request.status, body = body, headers = request.responseHeaders,
bytes = request.got, file = request.toFile })
end
local function pumpReading(request)
local data, err, partial = request.socket:receive(NET_CHUNK)
local got = data or partial
if got and #got > 0 then
request.lastProgress = singeGetTicks()
if not request.responseHeaders then
request.buffer = request.buffer .. got
if splitHead(request) and #request.buffer > 0 then
local rest = request.buffer
request.buffer = ""
if request.chunked then
consumeChunked(request, rest)
else
consume(request, rest)
end
end
elseif request.chunked then
consumeChunked(request, got)
else
consume(request, got)
end
end
if not active[request.id] then
return -- A bad chunk failed the request from inside consumeChunked.
end
if request.responseHeaders and ((request.length and request.got >= request.length) or request.chunkDone) then
return complete(request)
end
if err == "closed" then
if request.responseHeaders then
return complete(request)
end
return fail(request, "the server closed before answering")
end
if err and err ~= "timeout" and err ~= "wantread" then
fail(request, "read failed: " .. tostring(err))
end
end
-- One slice of work for every request in flight. Cheap when nothing is happening.
--
-- The list is snapshotted first because a request that finishes calls back into the caller, and the
-- caller very reasonably starts another one from there -- a download that reports itself installed,
-- say. Adding to a table while pairs() walks it is not allowed, and the failure ("invalid key to
-- 'next'") names neither the table nor the callback that did it.
function netPump()
local running = {}
for id in pairs(active) do
running[#running + 1] = id
end
for _, id in ipairs(running) do
local request = active[id]
if request then
if singeGetTicks() - request.lastProgress > NET_TIMEOUT then
fail(request, "the server stopped responding")
elseif request.state == "connecting" then
-- Writable means the connect finished; LuaSec is wrapped around it only then.
local _, writable = socket.select(nil, { request.socket }, 0)
if writable and #writable > 0 then
request.lastProgress = singeGetTicks()
startTls(request)
end
elseif request.state == "handshake" then
pumpHandshake(request)
elseif request.state == "sending" then
pumpSending(request)
elseif request.state == "reading" then
pumpReading(request)
end
end
end
end
-- Start a request. opts: url, method, headers, body, toFile, onProgress. onDone(ok, result) is
-- called once, with result.status/body/headers on success or result.error on failure.
function netRequest(opts, onDone)
local scheme, host, port, path = parseUrl(opts.url)
if not scheme or (scheme ~= "http" and scheme ~= "https") then
onDone(false, { error = "not a URL this understands: " .. tostring(opts.url) })
return nil
end
local request = {
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
method = opts.method or "GET", headers = opts.headers, body = opts.body,
bodyFile = opts.bodyFile, toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(), sent = 0,
}
-- A body sent from a file: its length has to be known before the head goes out.
if request.bodyFile then
local handle = io.open(request.bodyFile, "rb")
if not handle then
onDone(false, { error = "cannot read " .. tostring(request.bodyFile) })
return nil
end
request.bodySize = handle:seek("end")
handle:close()
end
nextId = nextId + 1
active[request.id] = request
beginConnect(request)
return request.id
end
function netCancel(id)
local request = active[id]
if request then
request.onDone = nil
finish(request, false, { error = "canceled" })
end
end
function netBusy()
return next(active) ~= nil
end

BIN
assets/SingeText.glb (Stored with Git LFS)

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,77 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Default Mappings
-- How far an axis must move before it counts as pressed, in raw axis units: SDL reports 32767 at
-- full deflection, so 15000 is a little under half way. It covers every axis of every gamepad,
-- the two analog triggers included.
DEAD_ZONE = 15000
-- One axis at a time, for a stiff stick or a worn trigger, in the same raw units. Each entry is
-- { axis, dead zone }, where the axis is any GAMEPAD_N axis entry -- the axis itself or either of
-- its two directions, which all name the same axis. Anything not listed keeps DEAD_ZONE.
DEAD_ZONES = {
-- { GAMEPAD_0.AXIS_LEFT_X, 8000 },
-- { GAMEPAD_0.AXIS_LEFT_Y, 8000 },
}
-- How far an analog trigger must travel before it counts as a button, as a per cent of full
-- travel: Hypseus's unit, where its own default is 99.5. Zero leaves the triggers on DEAD_ZONE,
-- which is what Singe has always given them, and --trigger_threshold on the command line wins over
-- whatever is set here. 15000 raw units is 45.8 per cent, for comparison.
TRIGGER_THRESHOLD = 0
-- One table per switch, in the order the engine numbers them (SWITCH_UP = 0 ...).
-- Each entry is a { name, value } pair from the SCANCODE, GAMEPAD_N, or MOUSE_N tables, or a
-- chord: a list of two to four of them held together, such as
-- { GAMEPAD_0.BUTTON_BACK, GAMEPAD_0.BUTTON_START }. Each keeps its own meaning as well.
INPUT_UP = { SCANCODE.UP, SCANCODE.KP_8, GAMEPAD_0.AXIS_LEFT_Y_U, GAMEPAD_0.AXIS_RIGHT_Y_U, GAMEPAD_0.DPAD_UP }
INPUT_LEFT = { SCANCODE.LEFT, SCANCODE.KP_4, GAMEPAD_0.AXIS_LEFT_X_L, GAMEPAD_0.AXIS_RIGHT_X_L, GAMEPAD_0.DPAD_LEFT }
INPUT_DOWN = { SCANCODE.DOWN, SCANCODE.KP_2, GAMEPAD_0.AXIS_LEFT_Y_D, GAMEPAD_0.AXIS_RIGHT_Y_D, GAMEPAD_0.DPAD_DOWN }
INPUT_RIGHT = { SCANCODE.RIGHT, SCANCODE.KP_6, GAMEPAD_0.AXIS_LEFT_X_R, GAMEPAD_0.AXIS_RIGHT_X_R, GAMEPAD_0.DPAD_RIGHT }
INPUT_1P_START = { SCANCODE.MAIN_1, GAMEPAD_0.BUTTON_RIGHT_BUMPER }
INPUT_2P_START = { SCANCODE.MAIN_2 }
INPUT_ACTION_1 = { SCANCODE.SPACE, SCANCODE.LCTRL, GAMEPAD_0.BUTTON_A, MOUSE_0.BUTTON_RIGHT }
INPUT_ACTION_2 = { SCANCODE.LALT, GAMEPAD_0.BUTTON_B, MOUSE_0.BUTTON_MIDDLE }
INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT }
INPUT_1P_COIN = { SCANCODE.MAIN_5, SCANCODE.C, GAMEPAD_0.BUTTON_LEFT_BUMPER }
INPUT_2P_COIN = { SCANCODE.MAIN_6 }
INPUT_SKILL_EASY = { SCANCODE.KP_DIVIDE }
INPUT_SKILL_MEDIUM = { SCANCODE.KP_MULTIPLY }
INPUT_SKILL_HARD = { SCANCODE.KP_MINUS }
INPUT_SERVICE = { SCANCODE.MAIN_9 }
INPUT_TEST_MODE = { SCANCODE.F2 }
INPUT_RESET_CPU = { SCANCODE.F3 }
INPUT_SCREENSHOT = { SCANCODE.F12, SCANCODE.F11, GAMEPAD_0.BUTTON_BACK } -- F12 is taken by some desktops and debuggers, so F11 works too.
INPUT_QUIT = { SCANCODE.ESCAPE, SCANCODE.Q }
INPUT_PAUSE = { SCANCODE.P, GAMEPAD_0.BUTTON_START }
INPUT_CONSOLE = { SCANCODE.GRAVE }
INPUT_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 }
INPUT_TILT = { SCANCODE.T }
INPUT_GRAB = { SCANCODE.G }
INPUT_RECORD = { SCANCODE.F10 } -- Starts and stops recording the picture and the sound to singeNNN.mp4.
-- Not a control: the engine raises this one itself when a mouse or a light gun is unplugged, so a
-- gun game can say so on screen. Bind a key to it as well if you want to test what the game does.
INPUT_MOUSE_DISCONNECT = { }

BIN
assets/font.xcf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,243 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The shipped GUI theme: what a document looks like before a game styles it. Link it from a
* document with <link type="text/rcss" href="Singe/gui.rcss"/> and override what you like.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
font-family: FreeSans;
font-weight: bold;
font-size: 22dp;
color: #f0f0f0;
}
h1 {
display: block;
font-size: 40dp;
margin: 0 0 12dp 0;
color: #ffd35a;
}
h2 {
display: block;
font-size: 28dp;
margin: 0 0 8dp 0;
color: #ffd35a;
}
p {
display: block;
margin: 0 0 8dp 0;
}
div {
display: block;
}
.panel {
display: block;
background-color: #101828e0;
decorator: linear-gradient(180deg, #182640e0, #0c1424e0);
box-shadow: 0 6dp 14dp #00000090;
border: 2dp #ffd35a;
border-radius: 8dp;
padding: 16dp;
}
button,
input.submit {
display: inline-block;
padding: 8dp 20dp;
margin: 4dp;
background-color: #24344e;
decorator: linear-gradient(180deg, #2e4260, #1c2a42);
border: 2dp #6a86b8;
border-radius: 6dp;
color: #f0f0f0;
text-align: center;
cursor: pointer;
tab-index: auto;
transition: background-color border-color 0.12s;
}
button:hover,
input.submit:hover {
background-color: #33507a;
decorator: linear-gradient(180deg, #3f5f8e, #2a4470);
border-color: #ffd35a;
}
button:focus,
input.submit:focus {
border-color: #ffd35a;
}
button:active,
input.submit:active {
background-color: #ffd35a;
decorator: linear-gradient(180deg, #ffe08a, #f0c040);
color: #101828;
}
input.text,
input.password,
textarea {
display: inline-block;
padding: 6dp 10dp;
margin: 4dp;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
color: #f0f0f0;
cursor: text;
tab-index: auto;
}
input.text:focus,
input.password:focus,
textarea:focus {
border-color: #ffd35a;
}
input.checkbox,
input.radio {
display: inline-block;
width: 22dp;
height: 22dp;
margin: 0 8dp 0 0;
background-color: #0a1220;
border: 2dp #6a86b8;
vertical-align: middle;
tab-index: auto;
}
input.radio {
border-radius: 11dp;
}
input.checkbox:checked,
input.radio:checked {
background-color: #ffd35a;
}
input.range {
display: inline-block;
height: 24dp;
width: 240dp;
margin: 4dp;
tab-index: auto;
}
input.range slidertrack {
height: 6dp;
margin: 9dp 0;
background-color: #24344e;
border-radius: 3dp;
}
input.range sliderbar {
width: 22dp;
height: 22dp;
margin: -8dp 0;
background-color: #ffd35a;
border-radius: 11dp;
}
select {
display: inline-block;
width: 240dp;
padding: 6dp 10dp;
margin: 4dp;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
tab-index: auto;
}
select selectvalue {
width: auto;
margin-right: 30dp;
}
select selectarrow {
width: 24dp;
height: 24dp;
background-color: #6a86b8;
}
select selectbox {
background-color: #101828;
border: 2dp #6a86b8;
}
select selectbox option {
padding: 4dp 10dp;
}
select selectbox option:hover,
select selectbox option:checked {
background-color: #33507a;
}
progress {
display: block;
height: 18dp;
margin: 4dp 0;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 9dp;
}
progress fill {
background-color: #ffd35a;
decorator: linear-gradient(180deg, #ffe08a, #e6b830);
border-radius: 7dp;
}
scrollbarvertical {
width: 14dp;
background-color: #0a1220;
}
scrollbarvertical sliderbar {
background-color: #6a86b8;
border-radius: 7dp;
min-height: 24dp;
}
scrollbarvertical sliderbar:hover,
scrollbarvertical sliderbar:active {
background-color: #ffd35a;
}
scrollbarvertical sliderarrowdec,
scrollbarvertical sliderarrowinc {
height: 0;
}
.list {
display: block;
overflow-y: auto;
background-color: #0a1220;
border: 2dp #6a86b8;
border-radius: 4dp;
}
.list div {
padding: 6dp 10dp;
tab-index: auto;
}
.list div:hover,
.list div:focus,
.list div.selected {
background-color: #33507a;
color: #ffd35a;
}
.muted {
color: #8fa2c4;
}

BIN
assets/icon.xcf (Stored with Git LFS)

Binary file not shown.

BIN
assets/kangarooPunchLogo.xcf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,354 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The bundled menu's look on top of the shipped theme. Nothing here has a position or a size:
* Singe/Menu.singe places every region from the overlay size, so the attract video it draws
* underneath fills the #video hole exactly.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
width: 100%;
height: 100%;
font-size: 14dp;
}
/* Every region the script places; the border sits inside the rectangle it is given. */
.placed {
position: absolute;
box-sizing: border-box;
}
.hidden {
display: none;
}
/* The game list: the panel look (the theme's gradient and shadow), one row per game, the
selected one gold on a gradient. */
#list {
background-color: #0a1220d0;
decorator: linear-gradient(180deg, #182640d0, #0c1424d0);
box-shadow: 0 6dp 14dp #00000090;
padding: 4dp 0;
font-size: 13dp;
}
#list div {
display: flex;
align-items: center;
padding: 3dp 8dp;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
/* A title takes whatever the update mark leaves and ends in an ellipsis when that is not enough, so
a long title can never push the mark out of sight. The whole title heads the details. */
#list div .title {
display: block;
flex: 1 1 0dp;
min-width: 0dp;
overflow: hidden;
text-overflow: ellipsis;
}
#list div:hover {
background-color: #24344e;
color: #f0f0f0;
}
#list div:focus {
background-color: transparent;
color: #f0f0f0;
}
#list div.selected,
#list div.selected:hover,
#list div.selected:focus {
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
color: #ffd35a;
}
/* Poster and marquee: the script sets an image decorator that fits the picture inside. No border:
cabinet art and a marquee are pictures in their own right and a frame round them fights with
whatever frame the artwork already has. */
.art {
background-color: #0a122080;
}
/* The attract video shows through here; nothing is drawn over it. */
#video {
}
#info {
display: flex;
flex-direction: column;
padding: 6dp 10dp;
background-color: #101828d0;
border-color: #6a86b8;
}
#title {
font-size: 18dp;
color: #ffd35a;
margin-bottom: 2dp;
}
.line {
font-size: 11dp;
color: #c8d2e6;
}
.line .label {
color: #8fa2c4;
}
#description {
flex: 1;
margin-top: 4dp;
font-size: 13dp;
overflow-y: auto;
}
#footer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #101828d0;
border: 2dp #6a86b8;
border-radius: 4dp;
padding: 0 8dp;
}
#hints {
font-size: 12dp;
color: #8fa2c4;
}
/* A game with a newer version on the service wears a small mark after its title, and the line
under its details says which version and which button. Gold, like the selection, since it is
the one thing on the page asking for a press. */
#list div .badge {
flex: none;
margin-left: 6dp;
padding: 0 4dp;
font-size: 10dp;
color: #101828;
background-color: #ffd35a;
border-radius: 3dp;
}
#notice {
color: #ffd35a;
}
/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a
page taken out of view has to be said again here or its display wins. */
.tool.hidden,
.tool.clear.hidden {
display: none;
}
/* The service tools: one page over everything, the backdrop still moving behind it, and on it
a card in the game page's own dress -- the panel gradient, the blue-gray border, a title bar in
the selection's gradient with the title in the game list's gold, and the key hints in a footer
strip like the game page's. Nothing on it says black. */
.tool {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
padding: 10dp;
box-sizing: border-box;
display: flex;
flex-direction: column;
color: #f0f0f0;
font-size: 15dp;
}
.tool .card {
flex: 1 1 0;
display: flex;
flex-direction: column;
background-color: #0a1220d8;
decorator: linear-gradient(180deg, #182640d8, #0c1424d8);
border: 2dp #6a86b8;
border-radius: 6dp;
box-shadow: 0 6dp 14dp #00000090;
overflow: hidden;
}
/* The audio delay tool alone turns the whole screen white for the frame of its flash; the card
steps out of the way so the flash is the flash and nothing else. */
.tool.flash {
background-color: #ffffff;
}
.tool.flash .card {
visibility: hidden;
}
.tool h1 {
font-size: 20dp;
color: #ffd35a;
margin: 0;
padding: 8dp 14dp;
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
border-bottom: 2dp #6a86b8;
}
.tool .columns {
flex: 1 1 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
/* The style sheet can scroll this box, but nothing drives a scroll bar from a joystick: the
windowing in Singe/Tools.singe is what keeps the chosen row on screen. */
.tool #toolBody {
flex: 1 1 0;
padding: 12dp 14dp;
overflow-y: auto;
}
/* The list of tools has a second column: what the chosen tool is for, beside the list. */
.tool #toolNote {
flex: 0 0 42%;
padding: 12dp 16dp;
border-left: 2dp #6a86b8;
color: #c8d2e6;
font-size: 14dp;
}
.tool #toolNote.hidden {
display: none;
}
.tool #toolKeys {
padding: 6dp 14dp;
border-top: 2dp #6a86b8;
background-color: #101828d0;
color: #8fa2c4;
font-size: 13dp;
}
.tool p {
margin: 0;
}
/* The key hints, close together when a tool needs more than one line of them. */
.tool p.keys {
margin: 0;
}
.tool p.keys + p.keys {
margin-top: 2dp;
}
/* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text
with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an
ordinary word is left whole. */
.tool .row {
margin-bottom: 4dp;
word-break: break-word;
}
.tool .sub {
margin-left: 20dp;
margin-bottom: 3dp;
font-size: 13dp;
color: #c8d2e6;
word-break: break-word;
}
/* A label beside its value. The two are columns rather than one run of text, so a value too long
for the line wraps under itself instead of under the label. */
.tool .pair {
display: flex;
margin-bottom: 4dp;
}
.tool .pair .label {
flex: 0 0 130dp;
color: #8fa2c4;
}
.tool .pair .detail {
flex: 1 1 0;
word-break: break-word;
}
/* A line a tool offers to choose between, and the chosen one the way the game list shows its
selection: gold on the gradient bar. A detail the row carries -- a size, a state, a value --
sits against the right edge, so a column of them lines up whatever the text beside it. */
.tool .item {
display: flex;
padding: 3dp 8dp;
margin: 0 0 1dp -8dp;
border-radius: 3dp;
}
.tool .item .text {
flex: 1 1 0;
}
.tool .item .detail {
flex: 0 0 auto;
margin-left: 16dp;
text-align: right;
}
.tool .item.selected {
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
color: #ffd35a;
}
/* A gap a tool asked for. */
.tool .blank {
height: 12dp;
}
/* Two tools draw their own picture into the overlay, which is underneath the document: the light
gun's targets and the display's test pattern. Those pages let it through, and the card becomes
a strip along the bottom between the two lower targets, so nothing worth aiming at is covered.
Its width is fixed: left to fit its content, the value column of a pair had no room and broke
its words one letter to a line. */
.tool.clear .card {
flex: 0 0 auto;
margin-top: auto;
align-self: center;
width: 70%;
}
.tool.clear h1 {
font-size: 16dp;
padding: 4dp 12dp;
}
/* The strip is as tall as its rows: the columns, which fill the page otherwise, take what they
need here. The body inside them keeps its flex, which in their row is its width; without it
a pair's value column had no room. */
.tool.clear .columns {
flex: 0 0 auto;
}
.tool.clear #toolBody {
padding: 6dp 12dp;
}
.tool.clear #toolKeys {
padding: 4dp 12dp;
}
/* The help under the list of tools, away from the list itself. */
#toolHelp {
margin-top: 12dp;
}

BIN
assets/menuBackground.mkv (Stored with Git LFS)

Binary file not shown.

BIN
assets/menuIntro.flac (Stored with Git LFS)

Binary file not shown.

View file

@ -1,97 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The score panel's look: an arcade scoreboard's seven segment readouts, each a row of dim
* "all segments lit" digits with the live value glowing over them, so an unused digit still
* shows its ghost the way a real display does. This copy is rewritten at every start: a game
* restyles the panel with its own copies of scoreBezel.rml and a sheet beside its script.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
width: 100%;
height: 100%;
font-family: FreeSans;
font-weight: bold;
color: #ff3018;
}
/* The cabinet the readouts are set into. */
#board {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 12dp 24dp;
background-color: #060606;
decorator: linear-gradient(180deg, #1a1a1c, #050505);
border: 3dp #3a3a40;
border-radius: 10dp;
}
.cell {
display: block;
text-align: center;
padding: 0 10dp;
}
/* An empty second player cell leaves the row to the other two. */
.cell:empty {
padding: 0;
}
.label {
display: block;
font-size: 20dp;
letter-spacing: 3dp;
color: #c8b070;
margin: 0 0 4dp 0;
}
/* A readout stacks the live digits exactly over the dim ones, so both are the same size and
the same shape whatever the value is. The slant is the LED look. */
.readout {
display: block;
position: relative;
font-size: 54dp;
letter-spacing: 6dp;
transform: skewX(-7deg);
}
.readout.small {
font-size: 34dp;
letter-spacing: 3dp;
}
.ghost {
display: block;
color: #4d1108;
}
.value {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
color: #ff3018;
font-effect: glow(2dp 5dp #ff6a3cc0);
}
/* The life count sits under the score, smaller, with its own label beside it. */
.lives {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin: 6dp 0 0 0;
}
.lives .label {
font-size: 16dp;
margin: 0 8dp 0 0;
}

View file

@ -1,37 +0,0 @@
<rml>
<!--
Singe 3
Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
The score panel: the document behind the scoreBezel calls, an arcade scoreboard with credits
and a score and a life count for each of two players. The engine writes the digits into the
.value elements and empties #player2 when the second score is switched off, so everything the
panel looks like lives in Singe/scoreBezel.rcss and everything it says comes from the game. The
Singe folder is rewritten at every start, so a game wanting another look ships its own copy of
this file as scoreBezel.rml beside its script, linking a sheet of its own, and the engine uses
that instead.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>Score</title>
<link type="text/rcss" href="Singe/scoreBezel.rcss"/>
</head>
<body id="score">
<div id="board">
<div class="cell" id="player1">
<div class="label">1UP</div>
<div class="readout"><div class="ghost">888888</div><div class="value" id="score1">000000</div></div>
<div class="lives"><div class="label">LIVES</div><div class="readout small"><div class="ghost">8</div><div class="value" id="lives1">0</div></div></div>
</div>
<div class="cell" id="middle">
<div class="label">CREDITS</div>
<div class="readout"><div class="ghost">88</div><div class="value" id="credits">00</div></div>
</div>
<div class="cell" id="player2">
<div class="label">2UP</div>
<div class="readout"><div class="ghost">888888</div><div class="value" id="score2">000000</div></div>
<div class="lives"><div class="label">LIVES</div><div class="readout small"><div class="ghost">8</div><div class="value" id="lives2">0</div></div></div>
</div>
</div>
</body>
</rml>

View file

@ -1,140 +0,0 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- settings.cfg: the options you would otherwise type on every command line.
--
-- Copy this file to settings.cfg and uncomment what you want. Singe reads it
-- from the same four places it reads controls.cfg, in this order, and a later
-- one wins key by key:
--
-- 1. the game's own script directory: what the game ships
-- 2. the directory Singe was started in: the machine's own
-- 3. the directory above the game's data directory
-- 4. the game's data directory: yours, for that one game
--
-- Every key is the long name of a command line option without the leading
-- dashes, so there is no second vocabulary to learn, and the command line
-- always wins over anything set here. A games.dat entry for the game does
-- too, because what is here is a default and what is there describes the game.
-- A key that is not an option, or one that names the game or a path, is an
-- error naming this file and the key.
--
-- Options that take no value on the command line take true or false here.
-- Setting one to false is the same as leaving it out.
--
-- The value shown against each key is its default, or an example where the
-- default is worked out from your display or from the game.
-- The window ------------------------------------------------------------
-- screen = 1 -- which display, counting from 1; without it, the primary one
-- xresolution = 1280 -- window width in pixels; without it, one that fits the display
-- yresolution = 960 -- window height in pixels; without it, one that fits the width
-- aspect = "16:9" -- which window size table to pick from: 4:3, 16:9 or 16:10
-- fullscreen = false -- exclusive full screen at the desktop resolution
-- fullscreen_window = false -- borderless window covering the desktop
-- nologos = false -- skip the splash screens
-- canvas = "720x480" -- world size for a game with no disc
-- scenequality = "high" -- 3D detail: high, or low for a small GPU (half resolution, no antialiasing, no bloom)
-- Where the picture sits -------------------------------------------------
-- stretch = false -- fill the window instead of keeping the video's shape
-- scalefactor = 100 -- per cent of the window the picture fills, 25 to 100
-- shiftx = 0 -- move it inside the room scalefactor left, -100 to 100
-- shifty = 0
-- rotate = 0 -- turn the whole presentation clockwise: 0, 90, 180 or 270
-- linearscale = true -- smooth the overlay as the window scales it, or take the nearest pixel
-- monochrome = false -- start with the disc picture in gray
-- Cabinet artwork --------------------------------------------------------
-- bezel = "cabinet.png" -- artwork in the bezels folder, drawn around the picture
-- bezeldir = "bezels" -- the folder holding it
-- bezelflip = false -- draw the artwork in front of the picture instead of behind it
-- sindenedge = "video" -- where a Sinden border sits: video, or window with artwork loaded
-- Sound ------------------------------------------------------------------
-- nosound = false -- mute everything for the whole run
-- startsilent = false -- start muted until the first input of any kind
-- volume_vldp = 100 -- laserdisc volume in per cent
-- volume_nonvldp = 100 -- sound effect and extra video volume in per cent
-- audio = 0 -- which audio track of the disc video to play
-- altaudio = "" -- play <base><suffix>.ogg beside the disc video instead
-- audiodelay = 0 -- milliseconds the audio is heard late (negative if early)
-- soundfont = "" -- the .sf2 a MIDI file is synthesized with; without it, a search
-- Input ------------------------------------------------------------------
-- nomouse = false -- ignore mice and light guns
-- nogamepad = false -- ignore gamepads
-- nocrosshair = false -- ask the game not to draw its own crosshair
-- haptic = 4 -- strongest rumble step a game may use; 0 turns rumble off
-- keymapfile = "controls.cfg" -- read the mappings from this file and make no search
-- sindengun = "10 5" -- the Sinden light gun border
-- xratio = 0 -- gun coordinate scale a game reads with ratioGetX()
-- yratio = 0 -- ... and ratioGetY()
-- mapjoysticks = true -- write a gamepad mapping for a device SDL does not recognize
-- gamepad_reorder = "3210" -- which pad fills which slot, as enumeration positions from 0
-- trigger_threshold = 0 -- per cent of full travel a trigger counts as pressed at; 0 uses DEAD_ZONE
-- joymouse = false -- let the first gamepad's left stick drive the mouse cursor
-- js_range = 5 -- how fast it drives it, 1 to 20
-- manymouse = false -- tell the mice apart whatever the game asks for
-- absolutes_only = false -- keep only the mice that report absolute positions, which light guns do
-- Cabinets and front ends ------------------------------------------------
-- idleexit = 0 -- quit after this many seconds with no input; 0 never does
-- no_update_check = false -- never look for a newer Singe from the menu (Engine Update still works by hand)
-- quitchord = "back+start" -- first gamepad buttons held together that quit whatever a game maps
-- fvalue = 0 -- one number handed to the game, which reads it with getFValue()
-- Working on a game ------------------------------------------------------
-- reload = false -- rerun the game when one of its loose script files changes
-- debug = false -- connect every game to ZeroBrane Studio's debugger; true for this
-- machine's, or "HOST:PORT" for one elsewhere, as a cabinet would.
-- With nothing listening, each start waits up to two seconds
-- program = false -- trace engine activity to trace.txt
-- perfsync = false -- with program, show the GPU's time in each stage of the perf lines; slows everything
-- trace = false -- trace every Lua call to trace.txt
-- showcalculated = false -- print the frame ranges of every framefile segment
-- softwarevideo = false -- decode video in software even when the machine can do it in hardware
-- zerocopy = true -- hand hardware decoded frames to the GPU without copying them, where it can
-- deinterlace = "auto" -- what an interlaced picture gets: off, auto or on. A laserdisc held
-- interlaced fields, and a rip that kept them combs on a progressive
-- display; auto touches only the frames the file marks interlaced
-- deterministic = 15 -- testing only: ignore real time, run on a virtual clock stepped this
-- many milliseconds a frame and seed the random generators with the same
-- number, so a run repeats to the pixel. true takes the default 15.
-- Audio and pacing are meaningless in this mode.

BIN
assets/singeLogo.xcf (Stored with Git LFS)

Binary file not shown.

View file

@ -1,45 +0,0 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* What a subtitle looks like: centered lines of light text on a dark bar just wide enough to hold
* them, at whatever height the game asked for, over the picture. The engine only ever sets the
* contents of #slot and the top of .cue. This copy is rewritten at every start: a game restyles
* its subtitles with its own copies of subtitle.rml and a sheet beside its script.
* Licensed under the GNU General Public License, version 3 or later.
*/
body {
width: 100%;
height: 100%;
font-family: FreeSans;
font-weight: bold;
}
/* The document covers the picture, so a cue is placed down it as a per cent. */
#slot {
display: block;
position: relative;
width: 100%;
height: 100%;
}
.cue {
display: block;
position: absolute;
left: 0;
width: 100%;
text-align: center;
}
/* The bar hugs the text rather than spanning the picture, as a subtitle's does. */
.cue .text {
display: inline-block;
padding: 4dp 18dp;
font-size: 30dp;
line-height: 36dp;
color: #ffffff;
background-color: #141414e0;
border-radius: 4dp;
font-effect: shadow(1dp 1dp #000000ff);
}

View file

@ -1,21 +0,0 @@
<rml>
<!--
Singe 3
Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
Subtitles: the document behind srtLoad, srtEnable, srtPosition and overlayBanner. The engine
writes one cue, or one banner, into #slot as a .cue whose inline style carries the height it was
asked for, so everything a subtitle looks like lives in Singe/subtitle.rcss and everything it
says comes from the game or from its .srt file. The Singe folder is rewritten at every start, so
a game wanting another look ships its own copy of this file as subtitle.rml beside its script,
linking a sheet of its own, and the engine uses that instead.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>Subtitles</title>
<link type="text/rcss" href="Singe/subtitle.rcss"/>
</head>
<body id="subtitle">
<div id="slot"></div>
</body>
</rml>

View file

@ -1,231 +0,0 @@
#!/bin/bash
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Builds Singe and every vendored library for one platform, or all of them. The work is done
# by the CMake superbuild (cmake/Superbuild.cmake, one preset per platform in
# CMakePresets.json); this script installs the host packages and calls it:
#
# ./build-all.sh every supported platform
# ./build-all.sh linux x86_64 one platform
#
# Anything cmake --build accepts can follow the platform, so a single library can be redone:
#
# ./build-all.sh linux x86_64 --target rebuild-ffmpeg
G_BUILDDIR=.builddir
G_INSTALL="$(dirname "$0")/INSTALL"
# The one copy of the host package list. Everything else that names these packages derives from it.
# Grouped by what wants them, because the SDL3 half is long and a bare list gives no clue why a
# package is here: SDL3 compiles a backend whenever it finds the headers, so a package missing from
# this list does not fail the build, it quietly drops a backend from the binary.
G_HOSTPACKAGES=(
# Toolchain
build-essential
cmake
git-lfs
pkg-config
perl
nasm
llvm
autoconf
automake
libtool
# dav1d, the AV1 decoder, is the one vendored library whose own build system is Meson
meson
ninja-build
# Content the build generates: embedded images, the menu video, the LuaSec table, the manual
imagemagick
ffmpeg
lua5.4
asciidoctor
ruby-asciidoctor-pdf
# Video decode and display
libva-dev
libvdpau-dev
libdrm-dev
libgl-dev
libegl-dev
libgles-dev
libgbm-dev
# SDL3 audio backends
libasound2-dev
libpulse-dev
libpipewire-0.3-dev
libjack-jackd2-dev
libsndio-dev
# SDL3 device hotplug, desktop integration and input methods
libudev-dev
libdbus-1-dev
libibus-1.0-dev
libxkbcommon-dev
# SDL3 X11 video backend and the extensions it uses
libx11-dev
libxext-dev
libxfixes-dev
libxi-dev
libxcursor-dev
libxrandr-dev
libxss-dev
libxtst-dev
# SDL3 Wayland video backend
libwayland-dev
wayland-protocols
libdecor-0-dev
)
G_INSTALLLEAD="Host packages (what build-all.sh installs):"
G_INSTALLWIDTH=72
function buildAll() {
local OS=$1
local ARCH=$2
shift 2
# Every platform builds with the zig the build fetches itself; the Pi preset also fetches its
# platform packages and macOS needs the Apple SDK tarball (see INSTALL).
# "macos universal" builds both macOS presets and joins them with llvm-lipo.
if [[ "${OS}" == "macos" && "${ARCH}" == "universal" ]]; then
buildAll macos aarch64 "$@"
buildAll macos x86_64 "$@"
cmake -P cmake/zig/macosUniversal.cmake
return
fi
cmake --preset ${OS}-${ARCH}
cmake --build --preset ${OS}-${ARCH} "$@"
}
# assets/ and media/ hold the artwork, the font, the models, and the menu video in Git LFS. A clone made without git-lfs
# leaves pointer stubs -- a hundred and thirty bytes of text where a video should be -- and the
# resource generators embed them without complaining, so the failure only shows up at run time as a
# menu with no background. One sentinel is enough to tell the two apart.
function checkoutHydrated() {
local SENTINEL="$(dirname "$0")/assets/menuBackground.mkv"
# A sentinel that is not there at all is not a hydrated checkout either, and saying so beats
# passing quietly because the path was wrong.
[[ -f "${SENTINEL}" ]] || return 1
! head -c 64 "${SENTINEL}" | grep -q "git-lfs.github.com/spec"
}
# The package list as INSTALL wants to read it: one sentence, wrapped, ending in a full stop.
function installParagraph() {
local LINE="${G_INSTALLLEAD}"
local SEP=" "
local WORD
for WORD in "${G_HOSTPACKAGES[@]}"; do
if (( ${#LINE} + ${#SEP} + ${#WORD} > G_INSTALLWIDTH )); then
printf '%s\n' "${LINE}"
LINE="${WORD}"
else
LINE="${LINE}${SEP}${WORD}"
fi
SEP=" "
done
printf '%s.\n' "${LINE}"
}
# What INSTALL says today: from the lead line to the first line that ends the sentence.
function installCurrent() {
awk -v lead="${G_INSTALLLEAD}" '
index($0, lead) == 1 { found = 1 }
found { print; if (/\.$/) exit }
' "${G_INSTALL}"
}
function installMatches() {
# A checkout without INSTALL is not worth nagging about; there is nothing to disagree with.
[[ ! -f "${G_INSTALL}" ]] || [[ "$(installParagraph)" == "$(installCurrent)" ]]
}
# Writes the paragraph back into INSTALL, replacing whatever sentence is there now.
function installSync() {
local TEMP
TEMP=$(mktemp)
awk -v lead="${G_INSTALLLEAD}" -v replacement="$(installParagraph)" '
index($0, lead) == 1 && !done {
print replacement
skip = 1
}
skip { if (/\.$/) { skip = 0; done = 1 } next }
{ print }
' "${G_INSTALL}" > "${TEMP}"
mv "${TEMP}" "${G_INSTALL}"
}
# -e = stop script on errors
# -u = stop script on undefined variable
# -o pipefail = stop pipeline if any step fails
set -euo pipefail
case "${1:-}" in
--packages)
printf '%s\n' "${G_HOSTPACKAGES[@]}"
exit 0
;;
--sync-install)
installSync
echo "INSTALL rewritten from build-all.sh."
exit 0
;;
esac
mkdir -p ${G_BUILDDIR}
# Host packages the build needs on a Debian based system. INSTALL prints the same list for a
# reader who has not run anything yet, so it is written from this one: --sync-install rewrites
# that paragraph and every build checks it, which is why there is no second list to keep in step.
if ! installMatches; then
echo "warning: INSTALL's host package list no longer matches this script."
echo " Run ./build-all.sh --sync-install to write it from here."
fi
sudo apt-get install -y "${G_HOSTPACKAGES[@]}"
if ! checkoutHydrated; then
echo "error: assets/ and media/ hold git-lfs pointer files, not the real artwork, font and video."
echo " The build would embed those stubs and produce a broken binary."
echo " Fix the checkout with: git lfs install && git lfs pull"
exit 1
fi
if [[ $# -ge 2 ]]; then
buildAll "$@" 2>&1 | tee ${G_BUILDDIR}/$1-$2.log
else
buildAll linux x86_64 2>&1 | tee ${G_BUILDDIR}/linux-x86_64.log
buildAll macos universal 2>&1 | tee ${G_BUILDDIR}/macos-universal.log
buildAll linux aarch64 2>&1 | tee ${G_BUILDDIR}/linux-aarch64.log
buildAll windows x86_64 2>&1 | tee ${G_BUILDDIR}/windows-x86_64.log
buildAll windows aarch64 2>&1 | tee ${G_BUILDDIR}/windows-aarch64.log
fi

View file

@ -1,131 +0,0 @@
#!/usr/bin/env bash
# Build the three books in every language they are kept in: the user manual (docs/Manual.adoc,
# which ships inside the binary), the programming reference (docs/Reference.adoc), and the
# beginner's course (docs/Learn.adoc), in English and in each translation, a folder under docs/
# holding a locale.adoc (docs/fr, docs/it, docs/de, docs/es). Produces .builddir/<Book>.html and
# .pdf for English and .builddir/<language>/<Book>.html and .pdf for each translation (a language's
# books sit together, so the links between them work), and then packs the two books that do not
# ship in the binary, with the lesson files and the license, into .builddir/Singe-Docs-v<version>.zip
# and .builddir/Singe-Docs-v<version>-<language>.zip, the optional documentation downloads. The
# version comes from CMakeLists.txt. Last, the ZeroBrane Studio package's API in every language,
# zbstudio/packages/singe/api*.lua, is made again from the Reference (util/zbstudioApi.py).
#
# ./build-docs.sh the books as they ship
# ./build-docs.sh preview the same, with PREVIEW across every page, for proofreaders, all of it
# under .builddir/preview so neither a release nor the binary's own
# copies of the manual (rendered by CMake into .builddir) are touched;
# the downloads are named -preview so they cannot be mistaken for a
# release, and they carry the Manual too, which a release download
# leaves to the binary.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
preview=
suffix=
if [[ "${1:-}" == "preview" ]]; then
preview=1
suffix=-preview
elif [[ -n "${1:-}" ]]; then
echo "usage: $0 [preview]" >&2
exit 2
fi
books=(Manual Learn Reference)
# English is docs/ itself; every other language is a folder of docs/ with its captions in it.
languages=(en)
for locale in "$here"/docs/*/locale.adoc; do
languages+=("$(basename "$(dirname "$locale")")")
done
for language in "${languages[@]}"; do
for book in "${books[@]}"; do
doc=$here/docs/$book.adoc
if [[ $language != en ]]; then
doc=$here/docs/$language/$book.adoc
fi
if [[ ! -f $doc ]]; then
echo "error: $doc not found" >&2
exit 1
fi
done
done
for tool in asciidoctor asciidoctor-pdf zip; do
if ! command -v "$tool" > /dev/null; then
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
exit 1
fi
done
# The version, from the four numbers of the project() line: patch 1 means a beta and tweak says
# which, so 3.00.1.2 is the books' "3.00b2", exactly as the binary is named.
read -r major minor patch tweak <<< "$(sed -n 's/^project(singe2 VERSION \([0-9]*\)\.\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1 \2 \3 \4/p' "$here/CMakeLists.txt")"
if [[ $patch == 1 ]]; then
version="${major}.${minor}b${tweak}"
else
version="${major}.${minor}"
fi
# compress deflates the PDFs' streams, which takes most of them to under half their size. The
# watermark, when asked for: a page background for the PDFs, a head snippet for the HTML.
pdfOpts=(-a compress)
# The HTML pages carry their pictures inside them (data-uri), so a page is one file wherever it is
# copied and the download needs no images folder beside it.
htmlOpts=(-a data-uri)
out=$here/.builddir
if [[ -n $preview ]]; then
pdfOpts+=(-a "page-background-image=image:$here/docs/images/preview.svg[fit=fill]")
htmlOpts+=(-a docinfo=shared -a "docinfodir=$here/docs/preview")
out=$here/.builddir/preview
fi
built=()
for language in "${languages[@]}"; do
src=$here/docs
dest=$out
name=Singe-Docs-v$version
if [[ $language != en ]]; then
src=$here/docs/$language
dest=$out/$language
name=Singe-Docs-v$version-$language
fi
mkdir -p "$dest"
for book in "${books[@]}"; do
asciidoctor "${htmlOpts[@]}" -a revnumber="$version" "$src/$book.adoc" -o "$dest/$book.html"
asciidoctor-pdf "${pdfOpts[@]}" -a revnumber="$version" "$src/$book.adoc" -o "$dest/$book.pdf"
built+=("$dest/$book.html" "$dest/$book.pdf")
done
# The documentation download: everything but the user manual, which the binary carries, except
# in a preview, where the Manual goes to the proofreaders with the rest. The README's lines marked
# @PREVIEW@ are kept for a preview and dropped for a release. Built in a staging folder so the zip
# has one top-level directory and no stray build files.
stage=$out/$name$suffix
rm -rf "$stage" "$stage.zip"
mkdir -p "$stage/learn"
cp "$dest/Reference.pdf" "$dest/Reference.html" "$dest/Learn.pdf" "$dest/Learn.html" "$stage/"
previewLines=/^@PREVIEW@/d
if [[ -n $preview ]]; then
cp "$dest/Manual.pdf" "$dest/Manual.html" "$stage/"
previewLines=s/^@PREVIEW@//
fi
cp -r "$here/docs/learn/." "$stage/learn/"
cp "$here/COPYING" "$stage/"
sed -e "s/@VERSION@/$version/" -e "$previewLines" "$src/downloadReadme.txt" > "$stage/README.txt"
(cd "$out" && zip -q -r "$name$suffix.zip" "$name$suffix")
rm -rf "$stage"
built+=("$stage.zip")
done
# ZeroBrane Studio's autocompletion and tooltips are made from the Reference, so they are made again
# with it, from the names a Singe built here defines; the next build embeds them. Without that
# Singe or python3 they stay as they are, and this says so. A preview leaves them alone.
if [[ -z $preview ]]; then
if command -v python3 > /dev/null && [[ -x $here/.builddir/Singe-v$version-Linux-x86_64 ]]; then
python3 "$here/util/zbstudioApi.py"
else
echo "note: ZeroBrane Studio API left as it was (needs python3 and .builddir/Singe-v$version-Linux-x86_64)"
fi
fi
echo "built:"
ls -la "${built[@]}"

View file

@ -1,478 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Superbuild: builds every vendored library into BUILD_DIR, then Singe against them.
# Entered from CMakeLists.txt when SINGE_SUPERBUILD is ON (the presets set it). Each
# library is an ExternalProject with its own configure, build and install steps, so a
# change to one rebuilds only it and what depends on it:
# cmake --build --preset linux-x86_64 --target rebuild-ffmpeg
# The toolchain environment (PATH, SYSROOT, osxcross variables) comes from
# ../toolchains/toolchains.sh; build-all.sh sources it before calling cmake.
include(ExternalProject)
include(ProcessorCount)
ProcessorCount(SB_JOBS)
if(SB_JOBS EQUAL 0)
set(SB_JOBS 4)
endif()
# Target description: the presets pass these; a plain cmake invocation can take them from
# the environment toolchains.sh exports.
foreach(pair "SINGE_TRIPLE;TRIPLE" "SINGE_CROSS_OS;CROSS_OS" "SINGE_SUFFIX;SUFFIX")
list(GET pair 0 var)
list(GET pair 1 env)
if(NOT DEFINED ${var})
set(${var} "$ENV{${env}}")
endif()
endforeach()
if(NOT SINGE_TRIPLE OR NOT SINGE_CROSS_OS)
message(FATAL_ERROR "SINGE_TRIPLE and SINGE_CROSS_OS must be set (use a preset or source toolchains.sh).")
endif()
set(SB_PREFIX ${BUILD_DIR})
set(SB_THIRDPARTY ${CMAKE_SOURCE_DIR}/thirdparty)
file(MAKE_DIRECTORY ${SB_PREFIX}/include ${SB_PREFIX}/lib)
# Compilers as the toolchain resolved them; the configure based libraries get them by name.
set(SB_CC "${CMAKE_C_COMPILER}")
if(CMAKE_CXX_COMPILER)
set(SB_CXX "${CMAKE_CXX_COMPILER}")
elseif(DEFINED ENV{CXX})
set(SB_CXX "$ENV{CXX}")
else()
string(REGEX REPLACE "gcc$" "g++" SB_CXX "${SB_CC}")
string(REGEX REPLACE "clang$" "clang++" SB_CXX "${SB_CXX}")
endif()
if(CMAKE_RANLIB)
set(SB_RANLIB "${CMAKE_RANLIB}")
elseif(DEFINED ENV{RANLIB})
set(SB_RANLIB "$ENV{RANLIB}")
else()
set(SB_RANLIB ranlib)
endif()
# The flags build-all.sh exported for every library: our install tree first, then the
# platform's. The Pi sysroot and the osxcross linker come from the toolchain.
set(SB_CFLAGS_BASE "$ENV{CFLAGS}")
set(SB_LDFLAGS "-L${SB_PREFIX}/lib $ENV{LDFLAGS}")
if(CMAKE_SYSROOT)
set(SB_CFLAGS_BASE "--sysroot=${CMAKE_SYSROOT} ${SB_CFLAGS_BASE}")
endif()
set(SB_CFLAGS "-I${SB_PREFIX}/include ${SB_CFLAGS_BASE}")
set(SB_PKG_CONFIG_LIBDIR "${SB_PREFIX}/lib/pkgconfig")
# A cross build looks inside its sysroot; a build for this machine looks at this machine. The
# sysroot is what tells them apart, not the platform's name.
if(SINGE_SYSROOT)
set(SB_PKG_CONFIG_LIBDIR "${SB_PKG_CONFIG_LIBDIR}:${SINGE_SYSROOT}/usr/lib/${SINGE_TRIPLE}/pkgconfig:${SINGE_SYSROOT}/usr/lib/pkgconfig:${SINGE_SYSROOT}/usr/share/pkgconfig")
elseif(KANGAROO_OS STREQUAL "linux")
set(SB_PKG_CONFIG_LIBDIR "${SB_PKG_CONFIG_LIBDIR}:/usr/lib/${SINGE_TRIPLE}/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig")
elseif(DEFINED ENV{PKG_CONFIG_LIBDIR})
set(SB_PKG_CONFIG_LIBDIR "${SB_PKG_CONFIG_LIBDIR}:$ENV{PKG_CONFIG_LIBDIR}")
endif()
# Environment every step runs under. CFLAGS is a parameter because SDL3_image's vendored
# libpng must not see the zlib headers under the install prefix.
function(singeStepEnv outVar cflags)
set(env ${CMAKE_COMMAND} -E env
"CC=${SB_CC}"
"CXX=${SB_CXX}"
"RANLIB=${SB_RANLIB}"
"CFLAGS=${cflags}"
"CXXFLAGS=${cflags}"
"LDFLAGS=${SB_LDFLAGS}"
"LD_LIBRARY_PATH=${SB_PREFIX}/lib"
"PKG_CONFIG_LIBDIR=${SB_PKG_CONFIG_LIBDIR}"
"MAKEFLAGS=-j${SB_JOBS}"
)
if(CMAKE_AR)
list(APPEND env "AR=${CMAKE_AR}")
endif()
if(SINGE_SYSROOT)
list(APPEND env "PKG_CONFIG_SYSROOT_DIR=${SINGE_SYSROOT}")
endif()
set(${outVar} ${env} PARENT_SCOPE)
endfunction()
singeStepEnv(SB_ENV "${SB_CFLAGS}")
singeStepEnv(SB_ENV_NO_PREFIX_INCLUDE "${SB_CFLAGS_BASE}")
set(SB_CMAKE_ARGS
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_INSTALL_PREFIX=${SB_PREFIX}
-DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE}
)
# A vendored CMake project: name, source directory, its dependencies, extra cache arguments,
# and the step environment to use.
function(singeCmakeProject name source deps args env)
set(binary ${SB_PREFIX}/build/${name})
ExternalProject_Add(${name}
SOURCE_DIR ${source}
BINARY_DIR ${binary}
DEPENDS ${deps}
CONFIGURE_COMMAND ${env} ${CMAKE_COMMAND} -S ${source} -B ${binary} ${SB_CMAKE_ARGS} ${args}
BUILD_COMMAND ${env} ${CMAKE_COMMAND} --build ${binary} --parallel ${SB_JOBS}
INSTALL_COMMAND ${env} ${CMAKE_COMMAND} --build ${binary} --target install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
singeRebuildTarget(${name})
endfunction()
# rebuild-<name> forgets the stamps and the build directory and builds the library again, then whatever depends on it
# is rebuilt by the normal build.
function(singeRebuildTarget name)
ExternalProject_Get_Property(${name} STAMP_DIR BINARY_DIR)
add_custom_target(rebuild-${name}
COMMAND ${CMAKE_COMMAND} -E rm -rf ${STAMP_DIR} ${BINARY_DIR}
COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR} --target ${name}
COMMENT "Rebuilding ${name}"
)
endfunction()
# ===== zlib (configure script; the generated zconf.h lives in the build directory) =====
set(zlibBinary ${SB_PREFIX}/build/zlib)
ExternalProject_Add(zlib
SOURCE_DIR ${SB_THIRDPARTY}/zlib
BINARY_DIR ${zlibBinary}
CONFIGURE_COMMAND ${CMAKE_COMMAND} -E env "CC=${SB_CC}" "AR=${CMAKE_AR}" "RANLIB=${SB_RANLIB}" "CFLAGS=-I${zlibBinary} ${SB_CFLAGS_BASE}" ${SB_THIRDPARTY}/zlib/configure --prefix=${SB_PREFIX} --static
BUILD_COMMAND ${SB_ENV} make
INSTALL_COMMAND ${SB_ENV} make install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
singeRebuildTarget(zlib)
# ===== zstd =====
singeCmakeProject(zstd ${SB_THIRDPARTY}/zstd/build/cmake "" "-DZSTD_BUILD_SHARED=off;-DZSTD_BUILD_STATIC=on;-DZSTD_BUILD_PROGRAMS=off" "${SB_ENV}")
# ===== SDL3 and its satellites =====
singeCmakeProject(SDL3 ${SB_THIRDPARTY}/SDL3 "" "-DSDL_SHARED=off;-DSDL_STATIC=on;-DSDL_TESTS=off;-DSDL_EXAMPLES=off" "${SB_ENV}")
# ===== Host tools: the shader compiler (see cmake/hostTools.cmake) =====
include(${CMAKE_CURRENT_LIST_DIR}/hostTools.cmake)
# ===== Jolt Physics (C++; static, single precision, no tests or samples) =====
# The x86 baseline is SSE4.1/4.2 so a shipped binary runs on anything from 2008 on; AVX and the
# bit-count instructions would silently raise it. No link-time optimization through the cross
# compiler, no warnings-as-errors with a clang Jolt has not met, nothing but the library; the GPU
# compute paths (hair and soft bodies on DX12, Vulkan, Metal or a CPU fallback) would need a shader
# compiler at build time and are not used.
set(joltOptions
-DTARGET_UNIT_TESTS=OFF -DTARGET_HELLO_WORLD=OFF -DTARGET_PERFORMANCE_TEST=OFF -DTARGET_SAMPLES=OFF -DTARGET_VIEWER=OFF
-DJPH_BUILD_SHARED_LIBS=OFF -DDOUBLE_PRECISION=OFF -DCROSS_PLATFORM_DETERMINISTIC=OFF
-DUSE_SSE4_1=ON -DUSE_SSE4_2=ON -DUSE_AVX=OFF -DUSE_AVX2=OFF -DUSE_AVX512=OFF -DUSE_F16C=OFF -DUSE_FMADD=OFF -DUSE_LZCNT=OFF -DUSE_TZCNT=OFF
-DINTERPROCEDURAL_OPTIMIZATION=OFF -DENABLE_ALL_WARNINGS=OFF -DGENERATE_DEBUG_SYMBOLS=OFF
-DDEBUG_RENDERER_IN_DEBUG_AND_RELEASE=OFF -DDEBUG_RENDERER_IN_DISTRIBUTION=ON -DFLOATING_POINT_EXCEPTIONS_ENABLED=OFF -DCPP_EXCEPTIONS_ENABLED=OFF -DCPP_RTTI_ENABLED=OFF
-DJPH_USE_DX12=OFF -DJPH_USE_VK=OFF -DJPH_USE_MTL=OFF -DJPH_USE_CPU_COMPUTE=OFF
)
singeCmakeProject(Jolt ${SB_THIRDPARTY}/JoltPhysics/Build "" "${joltOptions}" "${SB_ENV}")
# ===== Recast Navigation (C++; static, the libraries only) =====
singeCmakeProject(recastnavigation ${SB_THIRDPARTY}/recastnavigation ""
"-DRECASTNAVIGATION_DEMO=OFF;-DRECASTNAVIGATION_TESTS=OFF;-DRECASTNAVIGATION_EXAMPLES=OFF;-DBUILD_SHARED_LIBS=OFF"
"${SB_ENV}")
set(sdl3Dir -DSDL3_DIR=${SB_PREFIX}/lib/cmake/SDL3)
# Brotli, for the WOFF2 web fonts FreeType reads only when it is present. It is built before
# SDL3_image and SDL3_ttf so that it is in the prefix when FreeType looks for it.
#
# libjxl vendors brotli as well, and its install writes libbrotlidec.a and libbrotlicommon.a over
# the ones built here. That is harmless and is left alone: it is the same upstream library, the
# two FreeType links come from libjxl's tree as a matched pair, and only the decoder is ever linked
# -- libbrotlienc.a is the one file of this project's own build that survives, and nothing asks for
# it. Telling libjxl to use the copy already installed (JPEGXL_FORCE_SYSTEM_BROTLI) does work, but
# SDL3_image then tries to install brotli targets that no longer exist, and patching its install
# list is a worse trade than one duplicated build.
singeCmakeProject(brotli ${SB_THIRDPARTY}/brotli "" "-DBUILD_SHARED_LIBS=off;-DBROTLI_BUILD_TOOLS=off;-DBROTLI_DISABLE_TESTS=on" "${SB_ENV}")
singeCmakeProject(SDL3_image ${SB_THIRDPARTY}/SDL3_image "SDL3;zlib;brotli"
"-DBUILD_SHARED_LIBS=off;-DSDLIMAGE_DEPS_SHARED=off;-DSDLIMAGE_SAMPLES=off;-DSDLIMAGE_TESTS=off;-DSDLIMAGE_VENDORED=on;-DSDLIMAGE_BACKEND_STB=off;-DSDLIMAGE_AVIF=off;-DSDLIMAGE_JXL=on;-DSDLIMAGE_JXL_SHARED=off;-DBUILD_TESTING=OFF;-DSDLIMAGE_TIF=off;-DSDLIMAGE_WEBP=on;${sdl3Dir};-DWEBP_BUILD_ANIM_UTILS=off;-DWEBP_BUILD_CWEBP=off;-DWEBP_BUILD_DWEBP=off;-DWEBP_BUILD_GIF2WEBP=off;-DWEBP_BUILD_IMG2WEBP=off;-DWEBP_BUILD_VWEBP=off;-DWEBP_BUILD_WEBPINFO=off;-DWEBP_BUILD_WEBPMUX=off;-DWEBP_BUILD_EXTRAS=off"
"${SB_ENV_NO_PREFIX_INCLUDE}")
# SDL3_image builds libpng and installs the archive but not its headers, and FreeType needs both
# before it will read a font's color bitmap glyphs. pnglibconf.h is generated, so it comes from
# the build tree rather than the source one.
ExternalProject_Add_Step(SDL3_image pngHeaders
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${SB_THIRDPARTY}/SDL3_image/external/libpng/png.h ${SB_THIRDPARTY}/SDL3_image/external/libpng/pngconf.h ${SB_PREFIX}/build/SDL3_image/external/libpng-build/pnglibconf.h ${SB_PREFIX}/include
DEPENDEES install
COMMENT "Installing the libpng headers"
)
# Opus's NEON code on ARM comes with a run time check for NEON, and on Windows that check is written
# for MSVC only; MinGW gets "no CPU detection method available". Opus's CMake cannot presume NEON
# without the check either (it declares the NEON functions only alongside it), so on ARM64 Windows
# Opus is built from its plain C, which voice and .opus music need little of.
set(mixerOpusOptions "")
if((KANGAROO_OS STREQUAL "windows") AND (KANGAROO_ARCH STREQUAL "aarch64"))
set(mixerOpusOptions ";-DOPUS_DISABLE_INTRINSICS=ON")
endif()
singeCmakeProject(SDL3_mixer ${SB_THIRDPARTY}/SDL3_mixer "SDL3"
"-DBUILD_SHARED_LIBS=off;-DSDLMIXER_DEPS_SHARED=off;-DSDLMIXER_VENDORED=on;-DSDLMIXER_EXAMPLES=off;-DSDLMIXER_TESTS=off;-DSDLMIXER_FLAC_LIBFLAC=off;-DSDLMIXER_MP3_MPG123=off;-DSDLMIXER_VORBIS_STB=on;-DSDLMIXER_VORBIS_VORBISFILE=off;-DSDLMIXER_VORBIS_TREMOR=off;-DSDLMIXER_GME=on;-DSDLMIXER_GME_SHARED=off;-DGME_BUILD_EXAMPLES=off;-DGME_BUILD_TESTING=off;-DGME_YM2612_EMU=MAME;-DSDLMIXER_MIDI_FLUIDSYNTH=off;${sdl3Dir};-DWAVPACK_ENABLE_ASM=no${mixerOpusOptions}"
"${SB_ENV}")
singeCmakeProject(SDL3_ttf ${SB_THIRDPARTY}/SDL3_ttf "SDL3;zlib;brotli;SDL3_image"
"-DBUILD_SHARED_LIBS=off;-DSDLTTF_VENDORED=on;-DSDLTTF_HARFBUZZ=on;-DSDLTTF_PLUTOSVG=on;-DHB_HAVE_CORETEXT=off;-DSDLTTF_SAMPLES=off;-DFT_DISABLE_ZLIB=off;-DFT_DISABLE_BROTLI=off;-DZLIB_INCLUDE_DIR=${SB_PREFIX}/include;-DZLIB_LIBRARY=${SB_PREFIX}/lib/libz.a;-DBROTLIDEC_INCLUDE_DIRS=${SB_PREFIX}/include;-DBROTLIDEC_LIBRARIES=${SB_PREFIX}/lib/libbrotlidec.a;-DFT_DISABLE_PNG=off;-DPNG_PNG_INCLUDE_DIR=${SB_PREFIX}/include;-DPNG_LIBRARY=${SB_PREFIX}/lib/libpng16.a;${sdl3Dir}"
"${SB_ENV}")
# SDL3_ttf builds its vendored FreeType but does not install it, and Singe links it directly.
ExternalProject_Add_Step(SDL3_ttf freetype
COMMAND ${CMAKE_COMMAND} -E copy ${SB_PREFIX}/build/SDL3_ttf/external/freetype/libfreetype.a ${SB_PREFIX}/lib/libfreetype.a
DEPENDEES install
COMMENT "Installing libfreetype.a"
)
# ===== RmlUi (C++; static, the core, the Lua plugin and the debugger; no samples) =====
# FreeType is the copy SDL3_ttf builds (installed as libfreetype.a below, headers from its tree);
# Lua is the engine's own, handed over as a header-only target by cmake/rmluiLua.cmake.
set(rmluiOptions
-DBUILD_SHARED_LIBS=OFF -DRMLUI_SAMPLES=OFF -DRMLUI_PRECOMPILED_HEADERS=OFF
-DRMLUI_FONT_ENGINE=freetype -DRMLUI_LUA_BINDINGS=ON -DRMLUI_LUA_BINDINGS_LIBRARY=lua
-DFREETYPE_LIBRARY=${SB_PREFIX}/lib/libfreetype.a
-DFREETYPE_INCLUDE_DIR_ft2build=${SB_THIRDPARTY}/SDL3_ttf/external/freetype/include
-DFREETYPE_INCLUDE_DIR_freetype2=${SB_THIRDPARTY}/SDL3_ttf/external/freetype/include
-DSINGE_LUA_INCLUDE=${SB_THIRDPARTY}/lua/src
-DCMAKE_PROJECT_RmlUi_INCLUDE=${CMAKE_CURRENT_LIST_DIR}/rmluiLua.cmake
)
singeCmakeProject(RmlUi ${SB_THIRDPARTY}/RmlUi "SDL3_ttf" "${rmluiOptions}" "${SB_ENV}")
# ===== OpenSSL (its own Configure; the target name follows the platform) =====
if(KANGAROO_OS STREQUAL "windows")
if(KANGAROO_ARCH STREQUAL "x86")
set(opensslTarget mingw)
elseif(KANGAROO_ARCH STREQUAL "aarch64")
# OpenSSL has no MinGW target for ARM64 Windows; Singe's own configuration file adds one.
set(opensslTarget --config=${CMAKE_SOURCE_DIR}/cmake/opensslMingwArm64.conf mingw-arm64)
else()
set(opensslTarget mingw64)
endif()
elseif(KANGAROO_OS STREQUAL "linux")
if(KANGAROO_ARCH STREQUAL "aarch64")
set(opensslTarget linux-aarch64)
elseif(KANGAROO_ARCH STREQUAL "armhf")
set(opensslTarget linux-armv4)
elseif(SINGE_ZIG_TARGET)
set(opensslTarget linux-x86_64)
else()
set(opensslTarget "")
endif()
elseif(KANGAROO_OS STREQUAL "macos")
if(KANGAROO_ARCH STREQUAL "aarch64")
set(opensslTarget darwin64-arm64)
else()
set(opensslTarget darwin64-x86_64)
endif()
elseif(SINGE_ZIG_TARGET)
set(opensslTarget linux-x86_64)
else()
set(opensslTarget "")
endif()
# OpenSSL's generated assembly uses GNU as directives clang's assembler rejects; under zig the
# portable C paths serve, which is all LuaSec's traffic needs.
if(SINGE_ZIG_TARGET)
set(opensslAsm no-asm)
else()
set(opensslAsm "")
endif()
# OpenSSL's Configure derives the source tree from the build directory's position and only
# copes with a build directory nested inside its own tree, so this one lives there, one per target
# so that switching presets never links another architecture's objects.
set(opensslBinary ${SB_THIRDPARTY}/openssl/.builddir/${KANGAROO_OS}-${KANGAROO_ARCH})
ExternalProject_Add(openssl
SOURCE_DIR ${SB_THIRDPARTY}/openssl
BINARY_DIR ${opensslBinary}
DEPENDS zlib zstd
CONFIGURE_COMMAND ${SB_ENV} perl ../../Configure ${opensslTarget} --prefix=${SB_PREFIX} --libdir=lib --with-zlib-include=${SB_PREFIX}/include --with-zlib-lib=${SB_PREFIX}/lib --with-zstd-include=${SB_PREFIX}/include --with-zstd-lib=${SB_PREFIX}/lib ${opensslAsm} no-apps no-docs no-dso no-dynamic-engine no-module no-shared no-tests zlib enable-zstd
BUILD_COMMAND ${SB_ENV} make build_sw
INSTALL_COMMAND ${SB_ENV} make install_sw
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
singeRebuildTarget(openssl)
# ===== FFmpeg (hardware decoding per platform; CMakeLists.txt reads its .pc files back) =====
# ===== Rockchip Media Process Platform (the vendor decode library for Rockchip SoCs) =====
# Rockchip's decoders are aarch64 only. Because the library links statically it costs nothing on a
# board that is not a Rockchip, so it is built for the aarch64 targets rather than needing one of
# its own; set SINGE_ROCKCHIP_MPP to override either way.
if(NOT DEFINED SINGE_ROCKCHIP_MPP)
if((KANGAROO_OS STREQUAL "linux") AND (KANGAROO_ARCH STREQUAL "aarch64"))
set(SINGE_ROCKCHIP_MPP ON)
else()
set(SINGE_ROCKCHIP_MPP OFF)
endif()
endif()
# MPP is packaged by no distribution, so it is vendored and built here like everything else. Only
# the static library is kept: linked statically the Singe binary carries no runtime dependency on
# it, so one ARM build runs on a Rockchip board and on anything else, where the decoder simply fails
# to open a device and the player falls back. A shared build would put librockchip_mpp.so in the
# binary's DT_NEEDED and stop it starting anywhere the library is absent.
set(rkmppFlag "")
set(rkmppDep "")
if(SINGE_ROCKCHIP_MPP)
# MPP strips its merged archive with ${CMAKE_STRIP}, and no cross strip here understands an ar
# archive: zig's objcopy is ELF only. The step only drops debug symbols from an intermediate
# library, so it is skipped; the release binary is stripped at its own link.
singeCmakeProject(rockchipMpp ${SB_THIRDPARTY}/rockchip-mpp "" "-DBUILD_SHARED_LIBS=OFF;-DBUILD_TEST=OFF;-DHAVE_DRM=ON;-DCMAKE_STRIP=/bin/true" "${SB_ENV}")
# Its install puts both the archive and the shared object down; the shared one is removed so
# that -lrockchip_mpp can only resolve to the archive.
ExternalProject_Add_Step(rockchipMpp staticOnly
COMMAND ${CMAKE_COMMAND} -E rm -f ${SB_PREFIX}/lib/librockchip_mpp.so ${SB_PREFIX}/lib/librockchip_mpp.so.0 ${SB_PREFIX}/lib/librockchip_mpp.so.1 ${SB_PREFIX}/lib/librockchip_vpu.so ${SB_PREFIX}/lib/librockchip_vpu.so.0 ${SB_PREFIX}/lib/librockchip_vpu.so.1
DEPENDEES install
COMMENT "Keeping only the static Rockchip MPP library"
)
set(rkmppFlag --enable-rkmpp)
set(rkmppDep rockchipMpp)
message(STATUS "Rockchip MPP will be built and linked statically; the rkmpp decoders are enabled")
endif()
# ===== dav1d, the AV1 decoder =====
# FFmpeg's own AV1 decoder needs hardware behind it: with no hwaccel it says "Your platform doesn't
# support hardware accelerated AV1 decoding" and decodes nothing, so an AV1 video does not play at
# all on a machine without an AV1 capable GPU. dav1d is the software decoder, and it brings AVIF
# stills with it, an AVIF being an AV1 picture in a HEIF box. It is the one vendored library whose
# own build system is Meson rather than CMake or a configure script.
set(dav1dBinary ${SB_PREFIX}/build/dav1d)
set(dav1dCross "")
if(SINGE_ZIG_TARGET)
# Meson learns a cross build from a file rather than from the environment, and names both the
# system and some architectures its own way. The system name is not cosmetic: Meson picks
# nasm's output format from it, and calling Windows "mingw32" -- which is what FFmpeg calls it
# -- leaves nasm emitting ELF objects that the Windows linker will not read.
if(KANGAROO_OS STREQUAL "windows")
set(dav1dSystem windows)
elseif(KANGAROO_OS STREQUAL "macos")
set(dav1dSystem darwin)
else()
set(dav1dSystem linux)
endif()
if(KANGAROO_ARCH STREQUAL "armhf")
set(dav1dCpuFamily arm)
else()
set(dav1dCpuFamily ${KANGAROO_ARCH})
endif()
set(dav1dCrossFile ${SB_PREFIX}/build/dav1dCross.txt)
file(WRITE ${dav1dCrossFile}
"[binaries]\nc = '${SB_CC}'\ncpp = '${SB_CXX}'\nar = '${CMAKE_AR}'\nranlib = '${SB_RANLIB}'\nstrip = 'true'\npkg-config = 'pkg-config'\nnasm = 'nasm'\n\n[host_machine]\nsystem = '${dav1dSystem}'\ncpu_family = '${dav1dCpuFamily}'\ncpu = '${KANGAROO_ARCH}'\nendian = 'little'\n")
set(dav1dCross --cross-file ${dav1dCrossFile})
endif()
ExternalProject_Add(dav1d
SOURCE_DIR ${SB_THIRDPARTY}/dav1d
BINARY_DIR ${dav1dBinary}
CONFIGURE_COMMAND ${SB_ENV} meson setup --wipe --prefix=${SB_PREFIX} --libdir=lib --buildtype=release --default-library=static -Denable_tools=false -Denable_tests=false ${dav1dCross} ${dav1dBinary} ${SB_THIRDPARTY}/dav1d
BUILD_COMMAND ${SB_ENV} ninja -C ${dav1dBinary}
INSTALL_COMMAND ${SB_ENV} ninja -C ${dav1dBinary} install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
singeRebuildTarget(dav1d)
# Recording (src/record.c) encodes with the platform's own H.264 hardware where there is one, and
# with FFmpeg's built-in MPEG-4 everywhere; sound is AAC, and the file is MP4.
set(recordEncoders mpeg4,aac)
if(KANGAROO_OS STREQUAL "linux")
# ARM boards decode through a V4L2 memory-to-memory device; desktops have VAAPI or VDPAU.
if(KANGAROO_ARCH MATCHES "^(aarch64|armhf)$")
set(hwaccel --enable-v4l2-m2m --enable-libdrm ${rkmppFlag})
string(APPEND recordEncoders ,h264_v4l2m2m)
else()
set(hwaccel --enable-vaapi --enable-vdpau --enable-libdrm ${rkmppFlag})
string(APPEND recordEncoders ,h264_vaapi)
endif()
elseif(KANGAROO_OS STREQUAL "macos")
set(hwaccel --enable-videotoolbox)
string(APPEND recordEncoders ,h264_videotoolbox)
elseif(KANGAROO_OS STREQUAL "windows")
set(hwaccel --enable-d3d11va --enable-dxva2 --enable-mediafoundation)
string(APPEND recordEncoders ,h264_mf)
else()
set(hwaccel "")
endif()
# With a classic cross toolchain every tool is <triple>-<tool>; with zig each is named outright.
if(SINGE_ZIG_TARGET)
set(ffmpegTools --enable-cross-compile --cc=${SB_CC} --cxx=${SB_CXX} --ar=${CMAKE_AR} --ranlib=${SB_RANLIB} --windres=${CMAKE_RC_COMPILER})
# FFmpeg only uses nm to learn the symbol prefix. The host's binutils nm reads x86 ELF and PE
# objects but not aarch64 or Mach-O ones; the prefix is empty on Linux either way, and on macOS
# (where it is an underscore) and on ARM64 Windows llvm-nm from Debian's llvm package reads the
# objects.
if((KANGAROO_OS STREQUAL "macos") OR ((KANGAROO_OS STREQUAL "windows") AND (KANGAROO_ARCH STREQUAL "aarch64")))
find_program(SINGE_LLVM_NM NAMES llvm-nm llvm-nm-21 llvm-nm-20 llvm-nm-19 llvm-nm-18 llvm-nm-17 llvm-nm-16 llvm-nm-15)
if(NOT SINGE_LLVM_NM)
message(FATAL_ERROR "llvm-nm is needed for the ${KANGAROO_OS} ${KANGAROO_ARCH} FFmpeg build (apt-get install llvm).")
endif()
list(APPEND ffmpegTools --nm=${SINGE_LLVM_NM})
elseif(KANGAROO_ARCH STREQUAL "x86_64")
list(APPEND ffmpegTools --nm=nm)
endif()
else()
set(ffmpegTools --cross-prefix=${SINGE_TRIPLE}- --cc=${SB_CC} --cxx=${SB_CXX} --ranlib=${SB_RANLIB})
endif()
# FFmpeg's configure sees sys/sysctl.h in the Pi sysroot and in zig's glibc header set, then its
# build cannot include it; an empty stand-in satisfies both (Linux uses sysconf, not sysctl).
if((KANGAROO_OS STREQUAL "linux") AND SINGE_ZIG_TARGET)
file(WRITE ${SB_PREFIX}/include/sys/sysctl.h "/* File no longer used */\n")
endif()
set(ffmpegBinary ${SB_PREFIX}/build/ffmpeg)
ExternalProject_Add(ffmpeg
SOURCE_DIR ${SB_THIRDPARTY}/ffmpeg
BINARY_DIR ${ffmpegBinary}
DEPENDS dav1d ${rkmppDep}
CONFIGURE_COMMAND ${SB_ENV} ${SB_THIRDPARTY}/ffmpeg/configure --enable-static --disable-shared --disable-debug --disable-muxers --enable-muxer=mp4 ${hwaccel} --disable-encoders --enable-encoder=${recordEncoders} --disable-filters --enable-filter=bwdif --enable-filter=yadif --enable-filter=buffer --enable-filter=buffersink --enable-filter=format --disable-network --disable-devices --disable-vulkan --disable-d3d12va --disable-bzlib --disable-lzma --disable-doc --disable-programs --enable-libdav1d --enable-gpl --enable-version3 --extra-ldflags=-L${SB_PREFIX}/lib --prefix=${SB_PREFIX} --arch=${KANGAROO_ARCH} --target-os=${SINGE_CROSS_OS} ${ffmpegTools}
BUILD_COMMAND ${SB_ENV} make
INSTALL_COMMAND ${SB_ENV} make install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
singeRebuildTarget(ffmpeg)
# ===== Singe itself, against everything above =====
string(SUBSTRING ${KANGAROO_OS} 0 1 osInitial)
string(SUBSTRING ${KANGAROO_OS} 1 -1 osRest)
string(TOUPPER ${osInitial} osInitial)
# The built file carries the platform's executable suffix; the copy in .builddir/ carries the
# preset's, which is the same thing for Windows and lets an experimental build live beside another.
# SINGE_VERSION_FULL, not PROJECT_VERSION: the four numbers CMake needs are not the version
# anybody sees, and a beta build is named v3.00b2.
set(singeBinaryBase "Singe-v${SINGE_VERSION_FULL}-${osInitial}${osRest}-${KANGAROO_ARCH}")
set(singeBinaryName "${singeBinaryBase}${SINGE_SUFFIX}")
set(singeBinary ${SB_PREFIX}/singe)
ExternalProject_Add(singe
SOURCE_DIR ${CMAKE_SOURCE_DIR}
BINARY_DIR ${singeBinary}
DEPENDS zlib zstd SDL3 SDL3_image SDL3_mixer SDL3_ttf openssl ffmpeg Jolt recastnavigation RmlUi shadercross
CONFIGURE_COMMAND ${SB_ENV} ${CMAKE_COMMAND} -S ${CMAKE_SOURCE_DIR} -B ${singeBinary} ${SB_CMAKE_ARGS} -DSINGE_SHADERCROSS=${SINGE_SHADERCROSS} -DSINGE_SPIRV_CROSS=${SINGE_SPIRV_CROSS} -DSINGE_FROM_SUPERBUILD=ON -DSINGE_SUPERBUILD=OFF -DKANGAROO_OS=${KANGAROO_OS} -DKANGAROO_ARCH=${KANGAROO_ARCH} -DSINGE_TREE=${SINGE_TREE}
BUILD_COMMAND ${SB_ENV} ${CMAKE_COMMAND} --build ${singeBinary} --parallel ${SB_JOBS}
INSTALL_COMMAND ${CMAKE_COMMAND} -E copy ${singeBinary}/${singeBinaryBase}${CMAKE_EXECUTABLE_SUFFIX} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName}
BUILD_ALWAYS ON
)
message(STATUS "Superbuild for ${KANGAROO_OS}/${KANGAROO_ARCH}: libraries into ${SB_PREFIX}, binary ${singeBinaryName}")

View file

@ -1,81 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Embeds a file as a C array. Invoked in script mode by CMakeLists.txt:
# cmake -DINPUT=<file> -DOUTPUT=<header> -DGUARD=<macro> -DSYMBOL=<name> -P embed.cmake
# The header defines the array when EMBED_HERE is defined and declares it extern otherwise.
file(READ "${INPUT}" content HEX)
string(LENGTH "${content}" hexLength)
math(EXPR byteLength "${hexLength} / 2")
# 0x00, 0x01, ... twelve per line.
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1, " bytes "${content}")
string(REGEX REPLACE "((0x[0-9a-f][0-9a-f], ){12})" "\\1\n\t" bytes "${bytes}")
string(REGEX REPLACE ", $" "" bytes "${bytes}")
string(REGEX REPLACE ",\n\t$" "" bytes "${bytes}")
file(WRITE "${OUTPUT}" "/*
*
* Singe 3
* Copyright (C) 2006-${COPYRIGHT_END_YEAR} Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef ${GUARD}
#define ${GUARD}
// ===== THIS FILE IS AUTOMATICALLY GENERATED - DO NOT EDIT =====
#ifdef EMBED_HERE
const unsigned char ${SYMBOL}[] = {
${bytes}
};
const unsigned int ${SYMBOL}_len = ${byteLength};
#else // EMBED_HERE
extern const unsigned char ${SYMBOL}[];
extern const unsigned int ${SYMBOL}_len;
#endif // EMBED_HERE
#endif // ${GUARD}
")

View file

@ -1,70 +0,0 @@
# Host tools the build needs at build time, built once for the host machine and shared by every
# preset: SDL_shadercross (the scene shaders are HLSL, compiled to SPIR-V, DXIL and MSL when Singe
# is built, the way the icon and the other embedded files are made) with SPIRV-Cross, a minimal
# host SDL3 for its command line tool, and Microsoft's prebuilt DirectX Shader Compiler, which
# SDL_shadercross drives and which is fetched like zig (building it from source is a half-hour
# job nobody should need). Everything lives under .builddir/toolchains.
#
# DXC's prebuilt release exists for x86_64 Linux hosts. On another host put an unpacked DXC
# (lib/libdxcompiler.so, lib/libdxil.so, include/dxc/dxcapi.h) at .builddir/toolchains/dxc.
set(SINGE_DXC_VERSION "v1.10.2605.37")
set(SINGE_DXC_FILE "linux_dxc_2026_08_11.x86_64.tar.gz")
set(SINGE_DXC_SHA256 "dea02d61c0548a6163a5fb45daa9263faeb5334e35d25a30b0293a32d089ceae")
get_filename_component(hostToolchains ${CMAKE_CURRENT_LIST_DIR}/../.builddir/toolchains ABSOLUTE)
set(hostPrefix ${hostToolchains}/host)
set(hostBuild ${hostToolchains}/hostBuild)
set(hostDxc ${hostToolchains}/dxc)
file(MAKE_DIRECTORY ${hostPrefix} ${hostBuild})
# ----- DirectX Shader Compiler, prebuilt -----
if(NOT EXISTS ${hostDxc}/lib/libdxcompiler.so)
if((CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") AND (CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64"))
set(dxcUrl "https://github.com/microsoft/DirectXShaderCompiler/releases/download/${SINGE_DXC_VERSION}/${SINGE_DXC_FILE}")
message(STATUS "Downloading the DirectX Shader Compiler ${SINGE_DXC_VERSION}")
file(DOWNLOAD ${dxcUrl} ${hostToolchains}/${SINGE_DXC_FILE} EXPECTED_HASH SHA256=${SINGE_DXC_SHA256} STATUS status)
list(GET status 0 code)
if(NOT code EQUAL 0)
message(FATAL_ERROR "DirectX Shader Compiler download failed: ${status}")
endif()
file(MAKE_DIRECTORY ${hostDxc})
file(ARCHIVE_EXTRACT INPUT ${hostToolchains}/${SINGE_DXC_FILE} DESTINATION ${hostDxc})
else()
message(FATAL_ERROR "No prebuilt DirectX Shader Compiler for this host; put one at ${hostDxc} (see cmake/hostTools.cmake).")
endif()
endif()
# ----- The host builds run with the host's own compilers, never the cross toolchain -----
set(hostEnv ${CMAKE_COMMAND} -E env --unset=CC --unset=CXX --unset=AR --unset=RANLIB --unset=CFLAGS --unset=CXXFLAGS --unset=LDFLAGS --unset=PKG_CONFIG_LIBDIR --unset=PKG_CONFIG_SYSROOT_DIR "MAKEFLAGS=-j${SB_JOBS}")
# A | inside an argument becomes a ; (a CMake list) on the way to the child, see LIST_SEPARATOR.
set(hostArgs -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${hostPrefix} "-DCMAKE_INSTALL_RPATH=${hostPrefix}/lib|${hostDxc}/lib" -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON)
# A host CMake project; its stamps and build tree live with the tools, so every preset shares them.
function(singeHostProject name source deps args)
ExternalProject_Add(${name}
SOURCE_DIR ${source}
BINARY_DIR ${hostBuild}/${name}
STAMP_DIR ${hostBuild}/${name}-stamp
TMP_DIR ${hostBuild}/${name}-tmp
DEPENDS ${deps}
LIST_SEPARATOR |
CONFIGURE_COMMAND ${hostEnv} ${CMAKE_COMMAND} -S ${source} -B ${hostBuild}/${name} ${hostArgs} ${args}
BUILD_COMMAND ${hostEnv} ${CMAKE_COMMAND} --build ${hostBuild}/${name} --parallel ${SB_JOBS}
INSTALL_COMMAND ${hostEnv} ${CMAKE_COMMAND} --build ${hostBuild}/${name} --target install
LOG_CONFIGURE ON LOG_BUILD ON LOG_INSTALL ON LOG_OUTPUT_ON_FAILURE ON
)
endfunction()
# SDL3 for the host, shared, with nothing SDL_shadercross's tool does not use.
singeHostProject(hostSDL3 ${SB_THIRDPARTY}/SDL3 "" "-DSDL_SHARED=ON;-DSDL_STATIC=OFF;-DSDL_TESTS=OFF;-DSDL_EXAMPLES=OFF;-DSDL_AUDIO=OFF;-DSDL_VIDEO=OFF;-DSDL_GPU=OFF;-DSDL_RENDER=OFF;-DSDL_CAMERA=OFF;-DSDL_JOYSTICK=OFF;-DSDL_HAPTIC=OFF;-DSDL_HIDAPI=OFF;-DSDL_POWER=OFF;-DSDL_SENSOR=OFF;-DSDL_DIALOG=OFF;-DSDL_UNIX_CONSOLE_BUILD=ON")
# SPIRV-Cross as the shared C library SDL_shadercross looks for, and as the command line tool that
# turns SPIR-V into GLSL ES for the GLES backend -- SDL_shadercross emits DXBC, DXIL, MSL, SPIRV,
# HLSL and JSON and no GLSL of any kind, so the ES shaders come from this one (PLAN.md section 56).
# The CLI needs the static library as well as the shared one.
singeHostProject(spirvCross ${SB_THIRDPARTY}/SPIRV-Cross "" "-DSPIRV_CROSS_SHARED=ON;-DSPIRV_CROSS_STATIC=ON;-DSPIRV_CROSS_CLI=ON;-DSPIRV_CROSS_ENABLE_TESTS=OFF")
# SDL_shadercross, unvendored: SPIRV-Cross and SDL3 from the host prefix, DXC from its download.
singeHostProject(shadercross ${SB_THIRDPARTY}/SDL_shadercross "hostSDL3;spirvCross" "-DSDLSHADERCROSS_VENDORED=OFF;-DSDLSHADERCROSS_DXC=ON;-DSDLSHADERCROSS_SHARED=OFF;-DSDLSHADERCROSS_STATIC=ON;-DSDLSHADERCROSS_CLI=ON;-DSDLSHADERCROSS_INSTALL=ON;-DSDLSHADERCROSS_INSTALL_CPACK=OFF;-DCMAKE_PREFIX_PATH=${hostPrefix}|${hostDxc};-DSDL3_DIR=${hostPrefix}/lib/cmake/SDL3")
set(SINGE_SHADERCROSS ${hostPrefix}/bin/shadercross)
set(SINGE_SPIRV_CROSS ${hostPrefix}/bin/spirv-cross)

View file

@ -1,26 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Linux x86_64 built on a Linux x86_64 host with its own gcc and development packages.
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(KANGAROO_OS linux)
set(KANGAROO_ARCH x86_64)

View file

@ -1,34 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# OpenSSL's Configure, given this with --config, knows MinGW on 64-bit ARM Windows. OpenSSL has no
# such target of its own: its "mingw64" is x86_64 and passes -m64, which an ARM64 compiler refuses.
# This is that target without the flag, with the integer layout of OpenSSL's own ARM64 Windows target
# (VC-WIN64-ARM). The Singe build turns the assembly off for every zig target, so the C paths serve.
my %targets = (
"mingw-arm64" => {
inherit_from => [ "mingw-common" ],
sys_id => "MINGW64",
bn_ops => add("SIXTY_FOUR_BIT RC4_CHAR"),
uplink_arch => undef,
multilib => "64",
},
);

View file

@ -1,28 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Injected into RmlUi's configure through CMAKE_PROJECT_RmlUi_INCLUDE (cmake/Superbuild.cmake).
# RmlUi's Lua plugin wants a Lua::Lua target; Singe compiles Lua into its own binary, so the plugin
# only needs the headers here and links through the engine. Defining the target first keeps RmlUi
# from running find_package(Lua) and looking for a system Lua.
if(NOT TARGET Lua::Lua)
add_library(Lua::Lua INTERFACE IMPORTED)
set_target_properties(Lua::Lua PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${SINGE_LUA_INCLUDE}")
endif()

View file

@ -1,28 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Runs a command and captures its standard output in a file. Invoked in script mode:
# cmake -DOUTPUT=<file> -DCOMMAND=<program> -DARGS=<semicolon list> -P runToFile.cmake
execute_process(COMMAND ${COMMAND} ${ARGS} OUTPUT_FILE "${OUTPUT}" RESULT_VARIABLE result)
if(NOT result EQUAL 0)
file(REMOVE "${OUTPUT}")
message(FATAL_ERROR "${COMMAND} failed with status ${result}")
endif()

View file

@ -1,109 +0,0 @@
# Compiles src/shaders/scene.hlsl (which includes src/sceneShared.h) into a C header of SPIR-V, DXIL, MSL and GLSL ES
# with SDL_shadercross and SPIRV-Cross. Run by the build as
# cmake -DSHADERCROSS=<tool> -DSPIRVCROSS=<tool> -DSOURCE=<scene.hlsl> -DOUTPUT=<sceneShaders.h> -P shaderHeader.cmake
# so the header is generated into the build tree like the icon and the other embedded files.
#
# SDL_shadercross has no GLSL destination (DXBC, DXIL, MSL, SPIRV, HLSL, JSON), so the ES form is a second step over the
# SPIR-V it already produced: one pipeline, four outputs. SPIRVCROSS may be empty, and then the ES strings are null and
# the GLES backend refuses to start -- which is what a build without the host tool should do rather than fail.
# Invoked in script mode: -DSHADERCROSS -DSOURCE=<file.hlsl> -DOUTPUT=<header> -DENTRIES=<name:stage;...>
# -DPREFIX=<lowerCamel prefix> -DTYPE=<struct name>. ENTRIES, PREFIX and TYPE default to the scene's.
if(NOT ENTRIES)
set(ENTRIES vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment depthCutoutMain:fragment particleVertex:vertex particleFragment:fragment lineVertex:vertex lineFragment:fragment postVertex:vertex postFragment:fragment skyFragment:fragment bloomDown:fragment bloomUp:fragment)
endif()
if(NOT PREFIX)
set(PREFIX sceneShader)
endif()
if(NOT TYPE)
set(TYPE SceneShaderT)
endif()
get_filename_component(sourceName ${SOURCE} NAME)
string(TOUPPER "${PREFIX}" guard)
string(REPLACE "SHADER" "_SHADERS_H" guard "${guard}")
set(entries ${ENTRIES})
set(formats SPIRV DXIL MSL)
# GLSL ES 3.10 for the GLES backend. Three switches matter and each is load bearing:
# --es --version 310 ES 3.1, the floor the scene needs. The vertex stage's storage buffers become texture
# buffers in this form (SINGE_ESSL below), since ES lets a vertex shader have no
# storage blocks and Mali drivers give it none; renderGles.c adjusts the extension.
# --combined-samplers-inherit-bindings
# HLSL's separate Texture2D and SamplerState have no ES equivalent, so SPIRV-Cross folds
# each pair into one sampler and keeps the HLSL t-register as the binding, which is the
# slot the engine already binds by.
# --remove-unused-variables Without it every entry point declares every cbuffer and both storage buffers in the file:
# fragmentMain came out with twelve blocks instead of two, and a fragment shader that
# merely declares an unused SSBO can fail to link where MAX_FRAGMENT_SHADER_STORAGE_BLOCKS
# is zero, which ES 3.1 permits.
# --flip-vert-y SDL_GPU puts the framebuffer origin at the top left, as Vulkan, D3D12 and Metal do, and
# GL puts it at the bottom left. Without this every render to a texture arrives upside
# down -- the GUI came out mirrored the first time this ran. Inverting gl_Position.y is
# the same fix as a negative viewport height, and it also reverses triangle winding, which
# renderGles.c undoes by flipping the front face it sets.
# --fixup-clipspace HLSL puts clip-space Z in [0, w] and GL in [-w, w], and GL then stores (z/w + 1) / 2.
# Without the rewrite a depth written as d lands in the buffer as (d + 1) / 2, while the
# shader's own shadow comparison still computes d -- so everything in the near half of a
# light's range read as lit. Sponza showed it as a 2.6x too bright mid-shadow band with
# correct highlights and correct deep shadow, which is what a half-range comparison does.
set(esslFlags --es --version 310 --combined-samplers-inherit-bindings --remove-unused-variables --flip-vert-y --fixup-clipspace)
get_filename_component(sourceDir ${SOURCE} DIRECTORY)
get_filename_component(includeDir ${sourceDir} DIRECTORY)
get_filename_component(outputDir ${OUTPUT} DIRECTORY)
set(work ${outputDir}/work)
file(MAKE_DIRECTORY ${work})
set(header "// Generated from ${sourceName} by cmake/shaderHeader.cmake with SDL_shadercross; do not edit.\n")
string(APPEND header "// SPIR-V for Vulkan, DXIL for Direct3D 12, MSL for Metal, GLSL ES for OpenGL ES, one set per entry point.\n\n")
string(APPEND header "#ifndef ${guard}\n#define ${guard}\n\n#include <stddef.h>\n#include <stdlib.h>\n\n")
string(REGEX REPLACE "T$" "S" structName "${TYPE}")
string(APPEND header "typedef struct ${structName} {\n\tconst char *entryPoint;\n\tconst unsigned char *spirv;\n\tsize_t spirvSize;\n\tconst unsigned char *dxil;\n\tsize_t dxilSize;\n\tconst unsigned char *msl;\n\tsize_t mslSize;\n\tconst char *essl; // GLSL ES 3.10 source, or NULL when the host tool was absent\n} ${TYPE};\n\n")
foreach(entry IN LISTS entries)
string(REPLACE ":" ";" parts ${entry})
list(GET parts 0 name)
list(GET parts 1 stage)
foreach(format IN LISTS formats)
string(TOLOWER ${format} extension)
set(blob ${work}/${name}.${extension})
execute_process(COMMAND ${SHADERCROSS} ${SOURCE} -s HLSL -d ${format} -t ${stage} -e ${name} -I ${includeDir} -o ${blob} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err)
if(NOT code EQUAL 0)
message(FATAL_ERROR "Shader ${name} (${format}) failed to compile:\n${out}\n${err}")
endif()
file(READ ${blob} hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," bytes "${hex}")
string(APPEND header "static const unsigned char _${name}${format}[] = {\n\t${bytes}\n};\n\n")
endforeach()
# The ES form, cross compiled from its own SPIR-V: the same source with SINGE_ESSL defined, which reads the
# vertex stage's storage buffers as texture buffers. Emitted as a C string rather than bytes because that is
# what glShaderSource takes; the escaping is only quotes and backslashes, neither of which GLSL ES has a use for.
set(esslSymbol "NULL")
if(SPIRVCROSS)
set(esslSpirv ${work}/${name}.essl.spirv)
execute_process(COMMAND ${SHADERCROSS} ${SOURCE} -s HLSL -d SPIRV -t ${stage} -e ${name} -I ${includeDir} -DSINGE_ESSL -o ${esslSpirv} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err)
if(NOT code EQUAL 0)
message(FATAL_ERROR "Shader ${name} (SPIR-V for GLSL ES) failed to compile:\n${out}\n${err}")
endif()
set(essl ${work}/${name}.essl)
execute_process(COMMAND ${SPIRVCROSS} ${esslFlags} ${esslSpirv} --output ${essl} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err)
if(NOT code EQUAL 0)
message(FATAL_ERROR "Shader ${name} (GLSL ES) failed to cross compile:\n${out}\n${err}")
endif()
file(READ ${essl} esslText)
# SPIRV-Cross defaults an ES fragment shader to "precision mediump float", and a struct
# declared inside a uniform block carries no qualifier of its own, so Light's members inherit
# mediump -- fp16 on a real tiler. The scene's own values do not fit that: a camera or a
# light tens of units from the origin loses whole units of position. Ask for highp instead.
string(REPLACE "precision mediump float;" "precision highp float;" esslText "${esslText}")
string(REPLACE "\\" "\\\\" esslText "${esslText}")
string(REPLACE "\"" "\\\"" esslText "${esslText}")
string(REPLACE "\n" "\\n\"\n\t\"" esslText "${esslText}")
string(APPEND header "static const char _${name}ESSL[] =\n\t\"${esslText}\";\n\n")
set(esslSymbol "_${name}ESSL")
endif()
string(SUBSTRING ${name} 0 1 initial)
string(SUBSTRING ${name} 1 -1 rest)
string(TOUPPER ${initial} initial)
string(APPEND header "static const ${TYPE} ${PREFIX}${initial}${rest} = { \"${name}\", _${name}SPIRV, sizeof(_${name}SPIRV), _${name}DXIL, sizeof(_${name}DXIL), _${name}MSL, sizeof(_${name}MSL), ${esslSymbol} };\n\n")
endforeach()
string(APPEND header "#endif\n")
file(WRITE ${OUTPUT} "${header}")

View file

@ -1,93 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Raspberry Pi (64-bit Raspberry Pi OS) with zig as the compiler. zig supplies glibc pinned to the
# version below plus the kernel headers; the platform libraries Singe, SDL3 and FFmpeg build against
# (libdrm, ALSA, X11, GBM/EGL/GLES, udev, ...) come from the Debian packages listed in
# arm64Packages.cmake, downloaded and unpacked into a sysroot under .builddir/toolchains on first use.
# Nothing outside the source tree is touched; dpkg-deb from the host does the unpacking.
include(${CMAKE_CURRENT_LIST_DIR}/zig.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/arm64Packages.cmake)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
# Raspberry Pi OS bullseye shipped glibc 2.31; a binary built against it runs there and on everything since.
set(SINGE_GLIBC_VERSION 2.31)
get_filename_component(armToolchains ${CMAKE_CURRENT_LIST_DIR}/../../.builddir/toolchains ABSOLUTE)
set(SINGE_SYSROOT ${armToolchains}/sysroot-aarch64-linux-gnu CACHE PATH "ARM64 platform headers and libraries" FORCE)
set(armDebs ${armToolchains}/debs-aarch64-linux-gnu)
set(armStamp ${SINGE_SYSROOT}/.complete)
# The stamp holds the manifest that built the sysroot; a changed manifest rebuilds it.
string(SHA256 arm64Manifest "${arm64Packages}")
set(piHave "")
if(EXISTS ${armStamp})
file(READ ${armStamp} piHave)
string(STRIP "${piHave}" piHave)
endif()
if(NOT piHave STREQUAL arm64Manifest)
find_program(SINGE_DPKG_DEB dpkg-deb)
if(NOT SINGE_DPKG_DEB)
message(FATAL_ERROR "dpkg-deb is needed to unpack the Pi sysroot packages (apt-get install dpkg).")
endif()
message(STATUS "Building the ARM64 sysroot in ${SINGE_SYSROOT}")
file(REMOVE_RECURSE ${SINGE_SYSROOT})
file(MAKE_DIRECTORY ${SINGE_SYSROOT} ${armDebs})
foreach(entry IN LISTS arm64Packages)
string(REGEX MATCH "^([^|]*)\\|(.*)$" unused "${entry}")
set(url ${CMAKE_MATCH_1})
set(sha ${CMAKE_MATCH_2})
get_filename_component(deb ${url} NAME)
set(debPath ${armDebs}/${deb})
if(NOT EXISTS ${debPath})
message(STATUS "Downloading ${deb}")
file(DOWNLOAD ${url} ${debPath} EXPECTED_HASH SHA256=${sha} STATUS status)
list(GET status 0 code)
if(NOT code EQUAL 0)
file(REMOVE ${debPath})
message(FATAL_ERROR "Download of ${url} failed: ${status}")
endif()
endif()
execute_process(COMMAND ${SINGE_DPKG_DEB} -x ${debPath} ${SINGE_SYSROOT} RESULT_VARIABLE code)
if(NOT code EQUAL 0)
message(FATAL_ERROR "Unpacking ${deb} failed.")
endif()
endforeach()
file(WRITE ${armStamp} "${arm64Manifest}\n")
endif()
# The sysroot's headers come after zig's own; its libraries are linked by name and left unresolved
# at link time (they are only stubs for what the Pi provides at run time).
set(armLibs ${SINGE_SYSROOT}/usr/lib/aarch64-linux-gnu)
singeZigToolchain(aarch64-linux-gnu.${SINGE_GLIBC_VERSION} "-idirafter ${SINGE_SYSROOT}/usr/include/aarch64-linux-gnu -idirafter ${SINGE_SYSROOT}/usr/include -L${armLibs} -L${SINGE_SYSROOT}/lib/aarch64-linux-gnu -Wl,--allow-shlib-undefined")
# find_package and pkg-config look only in the sysroot; programs come from the host.
set(CMAKE_FIND_ROOT_PATH ${SINGE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_LIBRARY_ARCHITECTURE aarch64-linux-gnu CACHE STRING "" FORCE)
set(CMAKE_C_LIBRARY_ARCHITECTURE aarch64-linux-gnu CACHE STRING "" FORCE)
set(CMAKE_LIBRARY_PATH ${armLibs} CACHE PATH "" FORCE)
set(KANGAROO_OS linux)
set(KANGAROO_ARCH aarch64)

View file

@ -1,24 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# macOS on Apple silicon; everything is in macos.cmake.
set(SINGE_MACOS_ARCH aarch64)
set(CMAKE_SYSTEM_PROCESSOR arm64)
include(${CMAKE_CURRENT_LIST_DIR}/macos.cmake)

View file

@ -1,34 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Windows on 64-bit ARM with zig as the cross compiler: zig ships the mingw-w64 headers and import
# libraries, so nothing else has to be installed.
include(${CMAKE_CURRENT_LIST_DIR}/zig.cmake)
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
singeZigToolchain(aarch64-windows-gnu "")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(KANGAROO_OS windows)
set(KANGAROO_ARCH aarch64)

View file

@ -1,80 +0,0 @@
# Debian bookworm arm64 packages that give the Pi build its platform headers and stub libraries
# (libdrm, ALSA, X11, GBM/EGL/GLES, udev, dbus, Wayland, PulseAudio). zig supplies libc and the
# kernel headers itself, so no libc6 package is here. Every line is a permanent snapshot.debian.org
# URL and the SHA-256 Debian publishes for it; regenerate against a newer snapshot when needed.
# Format: one "url|sha256" pair per list element.
set(arm64PackagesSnapshot "https://snapshot.debian.org/archive/debian/20250901T000000Z")
set(arm64Packages
# Kernel headers, for the V4L2 stateless decoders: linux/media.h, videodev2.h, v4l2-controls.h.
"${arm64PackagesSnapshot}/pool/main/l/linux/linux-libc-dev_6.1.148-1_arm64.deb|55baaddd21bcaa7fed8894c89e4d65b684e85abcb48acf443519438185e1153f"
"${arm64PackagesSnapshot}/pool/main/libd/libdrm/libdrm-dev_2.4.114-1+b1_arm64.deb|26d7d0dcbbda8ed023b57989e0fca7f0ff8372daaf654daf8b3a362557be5e21"
"${arm64PackagesSnapshot}/pool/main/libd/libdrm/libdrm2_2.4.114-1+b1_arm64.deb|f5f15a46d02cf5d9fa52d4f1c54b8cf80c398711ad771a9938b12399b8d8090c"
"${arm64PackagesSnapshot}/pool/main/libd/libdrm/libdrm-amdgpu1_2.4.114-1+b1_arm64.deb|24692e933b98b2b8b80d87c45728efa2bc7176120ce9efcb8793407e508b5a32"
"${arm64PackagesSnapshot}/pool/main/libd/libdrm/libdrm-nouveau2_2.4.114-1+b1_arm64.deb|2acf0db609e8569dd89eea5f3e6f27968de50474d31213665901ad1c6d1133de"
"${arm64PackagesSnapshot}/pool/main/libd/libdrm/libdrm-radeon1_2.4.114-1+b1_arm64.deb|e53c2b4be53b4a8e6413887525c9172bdc95ed3a140a52ce23d6e1a68789aaa0"
"${arm64PackagesSnapshot}/pool/main/a/alsa-lib/libasound2-dev_1.2.8-1+b1_arm64.deb|263668e5ce831d3a8426bd342beba7e94925e893a4a5a285252b8f3ed6b3f5ee"
"${arm64PackagesSnapshot}/pool/main/a/alsa-lib/libasound2_1.2.8-1+b1_arm64.deb|9fa889400fcee4b92c8f4a2fafbb7f2cd33444d9ec1665a71002ab67c06114bb"
"${arm64PackagesSnapshot}/pool/main/a/alsa-lib/libasound2-data_1.2.8-1_all.deb|fe0780d2d3674b2977e0acb0d48b448ad72ba1642564b7dc537f55e839984c2d"
"${arm64PackagesSnapshot}/pool/main/libx/libx11/libx11-dev_1.8.4-2+deb12u2_arm64.deb|34bfc833561de983cfb4475523fedc13abb4faee14a876300de659591779add8"
"${arm64PackagesSnapshot}/pool/main/libx/libx11/libx11-6_1.8.4-2+deb12u2_arm64.deb|d1d533e983582282a9ea82c87ac5ce715a9b67bd6d1acbd2439a11c63c36549b"
"${arm64PackagesSnapshot}/pool/main/libx/libxcb/libxcb1-dev_1.15-1_arm64.deb|87b5c3f1bb130727df68295ecfaadc305ee10c150afeb1a183e95b9150e18db5"
"${arm64PackagesSnapshot}/pool/main/libx/libxcb/libxcb1_1.15-1_arm64.deb|041d9a68415c3ccf3ce8f4f8b88e4bbfb5dc1f0d97013c6ef8423e620ea50f84"
"${arm64PackagesSnapshot}/pool/main/libx/libxau/libxau-dev_1.0.9-1_arm64.deb|21db3761a8f388fc83707c00fac645d4332737a859dd727571e1e6ede003b048"
"${arm64PackagesSnapshot}/pool/main/libx/libxau/libxau6_1.0.9-1_arm64.deb|36c2bf400641a80521093771dc2562c903df4065f9eb03add50d90564337ea6c"
"${arm64PackagesSnapshot}/pool/main/libx/libxdmcp/libxdmcp-dev_1.1.2-3_arm64.deb|6fd0bc51f3a4e6b7f944f24f15524a4c4ba68a4b0aa136c18bdb96a2d36988f2"
"${arm64PackagesSnapshot}/pool/main/libx/libxdmcp/libxdmcp6_1.1.2-3_arm64.deb|e92569ac33247261aa09adfadc34ced3994ac301cf8b58de415a2d5dbf15ccfc"
"${arm64PackagesSnapshot}/pool/main/x/xorgproto/x11proto-dev_2022.1-1_all.deb|3cab66a6591774188a568f9b54c4e7956f18da76e0e0fc4a779db5f6bcc3f148"
"${arm64PackagesSnapshot}/pool/main/x/xorg-sgml-doctools/xorg-sgml-doctools_1.11-1.1_all.deb|168345058319094e475a87ace66f5fb6ae802109650ea8434d672117982b5d0a"
"${arm64PackagesSnapshot}/pool/main/libx/libxext/libxext-dev_1.3.4-1+b1_arm64.deb|07f5e3efeba9564071885b2d0d2ba277692fa309a9f05ef862688af0022b1fcf"
"${arm64PackagesSnapshot}/pool/main/libx/libxext/libxext6_1.3.4-1+b1_arm64.deb|5e9c0ad606eb4674c645fe8e0e64330c47d2729f7a59ed569848610efd5d5b62"
"${arm64PackagesSnapshot}/pool/main/libx/libxi/libxi-dev_1.8-1+b1_arm64.deb|4a5e7bc3b4e55c23281b2e500b572a61ae8869b1bc03fe14b4ae5a4c9e3c295c"
"${arm64PackagesSnapshot}/pool/main/libx/libxi/libxi6_1.8-1+b1_arm64.deb|b26383c9929d8bfba051eee22301e064d17039ed022bbf31990feb0d7a03b36c"
"${arm64PackagesSnapshot}/pool/main/libx/libxfixes/libxfixes-dev_6.0.0-2_arm64.deb|25266e1bd10c7624210abf3aa3eb85e3886b6c8c08b2ecc396b8a04f24c10082"
"${arm64PackagesSnapshot}/pool/main/libx/libxfixes/libxfixes3_6.0.0-2_arm64.deb|d47bda8fed01b19b41d503e2df05d9166c58e30e2376f2f8784ceb7a834befe6"
"${arm64PackagesSnapshot}/pool/main/libx/libxrandr/libxrandr-dev_1.5.2-2+b1_arm64.deb|f951a3813c525bc950e21e801837dfe447bdbef4d2610eddbd833d7d41f92c7c"
"${arm64PackagesSnapshot}/pool/main/libx/libxrandr/libxrandr2_1.5.2-2+b1_arm64.deb|f0bc7f79fee182caae176652fdb1bf349d13b9591b10ad4dc1c896521da8e49f"
"${arm64PackagesSnapshot}/pool/main/libx/libxrender/libxrender-dev_0.9.10-1.1_arm64.deb|26a3ef50910cae3422c20af90a4a4deb85e7da3680c9192e50efc01b7e1b2f8f"
"${arm64PackagesSnapshot}/pool/main/libx/libxrender/libxrender1_0.9.10-1.1_arm64.deb|f260193deb4eaf0b410a1c61e1f84804366ee3372b8bad9f38005f7dbcd89aae"
"${arm64PackagesSnapshot}/pool/main/libx/libxcursor/libxcursor-dev_1.2.1-1_arm64.deb|5d0a0e62fcb202d9afc08c95d6b44e28211e63a6f0763e05741561ac2580aaa1"
"${arm64PackagesSnapshot}/pool/main/libx/libxcursor/libxcursor1_1.2.1-1_arm64.deb|a93dd172bdd940de4ac6fd2b7a3f0213604c975f9f2f51eb3013f50886aaa87c"
"${arm64PackagesSnapshot}/pool/main/libx/libxtst/libxtst-dev_1.2.3-1.1_arm64.deb|4e205710dd04f9e9ee360da0161c155be01086e36462fead392b707b3857b4f2"
"${arm64PackagesSnapshot}/pool/main/libx/libxtst/libxtst6_1.2.3-1.1_arm64.deb|ef2a1b9ac3f2620d96e17744e41c4eefb7f491eee3bcc5bc1e592180b2d94128"
"${arm64PackagesSnapshot}/pool/main/libx/libxss/libxss-dev_1.2.3-1_arm64.deb|970b39c57df5c1307f905c6dd89967d548e9e2711a2099241de7b8e8831e40ca"
"${arm64PackagesSnapshot}/pool/main/libx/libxss/libxss1_1.2.3-1_arm64.deb|e0ff80e309eacda5face68b9a3bd56718fed2750af429324c35d7c9491c335f4"
"${arm64PackagesSnapshot}/pool/main/libx/libxinerama/libxinerama-dev_1.1.4-3_arm64.deb|64c758aef152615d99bceead07c86b03f3737dca190230b5dde1d79403288341"
"${arm64PackagesSnapshot}/pool/main/libx/libxinerama/libxinerama1_1.1.4-3_arm64.deb|1dc5f458f6b3245b1b128897f905717d3cf7777a5697577b246042e25311d0df"
"${arm64PackagesSnapshot}/pool/main/libx/libxkbcommon/libxkbcommon-dev_1.5.0-1_arm64.deb|7d39b34b202fe58e18992b439c79c149ebb1b821b58dfb863ff4898e06420699"
"${arm64PackagesSnapshot}/pool/main/libx/libxkbcommon/libxkbcommon0_1.5.0-1_arm64.deb|ec518d8a19796a399ab95e7bc4dfbb6bd2ed8e151f77b222df26208db412d852"
"${arm64PackagesSnapshot}/pool/main/m/mesa/libgbm-dev_22.3.6-1+deb12u1_arm64.deb|ba3f413f6f428b45e21d93fc5eb213bbc31ca1c15a7b265da3700100fd2475c8"
"${arm64PackagesSnapshot}/pool/main/m/mesa/libgbm1_22.3.6-1+deb12u1_arm64.deb|c76790e11aac46e328b6a13b34cccd3ef01fad79cfe80d2c4aa2384ac9ddb1f8"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libegl-dev_1.6.0-1_arm64.deb|8a3004f892785a5fa4c9f969c116f567a36d1c8e9b3d5fd0403055151e36c7d3"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libegl1_1.6.0-1_arm64.deb|707097a275155c600e2e9251c4ee7cdfdb2d8f50a678a850a2526a7bc9664166"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libgles-dev_1.6.0-1_arm64.deb|941e9963d64479544691f7183fe0eeae32b53b633df71c4c51263517ab2ea8f3"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libgles2_1.6.0-1_arm64.deb|efeb2717380f8411d66b3f669bb99bbd83ff09db3d4a1661533aa31af659608c"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libgl-dev_1.6.0-1_arm64.deb|56add9e710c072cf56504beed50a639f9f7c64f4e31e66958fa65a566084888c"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libgl1_1.6.0-1_arm64.deb|f4043ab47f52325973d2b608514b3469b055b65c744861c7f378755a32e799d6"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libglvnd-dev_1.6.0-1_arm64.deb|86cba4e243d10ae07cc6e769f23ecd097b2f87b722dc5ac48a48a3794cd29513"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libglvnd0_1.6.0-1_arm64.deb|d5063205fab5322ee686f33f87c6315b3fcd1335ea4e7bc8202b48ed4f9c9e83"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libglx-dev_1.6.0-1_arm64.deb|f3d6fca16c259743208df6f845aee3189eb54fed00e159811135089c16d0ad9c"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libglx0_1.6.0-1_arm64.deb|523f9b71f75e48ae605d8ef298a4b03ade26ade9a808a85329a3bd82a54b9b0d"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libopengl0_1.6.0-1_arm64.deb|4b3c6b3bc25530290cf231acac4c85b8806dda53ab6376ab22b3ec511b4fb7a0"
"${arm64PackagesSnapshot}/pool/main/libg/libglvnd/libopengl-dev_1.6.0-1_arm64.deb|b4ddc2d38103e9f0c3637d46334ccbf40c30e851c85f5348abc0aa37e3926822"
"${arm64PackagesSnapshot}/pool/main/s/systemd/libudev-dev_252.36-1~deb12u1_arm64.deb|62760a9e8fb87c2827333163efadc36c5ae66be59af18e7286d19813b8a72e03"
"${arm64PackagesSnapshot}/pool/main/s/systemd/libudev1_252.36-1~deb12u1_arm64.deb|5abfa6d6960f1b6a19c3d35373958d47a3a2f1221a5fe4652143fea0e0adc2ab"
"${arm64PackagesSnapshot}/pool/main/d/dbus/libdbus-1-dev_1.14.10-1~deb12u1_arm64.deb|817caa440feee88d53dd74432ce633af8d351552cd71014bcc8c98380c9ed88c"
"${arm64PackagesSnapshot}/pool/main/d/dbus/libdbus-1-3_1.14.10-1~deb12u1_arm64.deb|2a423794f44ee756f70fda67c6e47b15afe3dd22cd69e51f5d14d2ec9538f806"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-dev_1.21.0-1_arm64.deb|932405c58195d299495230792e374ad304adadf3d3ffdc07eda3466b756adc33"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-client0_1.21.0-1_arm64.deb|ae390cc04c2eb1de90b9a6373505b22c730ada5e72daa50c507b7f99c12faf06"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-egl1_1.21.0-1_arm64.deb|c08124a4d9af24f058b45c88867a84697882e755ea94965795d152fe26fe57c9"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-cursor0_1.21.0-1_arm64.deb|8c8d1a3942bc3fc9dd3ea7c679c1a314e59cdaaa700883d54d1bb3ec89db256b"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-server0_1.21.0-1_arm64.deb|4d99e7e0503fdaa999e67d47d8612c0465e775eafa47819573da50bdb09064f2"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-bin_1.21.0-1_arm64.deb|02e9361617b43b130ff032873a910258884160c169e89a1ca45d34d1150a2615"
"${arm64PackagesSnapshot}/pool/main/libd/libdecor-0/libdecor-0-dev_0.1.1-2_arm64.deb|e52fbcbbf6e755be2c642fcecc6154d9d071978d0797e7cf9ccf93d131bf4fc6"
"${arm64PackagesSnapshot}/pool/main/libd/libdecor-0/libdecor-0-0_0.1.1-2_arm64.deb|e88d3c2dd3336a97b8cf10ef270a264388fce81cedf8032b18f2a36b021bc6a3"
"${arm64PackagesSnapshot}/pool/main/p/pulseaudio/libpulse-dev_16.1+dfsg1-2+b1_arm64.deb|dac8f94dc214a77654cc62d0f1611cd4dee9cdefdc816e3056dd9fae5aa5d4a8"
"${arm64PackagesSnapshot}/pool/main/p/pulseaudio/libpulse0_16.1+dfsg1-2+b1_arm64.deb|26f17e3457c5fce0104a6b2f0efb75a3256b24b32ca7cfda6266373758a930cb"
"${arm64PackagesSnapshot}/pool/main/w/wayland/libwayland-egl-backend-dev_1.21.0-1_arm64.deb|0974da94059371e67e93d61398853c9967d0cc34cd7745b6d47d3aa47b88815d"
"${arm64PackagesSnapshot}/pool/main/libf/libffi/libffi-dev_3.4.4-1_arm64.deb|8ff6335f76752ef8659762435d0d88129ef0090a587520853238809bd10ef392"
"${arm64PackagesSnapshot}/pool/main/libf/libffi/libffi8_3.4.4-1_arm64.deb|80b5c36177dc0e29d531c7eddbed3cc7355cb490e49f8cfa5959572d161f27b3"
)

View file

@ -1,99 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# macOS with zig as the compiler, shared by the aarch64 and x86_64 toolchain files (which set
# SINGE_MACOS_ARCH and CMAKE_SYSTEM_PROCESSOR before including this). zig carries the macOS libc
# headers and the libSystem stub itself; everything else (Cocoa, CoreAudio, VideoToolbox, Metal and the rest of
# the frameworks, libc++, libiconv) comes from an Apple SDK, which Apple does not let a build
# script fetch. The SDK is expected, in this order:
# 1. an unpacked SDK at SINGE_MACOS_SDK (default .builddir/toolchains/MacOSX.sdk);
# 2. a tarball at .builddir/toolchains/MacOSX.sdk.tar.xz, unpacked on first use;
# 3. SINGE_MACOS_SDK_URL (with SINGE_MACOS_SDK_SHA256), downloaded and unpacked on first use;
# the default points at the community mirror osxcross users share (github.com/joseluisq/
# macosx-sdks), pinned to one SDK release and its published SHA-256.
# See INSTALL for how to make the tarball from Xcode instead.
include(${CMAKE_CURRENT_LIST_DIR}/zig.cmake)
set(CMAKE_SYSTEM_NAME Darwin)
# Oldest macOS the binary runs on (Ventura: Intel Macs from 2017 and every Apple silicon Mac).
set(SINGE_MACOS_VERSION 13.0)
get_filename_component(sdkToolchains ${CMAKE_CURRENT_LIST_DIR}/../../.builddir/toolchains ABSOLUTE)
set(SINGE_MACOS_SDK ${sdkToolchains}/MacOSX.sdk CACHE PATH "Unpacked Apple SDK for the macOS build")
set(SINGE_MACOS_SDK_URL "https://github.com/joseluisq/macosx-sdks/releases/download/15.5/MacOSX15.5.sdk.tar.xz" CACHE STRING "Where to fetch the Apple SDK tarball from; empty to never download")
set(SINGE_MACOS_SDK_SHA256 "c15cf0f3f17d714d1aa5a642da8e118db53d79429eb015771ba816aa7c6c1cbd" CACHE STRING "SHA-256 of that tarball; empty to skip the check")
set(sdkTarball ${sdkToolchains}/MacOSX.sdk.tar.xz)
if(NOT EXISTS ${SINGE_MACOS_SDK}/System/Library/Frameworks/Cocoa.framework)
if((NOT EXISTS ${sdkTarball}) AND SINGE_MACOS_SDK_URL)
message(STATUS "Downloading the Apple SDK from ${SINGE_MACOS_SDK_URL}")
file(MAKE_DIRECTORY ${sdkToolchains})
if(SINGE_MACOS_SDK_SHA256)
file(DOWNLOAD ${SINGE_MACOS_SDK_URL} ${sdkTarball} EXPECTED_HASH SHA256=${SINGE_MACOS_SDK_SHA256} STATUS status)
else()
file(DOWNLOAD ${SINGE_MACOS_SDK_URL} ${sdkTarball} STATUS status)
endif()
list(GET status 0 code)
if(NOT code EQUAL 0)
message(FATAL_ERROR "Apple SDK download failed: ${status}")
endif()
endif()
if(EXISTS ${sdkTarball})
message(STATUS "Unpacking ${sdkTarball}")
set(sdkUnpack ${sdkToolchains}/MacOSX.sdk.unpack)
file(REMOVE_RECURSE ${sdkUnpack})
file(ARCHIVE_EXTRACT INPUT ${sdkTarball} DESTINATION ${sdkUnpack})
file(GLOB sdkFound LIST_DIRECTORIES true ${sdkUnpack}/*.sdk ${sdkUnpack}/*/*.sdk)
list(LENGTH sdkFound sdkCount)
if(NOT sdkCount EQUAL 1)
message(FATAL_ERROR "${sdkTarball} should hold exactly one MacOSX*.sdk directory; found ${sdkCount}.")
endif()
file(RENAME ${sdkFound} ${SINGE_MACOS_SDK})
file(REMOVE_RECURSE ${sdkUnpack})
endif()
endif()
if(NOT EXISTS ${SINGE_MACOS_SDK}/System/Library/Frameworks/Cocoa.framework)
message(FATAL_ERROR "No Apple SDK at ${SINGE_MACOS_SDK}. Put an SDK tarball at ${sdkTarball} (see INSTALL), or set SINGE_MACOS_SDK to an unpacked SDK, or SINGE_MACOS_SDK_URL to fetch one.")
endif()
# No --sysroot: zig would then prefix every -L path with it, the superbuild's own lib directory
# included. The SDK is named outright instead: its usr/include (libDER and other non-libc pieces)
# after zig's own libc headers, its frameworks, and its stub libraries. The SDK's CMTag.h uses two
# macOS 14 types without an availability guard, and FFmpeg's configure makes that an error with
# -Werror=partial-availability; the trailing -Wno-unguarded-availability-new wins over it.
singeZigToolchain(${SINGE_MACOS_ARCH}-macos.${SINGE_MACOS_VERSION} "-idirafter ${SINGE_MACOS_SDK}/usr/include -F${SINGE_MACOS_SDK}/System/Library/Frameworks -L${SINGE_MACOS_SDK}/usr/lib -Wno-unguarded-availability-new")
# SDL3 compiles Objective-C, which the same zig wrappers handle, and CMake's Darwin support insists
# on an install_name_tool even though nothing here links dynamically; llvm's stands in.
set(CMAKE_OBJC_COMPILER ${CMAKE_C_COMPILER} CACHE FILEPATH "" FORCE)
set(CMAKE_OBJCXX_COMPILER ${CMAKE_CXX_COMPILER} CACHE FILEPATH "" FORCE)
find_program(SINGE_INSTALL_NAME_TOOL NAMES llvm-install-name-tool llvm-install-name-tool-21 llvm-install-name-tool-20 llvm-install-name-tool-19 llvm-install-name-tool-18 llvm-install-name-tool-17 llvm-install-name-tool-16 llvm-install-name-tool-15)
if(NOT SINGE_INSTALL_NAME_TOOL)
message(FATAL_ERROR "llvm-install-name-tool is needed for the macOS build (apt-get install llvm).")
endif()
set(CMAKE_INSTALL_NAME_TOOL ${SINGE_INSTALL_NAME_TOOL} CACHE FILEPATH "" FORCE)
set(CMAKE_OSX_SYSROOT "" CACHE PATH "Left empty; the wrappers name the SDK themselves" FORCE)
set(CMAKE_OSX_ARCHITECTURES "" CACHE STRING "Left empty; the zig target names the architecture" FORCE)
set(CMAKE_FIND_ROOT_PATH ${SINGE_MACOS_SDK})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(KANGAROO_OS macos)
set(KANGAROO_ARCH ${SINGE_MACOS_ARCH})

View file

@ -1,49 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Joins the Apple silicon and Intel Singe binaries into one universal binary with llvm-lipo.
# Run as: cmake -P cmake/zig/macosUniversal.cmake (after both macOS presets have built)
get_filename_component(buildDir ${CMAKE_CURRENT_LIST_DIR}/../../.builddir ABSOLUTE)
# The binaries by name, from the project() line as CMakeLists.txt makes SINGE_VERSION_FULL (patch 1
# is a beta and tweak says which), so one an older version left in .builddir is never joined.
file(STRINGS ${CMAKE_CURRENT_LIST_DIR}/../../CMakeLists.txt projectLine REGEX "^project\\(singe2 VERSION ")
if(NOT projectLine MATCHES "VERSION ([0-9]+)\\.([0-9]+)\\.([0-9]+)\\.([0-9]+)")
message(FATAL_ERROR "CMakeLists.txt has no four-number project() version.")
endif()
if(CMAKE_MATCH_3 EQUAL 1)
set(version "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}b${CMAKE_MATCH_4}")
else()
set(version "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}")
endif()
set(armBinary ${buildDir}/Singe-v${version}-Macos-aarch64)
set(intelBinary ${buildDir}/Singe-v${version}-Macos-x86_64)
if(NOT EXISTS ${armBinary} OR NOT EXISTS ${intelBinary})
message(FATAL_ERROR "Build the macos-aarch64 and macos-x86_64 presets first.")
endif()
set(universalBinary ${buildDir}/Singe-v${version}-Macos-universal)
find_program(SINGE_LIPO NAMES llvm-lipo llvm-lipo-21 llvm-lipo-20 llvm-lipo-19 llvm-lipo-18 llvm-lipo-17 llvm-lipo-16 llvm-lipo-15)
if(NOT SINGE_LIPO)
message(FATAL_ERROR "llvm-lipo is needed to make the universal binary (apt-get install llvm).")
endif()
execute_process(COMMAND ${SINGE_LIPO} -create ${armBinary} ${intelBinary} -output ${universalBinary} RESULT_VARIABLE code)
if(NOT code EQUAL 0)
message(FATAL_ERROR "llvm-lipo failed.")
endif()
message(STATUS "Wrote ${universalBinary}")

View file

@ -1,59 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Linux x86_64 with zig as the compiler, targeting glibc 2.28 (Debian 10, Ubuntu 18.04) so one
# release binary runs on more distributions than the build host's own glibc allows. zig supplies
# libc and its headers; the platform libraries (ALSA, X11, DRM, VA-API, VDPAU, GL) and their
# headers come from the host's development packages, searched after zig's own so the host's
# newer glibc headers never shadow the pinned ones.
include(${CMAKE_CURRENT_LIST_DIR}/zig.cmake)
set(SINGE_GLIBC_VERSION 2.28)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
# The linker may see the host's platform libraries but never its libc, libm, libpthread or gcc
# runtime, or symbols get bound to the host's newer glibc versions. A directory of links to
# everything else under the host's library directory keeps the two apart.
set(hostLibs ${CMAKE_BINARY_DIR}/hostlibs)
file(MAKE_DIRECTORY ${hostLibs})
file(GLOB hostSharedLibs /usr/lib/x86_64-linux-gnu/lib*.so)
foreach(lib ${hostSharedLibs})
get_filename_component(name ${lib} NAME)
if(NOT name MATCHES "^lib(c|m|dl|pthread|rt|resolv|util|anl|nsl|crypt|gcc_s|stdc\\+\\+|BrokenLocale|mvec|memusage|pcprofile|SegFault|thread_db|c_malloc_debug|asan|ubsan|tsan|lsan|atomic|gomp|itm|quadmath)\\.so$")
if(NOT EXISTS ${hostLibs}/${name})
file(CREATE_LINK ${lib} ${hostLibs}/${name} SYMBOLIC)
endif()
endif()
endforeach()
# --allow-shlib-undefined: the host's platform libraries reference its newer glibc; at link time only
# their symbols matter, and the machine that runs the binary brings its own copies.
singeZigToolchain(x86_64-linux-gnu.${SINGE_GLIBC_VERSION} "-idirafter /usr/include/x86_64-linux-gnu -idirafter /usr/include -L${hostLibs} -Wl,--allow-shlib-undefined")
# No find-root restrictions: the host's development packages are the platform packages here, so
# SDL3 must find X11, Wayland, ALSA and the rest where they are. CMake learns the Debian multiarch
# library directory from gcc, not from a clang-style compiler, and its compiler detection overwrites
# a plain variable, so the settings are forced into the cache or FindX11 never sees libX11.
set(CMAKE_LIBRARY_ARCHITECTURE x86_64-linux-gnu CACHE STRING "Debian multiarch library directory name" FORCE)
set(CMAKE_C_LIBRARY_ARCHITECTURE x86_64-linux-gnu CACHE STRING "Debian multiarch library directory name" FORCE)
set(CMAKE_LIBRARY_PATH /usr/lib/x86_64-linux-gnu CACHE PATH "Where the host platform libraries live" FORCE)
set(KANGAROO_OS linux)
set(KANGAROO_ARCH x86_64)

View file

@ -1,24 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# macOS on Intel; everything is in macos.cmake.
set(SINGE_MACOS_ARCH x86_64)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
include(${CMAKE_CURRENT_LIST_DIR}/macos.cmake)

View file

@ -1,34 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# Windows x86_64 with zig as the cross compiler: zig ships the mingw-w64 headers and import
# libraries, so nothing else has to be installed.
include(${CMAKE_CURRENT_LIST_DIR}/zig.cmake)
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
singeZigToolchain(x86_64-windows-gnu "")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(KANGAROO_OS windows)
set(KANGAROO_ARCH x86_64)

View file

@ -1,92 +0,0 @@
#
# Singe 3
# Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
#
# The one place the zig version lives. Toolchain files include this and name a target; the
# compiler, archiver and resource compiler are small wrapper scripts so every tool that wants a
# single program name (FFmpeg's configure, OpenSSL's, CMake itself) gets one.
# Pinned release, fetched into .builddir/toolchains on first use and checked against its hash.
set(ZIG_VERSION 0.15.2)
set(ZIG_SHA256 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239)
set(ZIG_NAME zig-x86_64-linux-${ZIG_VERSION})
set(ZIG_URL https://ziglang.org/download/${ZIG_VERSION}/${ZIG_NAME}.tar.xz)
get_filename_component(ZIG_TOOLCHAINS ${CMAKE_CURRENT_LIST_DIR}/../../.builddir/toolchains ABSOLUTE)
set(ZIG_ROOT ${ZIG_TOOLCHAINS}/${ZIG_NAME})
set(ZIG_EXECUTABLE ${ZIG_ROOT}/zig)
if(NOT EXISTS ${ZIG_EXECUTABLE})
message(STATUS "Downloading zig ${ZIG_VERSION} from ${ZIG_URL}")
file(MAKE_DIRECTORY ${ZIG_TOOLCHAINS})
file(DOWNLOAD ${ZIG_URL} ${ZIG_TOOLCHAINS}/${ZIG_NAME}.tar.xz EXPECTED_HASH SHA256=${ZIG_SHA256} STATUS status)
list(GET status 0 code)
if(NOT code EQUAL 0)
message(FATAL_ERROR "zig download failed: ${status}")
endif()
file(ARCHIVE_EXTRACT INPUT ${ZIG_TOOLCHAINS}/${ZIG_NAME}.tar.xz DESTINATION ${ZIG_TOOLCHAINS})
file(REMOVE ${ZIG_TOOLCHAINS}/${ZIG_NAME}.tar.xz)
if(NOT EXISTS ${ZIG_EXECUTABLE})
message(FATAL_ERROR "zig ${ZIG_VERSION} did not unpack to ${ZIG_ROOT}.")
endif()
endif()
# Writes the wrapper scripts for a target into the build tree and points CMake at them. extra is
# appended to every compiler invocation (a target's include and library directories).
function(singeZigToolchain target extra)
set(dir ${CMAKE_BINARY_DIR}/zig-${target})
file(MAKE_DIRECTORY ${dir})
# The same triple without its glibc version. zig cc refuses the versioned form when it is only
# preprocessing -- "version '.2.28' in target triple 'x86_64-unknown-linux-gnu.2.28' is invalid"
# -- and asking a compiler for its predefined macros that way is exactly how Meson identifies
# one, so dav1d would not configure. Preprocessing generates no code, so the version it loses
# there cannot reach anything built.
string(REGEX REPLACE "\\.[0-9].*$" "" targetBase "${target}")
# The compiler wrappers drop two flags FFmpeg's configure adds to every test link and zig's linker
# driver refuses: binutils' --pic-executable ASLR workaround on Windows and Apple's
# -dynamic,-search_paths_first on macOS. They also add
# -fno-sanitize=undefined: zig cc instruments for UBSan by default and expects its runtime at
# link time; a release build wants neither.
# "zig cc -Wl,--version" prints zig's own banner rather than the linker's, and Meson reads that
# answer to decide which linker it is driving. The linker really is LLD, and asking it directly
# says so in the words Meson knows, so the probe is passed straight through to it.
set(version "[[ $a == -Wl,--version ]] && exec \"${ZIG_EXECUTABLE}\" ld.lld --version;")
set(filter "for a in \"$@\"; do ${version} [[ $a == -Wl,--pic-executable* || $a == -Wl,-dynamic* ]] || args+=(\"$a\"); [[ $a == -E ]] && target=\"-target ${targetBase}\"; done")
# extra goes after the caller's arguments so the caller's -L directories are searched first.
# ld.lld and objcopy are here for vendored projects that shell out to a linker or a strip of
# their own rather than going through the compiler driver: Rockchip's MPP merges its archives
# with "ld -r" and then strips the result, and without these it would reach for the host's.
foreach(tool "cc;$target -fno-sanitize=undefined;${extra};yes" "c++;$target -fno-sanitize=undefined;${extra};yes" "ar;;;no" "ranlib;;;no" "rc;;;no" "ld.lld;;;no")
list(GET tool 0 name)
list(GET tool 1 args)
list(GET tool 2 after)
list(GET tool 3 compiler)
if(compiler STREQUAL "yes")
file(WRITE ${dir}/zig-${name} "#!/bin/bash\nargs=()\ntarget=\"-target ${target}\"\n${filter}\nexec \"${ZIG_EXECUTABLE}\" ${name} ${args} \"$\{args[@]}\" ${after}\n")
else()
file(WRITE ${dir}/zig-${name} "#!/bin/bash\nargs=()\n${filter}\nexec \"${ZIG_EXECUTABLE}\" ${name} ${args} \"$\{args[@]}\" ${after}\n")
endif()
file(CHMOD ${dir}/zig-${name} PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE)
endforeach()
set(CMAKE_C_COMPILER ${dir}/zig-cc CACHE FILEPATH "C compiler" FORCE)
set(CMAKE_CXX_COMPILER ${dir}/zig-c++ CACHE FILEPATH "C++ compiler" FORCE)
set(CMAKE_AR ${dir}/zig-ar CACHE FILEPATH "archiver" FORCE)
set(CMAKE_RANLIB ${dir}/zig-ranlib CACHE FILEPATH "ranlib" FORCE)
set(CMAKE_RC_COMPILER ${dir}/zig-rc CACHE FILEPATH "resource compiler" FORCE)
set(CMAKE_LINKER ${dir}/zig-ld.lld CACHE FILEPATH "linker" FORCE)
set(SINGE_ZIG_TARGET ${target} CACHE STRING "zig target triple" FORCE)
endfunction()

View file

@ -1,170 +0,0 @@
# Singe demos
Three demos in the old demoscene way: a song each, and everything on screen timed to it. Every
one runs live, nothing on screen is a recording of the demo itself, and every motion takes its time
from the music's clock (`musicGetPosition`), so a demo stays with its song on any machine at any
frame rate.
| Demo | Song | Length |
|---|---|---|
| Neon Coast | "Neons in Napoli" by eliza_music | 3:08 |
| Mirrorball | "Vintage Funky Groove" by miiira | 4:05 |
| Insert Coin | "Game Is On" by poorartistt | 4:10 |
Each folder has its own `games.dat`, so Singe runs a demo like any game: from this folder,
`Singe neonCoast/neonCoast.singe`. They need the 3D scene. `kit.lua` is the code all three share,
and each demo carries its own copy of it (and of the logos), so a demo packs into a `.game` with
nothing outside its folder; a change to one copy belongs in all three. The kit holds
the music clock, beats and bars, the envelopes and drum hits, the scene runner, and the effects
more than one demo uses: vector bobs, scrollers of five kinds (sine, bounce, zoom, spin, round a
circle), a 3D scroller, and a perspective crawl, flashes and strobes, chrome and glass materials, a camera-filling
quad, a torus knot, and three effects worked out a pixel at a time in Lua every frame and handed to
a sprite with `spriteSetPixels`: a plasma with a cycling palette, a lookup-table tunnel, and a
tape's snow. Nothing in any of the three demos is a video.
The demos run at 1920 by 1080, the canvas and window in each `games.dat`. They are laid out in
design units, 480 tall and as wide as the screen's shape makes them (853 at 16:9), and the kit turns
those into the window's pixels as it draws, `K.S` (2.25) to a unit: `K.draw` and `K.drawGrid` for
sprites, `K.text` and `K.font` for lettering rendered at full resolution, `K.width` and `K.height`
for sizes. Art the util scripts draw is made 2.25 pixels to a unit and loaded with `K.sprite(path,
density)`, so it draws pixel for pixel; Insert Coin's pixel art is drawn a whole number of screen
pixels to each of its own instead, so every art pixel is the same size. A whole-screen 2D layer
would cost the CPU milliseconds a frame on a 1920 by 1080 overlay, so those are quads on the GPU:
`K.fill` (a background), `K.under` (a sprite or a color), and `K.turn` (a sprite turned and
scaled, which on the overlay would be rebuilt every frame) between the 3D world and the overlay,
`K.backdrop` behind the world, and `K.flash` and `K.cover` (scanlines) over everything. Each demo
calls `K.screen(camera, fov, far)` once its camera is made.
Every demo opens on the Singe dragon and lettering, faded up on black, held, and faded out, before
its music starts, and closes the same way on the Kangaroo Punch logo after the music ends and the
picture fades to black. util/demoLogos.sh copies the logos' SVGs from media/ into every demo's art/ (the
lettering turned near-white for black), and the kit draws them from the SVG at the screen's
resolution (`K.vector`).
They are on the demo's own clock (the opening card runs from -3.5 s to 0), so util/demoCapture.sh
takes them with a negative start or a stretch past the end.
Scroller text can be changed freely. Each scroller is given the bars it runs between, not a speed:
`K.scroller(t, font, text, y, amp, startTime, endTime, style)` works its speed out from the text so
the first letter enters as the scene starts and the last has left a second and a half before it
ends, so the words are finished before the cut. Text too short to fill
the time at a readable speed is repeated until it does; text too long to read at its speed still
ends on time and puts a line in the trace (`DEMO scroller too long to read`, with about how many
characters would fit). `K.scroller3D(t, blocks, text, y, startTime, endTime)` is timed the same
way, with solid letters from `K.blockFont(glyphs, camera, fov, cell, alpha)`: a pixel font
extruded into blocks, hung off the camera in the scene's front layer (`nodeSetLayer`), so they
stand over 2D and 3D alike. Its letters are larger, so it is allowed to go slower before it
repeats. The text round the circle is written in over the first half of its scene,
and the crawl spaces whatever lines it is given over its scene, the same way.
## How the timing works
`util/demoSync.py` listens to a song once, offline, and writes `sync.lua` beside the demo:
- **Beats.** A song on a fixed tempo gets a grid, found by folding the onsets of the whole track at
every candidate period and keeping the sharpest; Neon Coast's is 122.000 beats a minute and
Insert Coin's 135.000, with the tracked beats within 4 ms of them. Mirrorball's song speeds up
from about 111 to 117, so it gets the tracker's beats, each moved to the strongest onset near it
and smoothed through its neighbors, and carried on past where the tracker gave up.
- **Bars.** Beat one is the beat where the loudness and the bass jump most often, since songs
change section on a downbeat.
- **Envelopes.** Kick, snare, and hat (the change in each drum's frequency band), bass, loudness,
and brightness, sixty samples a second, each 0 to 1, packed one character a sample.
- **Hits.** The times of the kicks, snares, and hats, for effects that fire once per hit.
A scene in a demo says which bars it spans, so cuts land on downbeats, and inside it effects read
the envelopes and hits at the current time: a flash that decays from every kick, a formation that
steps on every beat, a tunnel whose speed the kick pushes.
## The songs, and what happens in them
**Neon Coast** (bar n starts at 0.1008 + (n - 1) x 1.96721 s)
| Bars | Song | Demo |
|---|---|---|
| 1-3 | pads only | a tape starts: snow, the deck's lettering, the title typed out |
| 4-16 | bass in | the sunset grid; the chrome logotype rises at 9, the title slams in at 13 |
| 17-18 | bass out, snare build | the logotype turns, the fog closes in, strobes on the snare roll |
| 19-28 | full groove | the night drive, fast: two streetlights pass the car on every beat, a 3D city on the horizon; a bouncing scroller |
| 29-30 | bass out | the car flies off the crest in slow motion, the road dropping away below, and lands on bar 31 |
| 31-38 | main section | the chrome knot and glass, a cut on every downbeat; a spinning scroller |
| 39-46 | main section | slaloming round the towers of a neon city, a swerve a bar and a barrel roll every two |
| 47-48 | filtered drop | the tape rewinds the flight |
| 49-58 | busy hats | the tunnel, scrubbed by the kick; vector bobs and the scroller |
| 59-60 | bass out | the tunnel stops, the bobs fall in, ARE YOU READY |
| 61-64 | loud | plasma, a rotozoomed logo, a ring of palms, text written round a circle |
| 65-68 | loud | low and fast over neon mountains toward the sun, under a perspective crawl of Singe's features |
| 69-76 | breakdown, then the kick | a cloth flag waving on the night grid, gusting with the kicks once they return; a zooming scroller |
| 77-78 | riser, dead stop | into hyperspace, white, and black for the stop |
| 79-94 | finale | road, city, knot, and mountains, cut every two bars, fireworks on the snares; over the road the logotype and the dragon, beating its wings on the beat |
| 95- | ending | the set switches off |
**Mirrorball** (eight-bar phrases from bar 1 at 0.79 s; the bars shorten as the song speeds up)
| Bars | Song | Demo |
|---|---|---|
| 1-8 | choppy drums and bass | copper bars gated by the groove, the title dropping a letter at a time |
| 9-16 | chords | the club: the ball comes down, spots sweep, the floor swaps on every beat |
| 17-24 | lead | a rotozoomer, copper bars colored by the note being played, the scroller |
| 25-28 | full | a mirrored kaleidoscope of plasma under vector bobs |
| 29-32 | | a cloud of glowing dots that takes a new shape every bar |
| 33-40 | choppy again | the club, cut on every beat, black in the gaps, the lasers changing pattern every bar |
| 41-48 | denser hats | the twister, drawn a scanline at a time, over copper bars |
| 49-52 | horns | a checkered ball bouncing on the beat; a ball drops on every snare and piles up |
| 53-56 | | an equalizer city, a band of the music to each column and its last moments running back |
| 57-60 | | glenz glass in a tunnel scrubbed by the kick |
| 61-64 | | a turntable, the record scratched on every snare, the camera cutting every two beats |
| 65-72 | dense | lasers in the haze: 24 beams in six patterns, a new one every two beats |
| 73-80 | | plasma and a rotozoomed logo |
| 81-88 | breakdown | the dark club; the floor relights a tile a beat when the bass returns |
| 89-96 | build | everything back on, strobing |
| 97-112 | full, then final | the party, cutting faster: club, dots, equalizer, club spinning, turntable, club |
| 113- | the last downbeat | the ball shatters and the lights go down |
**Insert Coin** (bar n starts at 0.0067 + (n - 1) x 1.77778 s)
From bar 1 to bar 121, a large scroller of translucent 3D block letters (`FEATURE_TEXT` near the
top of insertCoin.singe, in the demo's own pixel font from `art/font.lua`) runs over every era,
saying what Singe is and what it can do.
| Bars | Song | Demo |
|---|---|---|
| 1-4 | intro | a tube warms up; INSERT COIN, and a coin drops on the downbeat of 5 |
| 5-8 | drums in | analog: bat and ball in phosphor, the ball meeting a bat on every beat |
| 9-16 | groove | 8-bit: a formation that steps on the beat; the cannon fires on the snare |
| 17-24 | lead melody | 16-bit: a runner over parallax hills, jumping on the backbeat |
| 25-32 | lead melody | 16-bit: a kart round a track seen in perspective |
| 33-40 | breakdown, riser | a tape loading |
| 41-48 | full | vector: glowing wireframe asteroids, split by the ship's aimed shot on every snare |
| 49-56 | full | 32-bit: a flat-shaded ship weaving a winding path through gates, one a beat, banking into the turns |
| 57-64 | heavy | light gun: targets slide along three rails, and each snare flashes the screen and drops the one under the crosshair |
| 65-72 | heavy | now: chrome balls into a wall of glossy bricks, real physics, on the kick |
| 73-80 | breakdown | GAME OVER, and a countdown to continue, a number a bar |
| 81-88 | melody returns | versus: two fighters trade blows on the kicks and snares, ROUND 1, FIGHT!, K.O. |
| 89-96 | melody | the boss, firing on the kick, the hats, and the snare |
| 97-104 | full | the bonus stage: coins rain down far along the tunnel and ride its floor off the screen |
| 105-112 | full | rhythm: a note highway whose notes are the song's own kicks, snares, and hats |
| 113-120 | melody | the high score table, which is the credits |
| 121-136 | final | every era again, a bar each, then GAME ON |
| 137- | outro | GAME OVER, and the set switched off on the last hit |
## Making them again
Everything in `art/` and `sync.lua` is generated:
- `util/neonCoastArt.py`, `util/mirrorballArt.py`, `util/insertCoinArt.py` draw each demo's
pictures (PIL and numpy).
- `util/neonCoastCar.sh` renders Neon Coast's car with Singe's own 3D renderer
(`util/neonCoastCar.singe` builds the model): seven steering angles, each drawn over black and
over white so every pixel's true alpha comes out of the pair.
- `util/demoSync.py` writes `sync.lua` from `music.mp3`. It needs librosa, which Singe does not:
`python3 -m venv env && env/bin/pip install librosa`.
`util/demoRecord.sh neonCoast out.mp4` records a whole demo with its music, using Singe's own
recorder: `--deterministic` and `--record` together keep every frame and generate the sound in
step with the picture, and Neon Coast's three minutes take about two and a half on a desktop.
`util/demoCapture.sh neonCoast 0 190 [every] [out.mp4]` runs a stretch of a demo headless on a
clock that moves exactly one 20 ms step a frame, saves the frames, and with `every` = 1 puts the
same stretch of the music under them in an MP4. The engine plays the music on real time, so a
capture is silent and the song is added afterwards; a demo run normally plays it itself. Set
`DEMO_STATS = true` before loading a demo to have it print its frame rate every second.

BIN
demos/insertCoin/art/bossArm.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/bossCore.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/bullet.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/cannon.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/coin.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/coinDragon.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/coinSinge.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="193 22 889 749" width="889" height="749"><g transform="translate(1275 0) scale(-1 1)">
<!-- Singe dragon, reconstructed as polygons from Dragon.jpeg; transparent background. -->
<defs>
<linearGradient id="g0" gradientUnits="userSpaceOnUse" x1="698.7" y1="96.1" x2="629.6" y2="137.3"><stop offset="0" stop-color="#6f7277"/><stop offset="1" stop-color="#97969a"/></linearGradient>
<linearGradient id="g1" gradientUnits="userSpaceOnUse" x1="503.6" y1="307.1" x2="382.6" y2="159.3"><stop offset="0" stop-color="#66686f"/><stop offset="1" stop-color="#8a8b90"/></linearGradient>
<linearGradient id="g2" gradientUnits="userSpaceOnUse" x1="845.5" y1="159.5" x2="866.9" y2="156.5"><stop offset="0" stop-color="#73777b"/><stop offset="1" stop-color="#73767c"/></linearGradient>
<linearGradient id="g3" gradientUnits="userSpaceOnUse" x1="835.5" y1="229.1" x2="842.3" y2="253.1"><stop offset="0" stop-color="#54575d"/><stop offset="1" stop-color="#5a5d63"/></linearGradient>
<linearGradient id="g4" gradientUnits="userSpaceOnUse" x1="738.8" y1="402.4" x2="778.4" y2="373.8"><stop offset="0" stop-color="#4e5357"/><stop offset="1" stop-color="#55595e"/></linearGradient>
<linearGradient id="g5" gradientUnits="userSpaceOnUse" x1="502.9" y1="620.5" x2="456.0" y2="520.4"><stop offset="0" stop-color="#52575e"/><stop offset="1" stop-color="#5a6166"/></linearGradient>
<linearGradient id="g6" gradientUnits="userSpaceOnUse" x1="854.9" y1="616.3" x2="901.1" y2="622.7"><stop offset="0" stop-color="#33393e"/><stop offset="1" stop-color="#373c42"/></linearGradient>
<linearGradient id="g7" gradientUnits="userSpaceOnUse" x1="364.2" y1="664.4" x2="360.7" y2="746.1"><stop offset="0" stop-color="#353b40"/><stop offset="1" stop-color="#40474d"/></linearGradient>
<linearGradient id="g8" gradientUnits="userSpaceOnUse" x1="965.4" y1="697.5" x2="906.6" y2="706.0"><stop offset="0" stop-color="#63686c"/><stop offset="1" stop-color="#676c70"/></linearGradient>
<linearGradient id="g9" gradientUnits="userSpaceOnUse" x1="894.1" y1="714.4" x2="875.2" y2="729.2"><stop offset="0" stop-color="#43474a"/><stop offset="1" stop-color="#45474b"/></linearGradient>
<linearGradient id="g10" gradientUnits="userSpaceOnUse" x1="682.0" y1="67.3" x2="737.5" y2="95.0"><stop offset="0" stop-color="#d5c9bc"/><stop offset="1" stop-color="#dbcdc1"/></linearGradient>
<linearGradient id="g11" gradientUnits="userSpaceOnUse" x1="979.3" y1="183.9" x2="958.7" y2="176.5"><stop offset="0" stop-color="#9b9187"/><stop offset="1" stop-color="#b9aea5"/></linearGradient>
<linearGradient id="g12" gradientUnits="userSpaceOnUse" x1="924.1" y1="150.5" x2="852.7" y2="205.4"><stop offset="0" stop-color="#c2b6a8"/><stop offset="1" stop-color="#f1e7d9"/></linearGradient>
<linearGradient id="g13" gradientUnits="userSpaceOnUse" x1="663.8" y1="225.3" x2="726.2" y2="233.1"><stop offset="0" stop-color="#d4c4b2"/><stop offset="1" stop-color="#dccebd"/></linearGradient>
<linearGradient id="g14" gradientUnits="userSpaceOnUse" x1="945.6" y1="245.5" x2="885.2" y2="257.5"><stop offset="0" stop-color="#837b70"/><stop offset="1" stop-color="#b0a292"/></linearGradient>
<linearGradient id="g15" gradientUnits="userSpaceOnUse" x1="889.0" y1="403.3" x2="817.3" y2="411.5"><stop offset="0" stop-color="#a1998c"/><stop offset="1" stop-color="#e4d7c8"/></linearGradient>
<linearGradient id="g16" gradientUnits="userSpaceOnUse" x1="886.4" y1="506.4" x2="918.1" y2="507.5"><stop offset="0" stop-color="#857f74"/><stop offset="1" stop-color="#8b8279"/></linearGradient>
<linearGradient id="g17" gradientUnits="userSpaceOnUse" x1="881.7" y1="569.7" x2="827.2" y2="506.1"><stop offset="0" stop-color="#888175"/><stop offset="1" stop-color="#a49b8d"/></linearGradient>
<linearGradient id="g18" gradientUnits="userSpaceOnUse" x1="208.2" y1="559.7" x2="242.4" y2="555.4"><stop offset="0" stop-color="#aa9c8d"/><stop offset="1" stop-color="#b3a693"/></linearGradient>
<linearGradient id="g19" gradientUnits="userSpaceOnUse" x1="852.5" y1="644.9" x2="736.7" y2="633.3"><stop offset="0" stop-color="#928a7d"/><stop offset="1" stop-color="#daccbc"/></linearGradient>
<linearGradient id="g20" gradientUnits="userSpaceOnUse" x1="224.4" y1="631.6" x2="303.9" y2="662.7"><stop offset="0" stop-color="#8d8376"/><stop offset="1" stop-color="#b0a396"/></linearGradient>
<linearGradient id="g21" gradientUnits="userSpaceOnUse" x1="1017.2" y1="708.7" x2="974.7" y2="752.6"><stop offset="0" stop-color="#726b60"/><stop offset="1" stop-color="#867e73"/></linearGradient>
<linearGradient id="g22" gradientUnits="userSpaceOnUse" x1="560.7" y1="705.9" x2="600.9" y2="763.2"><stop offset="0" stop-color="#898072"/><stop offset="1" stop-color="#a89b8b"/></linearGradient>
<linearGradient id="g23" gradientUnits="userSpaceOnUse" x1="801.0" y1="120.8" x2="796.8" y2="140.0"><stop offset="0" stop-color="#e5e4e4"/><stop offset="1" stop-color="#eff0ef"/></linearGradient>
<linearGradient id="g24" gradientUnits="userSpaceOnUse" x1="795.2" y1="187.5" x2="744.9" y2="264.6"><stop offset="0" stop-color="#d6dad6"/><stop offset="1" stop-color="#e7e8e7"/></linearGradient>
<linearGradient id="g25" gradientUnits="userSpaceOnUse" x1="444.3" y1="360.6" x2="343.6" y2="248.4"><stop offset="0" stop-color="#eaeaea"/><stop offset="1" stop-color="#f1f1f0"/></linearGradient>
<linearGradient id="g26" gradientUnits="userSpaceOnUse" x1="774.0" y1="291.5" x2="756.7" y2="328.6"><stop offset="0" stop-color="#e0e1de"/><stop offset="1" stop-color="#e7e8e5"/></linearGradient>
<linearGradient id="g27" gradientUnits="userSpaceOnUse" x1="504.5" y1="428.2" x2="600.5" y2="343.0"><stop offset="0" stop-color="#e6e8e7"/><stop offset="1" stop-color="#e9eae8"/></linearGradient>
<linearGradient id="g28" gradientUnits="userSpaceOnUse" x1="270.4" y1="455.9" x2="305.1" y2="499.0"><stop offset="0" stop-color="#e7e8e7"/><stop offset="1" stop-color="#ececeb"/></linearGradient>
<linearGradient id="g29" gradientUnits="userSpaceOnUse" x1="780.3" y1="469.6" x2="741.5" y2="534.9"><stop offset="0" stop-color="#dbdfdb"/><stop offset="1" stop-color="#e6e7e4"/></linearGradient>
<linearGradient id="g30" gradientUnits="userSpaceOnUse" x1="572.4" y1="460.6" x2="554.8" y2="532.2"><stop offset="0" stop-color="#e1e3e0"/><stop offset="1" stop-color="#eaeae9"/></linearGradient>
<linearGradient id="g31" gradientUnits="userSpaceOnUse" x1="373.0" y1="591.4" x2="450.6" y2="588.1"><stop offset="0" stop-color="#e1e4e0"/><stop offset="1" stop-color="#e5e7e4"/></linearGradient>
<linearGradient id="g32" gradientUnits="userSpaceOnUse" x1="634.8" y1="522.2" x2="694.0" y2="646.0"><stop offset="0" stop-color="#cfd5d1"/><stop offset="1" stop-color="#d8ddd9"/></linearGradient>
<linearGradient id="g33" gradientUnits="userSpaceOnUse" x1="488.1" y1="692.3" x2="494.0" y2="725.6"><stop offset="0" stop-color="#d5dad6"/><stop offset="1" stop-color="#e2e5e2"/></linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="3" dy="5" stdDeviation="4" flood-color="#000" flood-opacity="0.18"/></filter>
<filter id="brush" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.003 0.7" numOctaves="2" seed="7" result="noise"/><feColorMatrix in="noise" type="saturate" values="0" result="gray"/><feComponentTransfer in="gray" result="grain"><feFuncA type="linear" slope="0.22"/></feComponentTransfer><feComposite in="grain" in2="SourceGraphic" operator="in" result="clipped"/><feBlend in="clipped" in2="SourceGraphic" mode="overlay"/></filter>
</defs>
<title>Singe dragon</title>
<g filter="url(#shadow)">
<polygon points="620.3,450.0 420.1,406.2 575.1,277.9 680.5,456.9" fill="url(#g27)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="718.9,692.9 547.2,565.9 642.9,498.6 688.8,474.3 721.0,511.5" fill="url(#g32)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="832.0,437.8 829.7,543.9 705.5,374.0 673.4,303.6 704.9,256.9" fill="url(#g4)" stroke="#aaacae" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="573.2,269.1 519.7,314.7 404.5,263.3 302.7,135.5" fill="url(#g1)" stroke="#c4c5c7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="281.8,224.7 375.5,258.3 510.4,318.9 414.4,400.8" fill="url(#g25)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="732.1,520.7 894.5,701.6 730.0,691.8" fill="url(#g19)" stroke="#ece5dd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="412.5,697.6 353.4,656.7 383.4,543.9 473.3,459.7" fill="url(#g31)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="320.8,754.9 288.6,624.8 486.2,759.6" fill="url(#g7)" stroke="#9fa3a6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="481.8,461.2 539.9,571.7 415.1,701.2" fill="url(#g5)" stroke="#acb0b2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="785.2,280.5 915.9,436.6 874.0,506.8 815.2,407.5" fill="url(#g15)" stroke="#f1ebe3" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="674.6,467.0 539.5,557.8 484.1,450.4" fill="url(#g30)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="829.1,553.8 825.4,615.9 692.3,464.8 719.1,401.9" fill="url(#g29)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="346.0,386.3 325.7,487.9 266.2,553.8 224.8,479.9" fill="url(#g28)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="266.0,555.8 310.0,745.6 202.7,639.4" fill="url(#g20)" stroke="#d7d1ca" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="772.9,160.4 839.1,206.4 782.1,265.6 730.4,279.6 711.1,253.1" fill="url(#g24)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="481.7,755.7 587.6,673.5 660.8,760.2" fill="url(#g22)" stroke="#d3cdc5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="656.2,206.4 767.6,153.0 644.6,335.1" fill="url(#g13)" stroke="#ede6de" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="837.1,618.3 834.8,444.4 906.2,561.9" fill="url(#g17)" stroke="#d1cdc6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="956.0,158.3 956.0,190.3 862.2,209.3 774.5,150.0 819.0,146.0" fill="url(#g12)" stroke="#f8f3ec" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="933.1,751.6 995.8,671.8 1073.3,758.9" fill="url(#g21)" stroke="#c2beb9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="778.2,149.8 640.3,144.9 552.5,57.2" fill="url(#g0)" stroke="#cbcacc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="928.4,545.6 882.6,680.7 833.3,626.6" fill="url(#g6)" stroke="#9b9da0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="421.4,698.7 579.0,670.2 483.0,746.1" fill="url(#g33)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="883.2,680.4 990.9,673.4 926.5,753.0" fill="url(#g8)" stroke="#b3b5b7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="930.3,539.5 906.2,561.1 871.3,508.2 912.0,435.2" fill="url(#g16)" stroke="#c5c0bc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="257.8,560.4 201.9,632.0 214.1,482.6" fill="url(#g18)" stroke="#d9d2c9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="783.1,149.1 712.8,111.3 830.2,116.0 874.7,144.4" fill="url(#g23)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="940.3,291.2 842.3,211.4 966.9,239.2" fill="url(#g14)" stroke="#d7d0c8" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="887.2,248.1 785.9,269.7 841.6,212.7" fill="url(#g3)" stroke="#acaeb1" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="771.8,272.5 797.9,379.7 733.9,288.2" fill="url(#g26)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="700.8,100.8 653.0,30.8 778.0,107.7" fill="url(#g10)" stroke="#ede6e0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="826.6,702.0 893.1,702.0 934.9,762.2" fill="url(#g9)" stroke="#a2a3a5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="954.9,193.0 958.8,135.4 989.6,179.7 970.4,218.2" fill="url(#g11)" stroke="#dcd6d2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="825.0,149.0 887.9,149.0 854.9,174.1" fill="url(#g2)" stroke="#b9bbbd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
</g>
</g></svg>

Before

Width:  |  Height:  |  Size: 13 KiB

BIN
demos/insertCoin/art/duskSky.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/fighterA.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/fighterB.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,67 +0,0 @@
-- Insert Coin's pixel font, written by util/insertCoinArt.py from its GLYPHS: rows top down, "#" lit.
return {
[" "] = { " ", " ", " ", " ", " ", " ", " " },
["!"] = { " # ", " # ", " # ", " # ", " # ", " ", " # " },
["\""] = { " # # ", " # # ", " ", " ", " ", " ", " " },
["#"] = { " # # ", "#####", " # # ", " # # ", " # # ", "#####", " # # " },
["$"] = { " # ", " ####", "# # ", " ### ", " # #", "#### ", " # " },
["%"] = { "## ", "## #", " # ", " # ", " # ", "# ##", " ##" },
["&"] = { " ## ", "# # ", "# # ", " # ", "# # #", "# # ", " ## #" },
["'"] = { " # ", " # ", " ", " ", " ", " ", " " },
["("] = { " # ", " # ", " # ", " # ", " # ", " # ", " # " },
[")"] = { " # ", " # ", " # ", " # ", " # ", " # ", " # " },
["*"] = { " ", " # ", "# # #", " ### ", "# # #", " # ", " " },
["+"] = { " ", " # ", " # ", "#####", " # ", " # ", " " },
[","] = { " ", " ", " ", " ", " ## ", " # ", " # " },
["-"] = { " ", " ", " ", "#####", " ", " ", " " },
["."] = { " ", " ", " ", " ", " ", " ## ", " ## " },
["/"] = { " ", " #", " # ", " # ", " # ", "# ", " " },
["0"] = { " ### ", "# #", "# ##", "# # #", "## #", "# #", " ### " },
["1"] = { " # ", " ## ", " # ", " # ", " # ", " # ", " ### " },
["2"] = { " ### ", "# #", " #", " # ", " # ", " # ", "#####" },
["3"] = { "#####", " # ", " # ", " # ", " #", "# #", " ### " },
["4"] = { " # ", " ## ", " # # ", "# # ", "#####", " # ", " # " },
["5"] = { "#####", "# ", "#### ", " #", " #", "# #", " ### " },
["6"] = { " ## ", " # ", "# ", "#### ", "# #", "# #", " ### " },
["7"] = { "#####", " #", " # ", " # ", " # ", " # ", " # " },
["8"] = { " ### ", "# #", "# #", " ### ", "# #", "# #", " ### " },
["9"] = { " ### ", "# #", "# #", " ####", " #", " # ", " ## " },
[":"] = { " ", " ## ", " ## ", " ", " ## ", " ## ", " " },
[";"] = { " ", " ## ", " ## ", " ", " ## ", " # ", " # " },
["<"] = { " # ", " # ", " # ", "# ", " # ", " # ", " # " },
["="] = { " ", " ", "#####", " ", "#####", " ", " " },
[">"] = { " # ", " # ", " # ", " #", " # ", " # ", " # " },
["?"] = { " ### ", "# #", " #", " # ", " # ", " ", " # " },
["@"] = { " ### ", "# #", "# ###", "# # #", "# ###", "# ", " ####" },
["A"] = { " ### ", "# #", "# #", "#####", "# #", "# #", "# #" },
["B"] = { "#### ", "# #", "# #", "#### ", "# #", "# #", "#### " },
["C"] = { " ### ", "# #", "# ", "# ", "# ", "# #", " ### " },
["D"] = { "#### ", "# #", "# #", "# #", "# #", "# #", "#### " },
["E"] = { "#####", "# ", "# ", "#### ", "# ", "# ", "#####" },
["F"] = { "#####", "# ", "# ", "#### ", "# ", "# ", "# " },
["G"] = { " ### ", "# #", "# ", "# ###", "# #", "# #", " ####" },
["H"] = { "# #", "# #", "# #", "#####", "# #", "# #", "# #" },
["I"] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["J"] = { " ###", " # ", " # ", " # ", " # ", "# # ", " ## " },
["K"] = { "# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #" },
["L"] = { "# ", "# ", "# ", "# ", "# ", "# ", "#####" },
["M"] = { "# #", "## ##", "# # #", "# # #", "# #", "# #", "# #" },
["N"] = { "# #", "# #", "## #", "# # #", "# ##", "# #", "# #" },
["O"] = { " ### ", "# #", "# #", "# #", "# #", "# #", " ### " },
["P"] = { "#### ", "# #", "# #", "#### ", "# ", "# ", "# " },
["Q"] = { " ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #" },
["R"] = { "#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #" },
["S"] = { " ####", "# ", "# ", " ### ", " #", " #", "#### " },
["T"] = { "#####", " # ", " # ", " # ", " # ", " # ", " # " },
["U"] = { "# #", "# #", "# #", "# #", "# #", "# #", " ### " },
["V"] = { "# #", "# #", "# #", "# #", "# #", " # # ", " # " },
["W"] = { "# #", "# #", "# #", "# # #", "# # #", "# # #", " # # " },
["X"] = { "# #", "# #", " # # ", " # ", " # # ", "# #", "# #" },
["Y"] = { "# #", "# #", " # # ", " # ", " # ", " # ", " # " },
["Z"] = { "#####", " #", " # ", " # ", " # ", "# ", "#####" },
["["] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["\\"] = { " ", "# ", " # ", " # ", " # ", " #", " " },
["]"] = { " ### ", " # ", " # ", " # ", " # ", " # ", " ### " },
["^"] = { " # ", " # # ", "# #", " ", " ", " ", " " },
["_"] = { " ", " ", " ", " ", " ", " ", "#####" },
}

BIN
demos/insertCoin/art/font.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/ground.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/hills.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/invaderA.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/invaderB.png (Stored with Git LFS)

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 427 KiB

BIN
demos/insertCoin/art/kart.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart1.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart2.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart3.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart4.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/kart5.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/mountains.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/raceSky.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/runner.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/scanlines.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/ship.png (Stored with Git LFS)

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
demos/insertCoin/art/target.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/track.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/insertCoin/art/turret.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,16 +0,0 @@
-- Insert Coin: a demo for Singe, to "Game Is On" by poorartistt.
GAMES = {
{
TITLE = "Insert Coin",
SCRIPT = "insertCoin/insertCoin.singe",
CANVAS_X = 1920,
CANVAS_Y = 1080,
RESOLUTION_X = 1920,
RESOLUTION_Y = 1080,
REQUIRES = { "SCENE", "POST", "SHADOWS" },
GAME_ID = "07678fa2-6d97-450f-ac8c-a7483d3ce9c1",
YEAR = 2026,
GENRE = "Demo",
DESCRIPTION = "A demo for Singe, to \"Game Is On\" by poorartistt."
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

BIN
demos/insertCoin/music.mp3 (Stored with Git LFS)

Binary file not shown.

File diff suppressed because one or more lines are too long

BIN
demos/mirrorball/art/bob.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/mirrorball/art/boing.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/mirrorball/art/club.png (Stored with Git LFS)

Binary file not shown.

BIN
demos/mirrorball/art/copper.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="193 22 889 749" width="889" height="749"><g transform="translate(1275 0) scale(-1 1)">
<!-- Singe dragon, reconstructed as polygons from Dragon.jpeg; transparent background. -->
<defs>
<linearGradient id="g0" gradientUnits="userSpaceOnUse" x1="698.7" y1="96.1" x2="629.6" y2="137.3"><stop offset="0" stop-color="#6f7277"/><stop offset="1" stop-color="#97969a"/></linearGradient>
<linearGradient id="g1" gradientUnits="userSpaceOnUse" x1="503.6" y1="307.1" x2="382.6" y2="159.3"><stop offset="0" stop-color="#66686f"/><stop offset="1" stop-color="#8a8b90"/></linearGradient>
<linearGradient id="g2" gradientUnits="userSpaceOnUse" x1="845.5" y1="159.5" x2="866.9" y2="156.5"><stop offset="0" stop-color="#73777b"/><stop offset="1" stop-color="#73767c"/></linearGradient>
<linearGradient id="g3" gradientUnits="userSpaceOnUse" x1="835.5" y1="229.1" x2="842.3" y2="253.1"><stop offset="0" stop-color="#54575d"/><stop offset="1" stop-color="#5a5d63"/></linearGradient>
<linearGradient id="g4" gradientUnits="userSpaceOnUse" x1="738.8" y1="402.4" x2="778.4" y2="373.8"><stop offset="0" stop-color="#4e5357"/><stop offset="1" stop-color="#55595e"/></linearGradient>
<linearGradient id="g5" gradientUnits="userSpaceOnUse" x1="502.9" y1="620.5" x2="456.0" y2="520.4"><stop offset="0" stop-color="#52575e"/><stop offset="1" stop-color="#5a6166"/></linearGradient>
<linearGradient id="g6" gradientUnits="userSpaceOnUse" x1="854.9" y1="616.3" x2="901.1" y2="622.7"><stop offset="0" stop-color="#33393e"/><stop offset="1" stop-color="#373c42"/></linearGradient>
<linearGradient id="g7" gradientUnits="userSpaceOnUse" x1="364.2" y1="664.4" x2="360.7" y2="746.1"><stop offset="0" stop-color="#353b40"/><stop offset="1" stop-color="#40474d"/></linearGradient>
<linearGradient id="g8" gradientUnits="userSpaceOnUse" x1="965.4" y1="697.5" x2="906.6" y2="706.0"><stop offset="0" stop-color="#63686c"/><stop offset="1" stop-color="#676c70"/></linearGradient>
<linearGradient id="g9" gradientUnits="userSpaceOnUse" x1="894.1" y1="714.4" x2="875.2" y2="729.2"><stop offset="0" stop-color="#43474a"/><stop offset="1" stop-color="#45474b"/></linearGradient>
<linearGradient id="g10" gradientUnits="userSpaceOnUse" x1="682.0" y1="67.3" x2="737.5" y2="95.0"><stop offset="0" stop-color="#d5c9bc"/><stop offset="1" stop-color="#dbcdc1"/></linearGradient>
<linearGradient id="g11" gradientUnits="userSpaceOnUse" x1="979.3" y1="183.9" x2="958.7" y2="176.5"><stop offset="0" stop-color="#9b9187"/><stop offset="1" stop-color="#b9aea5"/></linearGradient>
<linearGradient id="g12" gradientUnits="userSpaceOnUse" x1="924.1" y1="150.5" x2="852.7" y2="205.4"><stop offset="0" stop-color="#c2b6a8"/><stop offset="1" stop-color="#f1e7d9"/></linearGradient>
<linearGradient id="g13" gradientUnits="userSpaceOnUse" x1="663.8" y1="225.3" x2="726.2" y2="233.1"><stop offset="0" stop-color="#d4c4b2"/><stop offset="1" stop-color="#dccebd"/></linearGradient>
<linearGradient id="g14" gradientUnits="userSpaceOnUse" x1="945.6" y1="245.5" x2="885.2" y2="257.5"><stop offset="0" stop-color="#837b70"/><stop offset="1" stop-color="#b0a292"/></linearGradient>
<linearGradient id="g15" gradientUnits="userSpaceOnUse" x1="889.0" y1="403.3" x2="817.3" y2="411.5"><stop offset="0" stop-color="#a1998c"/><stop offset="1" stop-color="#e4d7c8"/></linearGradient>
<linearGradient id="g16" gradientUnits="userSpaceOnUse" x1="886.4" y1="506.4" x2="918.1" y2="507.5"><stop offset="0" stop-color="#857f74"/><stop offset="1" stop-color="#8b8279"/></linearGradient>
<linearGradient id="g17" gradientUnits="userSpaceOnUse" x1="881.7" y1="569.7" x2="827.2" y2="506.1"><stop offset="0" stop-color="#888175"/><stop offset="1" stop-color="#a49b8d"/></linearGradient>
<linearGradient id="g18" gradientUnits="userSpaceOnUse" x1="208.2" y1="559.7" x2="242.4" y2="555.4"><stop offset="0" stop-color="#aa9c8d"/><stop offset="1" stop-color="#b3a693"/></linearGradient>
<linearGradient id="g19" gradientUnits="userSpaceOnUse" x1="852.5" y1="644.9" x2="736.7" y2="633.3"><stop offset="0" stop-color="#928a7d"/><stop offset="1" stop-color="#daccbc"/></linearGradient>
<linearGradient id="g20" gradientUnits="userSpaceOnUse" x1="224.4" y1="631.6" x2="303.9" y2="662.7"><stop offset="0" stop-color="#8d8376"/><stop offset="1" stop-color="#b0a396"/></linearGradient>
<linearGradient id="g21" gradientUnits="userSpaceOnUse" x1="1017.2" y1="708.7" x2="974.7" y2="752.6"><stop offset="0" stop-color="#726b60"/><stop offset="1" stop-color="#867e73"/></linearGradient>
<linearGradient id="g22" gradientUnits="userSpaceOnUse" x1="560.7" y1="705.9" x2="600.9" y2="763.2"><stop offset="0" stop-color="#898072"/><stop offset="1" stop-color="#a89b8b"/></linearGradient>
<linearGradient id="g23" gradientUnits="userSpaceOnUse" x1="801.0" y1="120.8" x2="796.8" y2="140.0"><stop offset="0" stop-color="#e5e4e4"/><stop offset="1" stop-color="#eff0ef"/></linearGradient>
<linearGradient id="g24" gradientUnits="userSpaceOnUse" x1="795.2" y1="187.5" x2="744.9" y2="264.6"><stop offset="0" stop-color="#d6dad6"/><stop offset="1" stop-color="#e7e8e7"/></linearGradient>
<linearGradient id="g25" gradientUnits="userSpaceOnUse" x1="444.3" y1="360.6" x2="343.6" y2="248.4"><stop offset="0" stop-color="#eaeaea"/><stop offset="1" stop-color="#f1f1f0"/></linearGradient>
<linearGradient id="g26" gradientUnits="userSpaceOnUse" x1="774.0" y1="291.5" x2="756.7" y2="328.6"><stop offset="0" stop-color="#e0e1de"/><stop offset="1" stop-color="#e7e8e5"/></linearGradient>
<linearGradient id="g27" gradientUnits="userSpaceOnUse" x1="504.5" y1="428.2" x2="600.5" y2="343.0"><stop offset="0" stop-color="#e6e8e7"/><stop offset="1" stop-color="#e9eae8"/></linearGradient>
<linearGradient id="g28" gradientUnits="userSpaceOnUse" x1="270.4" y1="455.9" x2="305.1" y2="499.0"><stop offset="0" stop-color="#e7e8e7"/><stop offset="1" stop-color="#ececeb"/></linearGradient>
<linearGradient id="g29" gradientUnits="userSpaceOnUse" x1="780.3" y1="469.6" x2="741.5" y2="534.9"><stop offset="0" stop-color="#dbdfdb"/><stop offset="1" stop-color="#e6e7e4"/></linearGradient>
<linearGradient id="g30" gradientUnits="userSpaceOnUse" x1="572.4" y1="460.6" x2="554.8" y2="532.2"><stop offset="0" stop-color="#e1e3e0"/><stop offset="1" stop-color="#eaeae9"/></linearGradient>
<linearGradient id="g31" gradientUnits="userSpaceOnUse" x1="373.0" y1="591.4" x2="450.6" y2="588.1"><stop offset="0" stop-color="#e1e4e0"/><stop offset="1" stop-color="#e5e7e4"/></linearGradient>
<linearGradient id="g32" gradientUnits="userSpaceOnUse" x1="634.8" y1="522.2" x2="694.0" y2="646.0"><stop offset="0" stop-color="#cfd5d1"/><stop offset="1" stop-color="#d8ddd9"/></linearGradient>
<linearGradient id="g33" gradientUnits="userSpaceOnUse" x1="488.1" y1="692.3" x2="494.0" y2="725.6"><stop offset="0" stop-color="#d5dad6"/><stop offset="1" stop-color="#e2e5e2"/></linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="3" dy="5" stdDeviation="4" flood-color="#000" flood-opacity="0.18"/></filter>
<filter id="brush" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.003 0.7" numOctaves="2" seed="7" result="noise"/><feColorMatrix in="noise" type="saturate" values="0" result="gray"/><feComponentTransfer in="gray" result="grain"><feFuncA type="linear" slope="0.22"/></feComponentTransfer><feComposite in="grain" in2="SourceGraphic" operator="in" result="clipped"/><feBlend in="clipped" in2="SourceGraphic" mode="overlay"/></filter>
</defs>
<title>Singe dragon</title>
<g filter="url(#shadow)">
<polygon points="620.3,450.0 420.1,406.2 575.1,277.9 680.5,456.9" fill="url(#g27)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="718.9,692.9 547.2,565.9 642.9,498.6 688.8,474.3 721.0,511.5" fill="url(#g32)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="832.0,437.8 829.7,543.9 705.5,374.0 673.4,303.6 704.9,256.9" fill="url(#g4)" stroke="#aaacae" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="573.2,269.1 519.7,314.7 404.5,263.3 302.7,135.5" fill="url(#g1)" stroke="#c4c5c7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="281.8,224.7 375.5,258.3 510.4,318.9 414.4,400.8" fill="url(#g25)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="732.1,520.7 894.5,701.6 730.0,691.8" fill="url(#g19)" stroke="#ece5dd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="412.5,697.6 353.4,656.7 383.4,543.9 473.3,459.7" fill="url(#g31)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="320.8,754.9 288.6,624.8 486.2,759.6" fill="url(#g7)" stroke="#9fa3a6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="481.8,461.2 539.9,571.7 415.1,701.2" fill="url(#g5)" stroke="#acb0b2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="785.2,280.5 915.9,436.6 874.0,506.8 815.2,407.5" fill="url(#g15)" stroke="#f1ebe3" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="674.6,467.0 539.5,557.8 484.1,450.4" fill="url(#g30)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="829.1,553.8 825.4,615.9 692.3,464.8 719.1,401.9" fill="url(#g29)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="346.0,386.3 325.7,487.9 266.2,553.8 224.8,479.9" fill="url(#g28)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="266.0,555.8 310.0,745.6 202.7,639.4" fill="url(#g20)" stroke="#d7d1ca" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="772.9,160.4 839.1,206.4 782.1,265.6 730.4,279.6 711.1,253.1" fill="url(#g24)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="481.7,755.7 587.6,673.5 660.8,760.2" fill="url(#g22)" stroke="#d3cdc5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="656.2,206.4 767.6,153.0 644.6,335.1" fill="url(#g13)" stroke="#ede6de" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="837.1,618.3 834.8,444.4 906.2,561.9" fill="url(#g17)" stroke="#d1cdc6" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="956.0,158.3 956.0,190.3 862.2,209.3 774.5,150.0 819.0,146.0" fill="url(#g12)" stroke="#f8f3ec" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="933.1,751.6 995.8,671.8 1073.3,758.9" fill="url(#g21)" stroke="#c2beb9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="778.2,149.8 640.3,144.9 552.5,57.2" fill="url(#g0)" stroke="#cbcacc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="928.4,545.6 882.6,680.7 833.3,626.6" fill="url(#g6)" stroke="#9b9da0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="421.4,698.7 579.0,670.2 483.0,746.1" fill="url(#g33)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="883.2,680.4 990.9,673.4 926.5,753.0" fill="url(#g8)" stroke="#b3b5b7" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="930.3,539.5 906.2,561.1 871.3,508.2 912.0,435.2" fill="url(#g16)" stroke="#c5c0bc" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="257.8,560.4 201.9,632.0 214.1,482.6" fill="url(#g18)" stroke="#d9d2c9" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="783.1,149.1 712.8,111.3 830.2,116.0 874.7,144.4" fill="url(#g23)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="940.3,291.2 842.3,211.4 966.9,239.2" fill="url(#g14)" stroke="#d7d0c8" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="887.2,248.1 785.9,269.7 841.6,212.7" fill="url(#g3)" stroke="#acaeb1" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="771.8,272.5 797.9,379.7 733.9,288.2" fill="url(#g26)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>
<polygon points="700.8,100.8 653.0,30.8 778.0,107.7" fill="url(#g10)" stroke="#ede6e0" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="826.6,702.0 893.1,702.0 934.9,762.2" fill="url(#g9)" stroke="#a2a3a5" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="954.9,193.0 958.8,135.4 989.6,179.7 970.4,218.2" fill="url(#g11)" stroke="#dcd6d2" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
<polygon points="825.0,149.0 887.9,149.0 854.9,174.1" fill="url(#g2)" stroke="#b9bbbd" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>
</g>
</g></svg>

Before

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show more