diff --git a/CHANGELOG b/CHANGELOG
index e5b23a110..6ea543d95 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -32,6 +32,179 @@ API Changes
- Framework.singe applies the legacy sprite argument order once even when
a game loads it more than once.
+- Hypseus Singe extensions, so games written for it run unchanged:
+ spriteLoadFrames, spriteDrawFrame, spriteFrameWidth, spriteFrameHeight,
+ spriteResetColorKey, discAudioSuffix, singeSetQuitKeyEnabled (with the
+ Framework alias keyboardCatchQuit), vldpGetScale, vldpFocusArea,
+ vldpResetFocus, vldpSetMonochrome, overlaySetMonochrome,
+ vldpGetYUVPixel, controllerIsValid and controllerDoRumble. Sprite calls
+ take the handle first like the rest of 3.00; the LEGACY_SPRITE_ARGS
+ shim covers spriteDrawFrame and spriteResetColorKey.
+
+- More Hypseus Singe names, almost all of them supplied by
+ Framework.singe over calls Singe already had: setOverlaySize and
+ setOverlayResolution over overlaySetResolution, rewriteStatus as a stub,
+ and the six music calls over the sound family. setOverlayLinearScale is
+ a real engine call: it smooths the overlay as the window scales it,
+ which is the default and what Singe has always done, or takes the
+ nearest pixel for crisp pixel art.
+
+- The picture's place on screen is now one rectangle built from four
+ things in order: the window, --scalefactor (range widened from 50-100
+ to 25-100), --shiftx and --shifty (new; they move the picture inside the
+ room the scale left, -100 hard against one side and 100 against the
+ other, which is what Hypseus's own mean -- so at full scale there is no
+ room and a shift does nothing), and last the Sinden border, which is
+ therefore still a border whatever the shift and the scale did --
+ --scalefactor and --sindengun may now be used together. --rotate (new;
+ 0, 90, 180 or 270 degrees)
+ turns the whole presentation, video, overlay, GUIs, 3D scene and
+ particles together, filling a portrait window on a quarter turn. Mouse
+ and light gun positions follow all of it, so a click lands where it is
+ drawn. Scripts move the scale and the rotation while the game runs with
+ the Hypseus extensions vldpSetScale, vldpSetRotate and vldpGetRotate.
+
+- Bezels. --bezel names cabinet artwork in a bezels folder (--bezeldir
+ names another folder, --bezelflip draws the artwork in front of the
+ picture instead of behind it), and a Lua sidecar named after the
+ artwork declares the cutout the picture is drawn in, in the artwork's
+ own pixels: CUTOUT = { x = 200, y = 120, width = 800, height = 500 }.
+ With no cutout the picture keeps the window and --scalefactor,
+ --shiftx and --shifty place it, as before. A Sinden border goes to the
+ edge of the window when artwork is loaded, because the gun's camera
+ sees the whole screen; --sindenedge video|window overrides that.
+ Scripts ask mainBezelLoaded() which artwork they got and choose with
+ setOverlayOnTop whether the overlay sits above it.
+
+- The arcade score panel behind the seven scoreBezel calls: credits, and
+ a score and a life count for two players, in a shipped RmlUi document
+ (Singe/scoreBezel.rml and Singe/scoreBezel.rcss) with a seven segment
+ look that a theme or a game may restyle. It is off until a game asks
+ for it with scoreBezelEnable, exactly as in Hypseus, and it sits in the
+ room a bezel's cutout leaves above or below the picture, or along the
+ bottom of the picture when there is none. Hypseus's second argument,
+ which chooses among its five scoreboards, is accepted and ignored.
+ guiDrawScreen puts a game's own GUIs out there beside it: it is
+ guiDraw with the rectangle measured across the whole window.
+
+- A settings file. settings.cfg holds the options you would otherwise
+ type on every command line, in Lua like controls.cfg and found the same
+ four ways: the directory Singe was started in, above and inside the data
+ directory, and beside the game's script, with a later file winning key by
+ key. Its keys are the long option names without the dashes, so there is
+ no second vocabulary, and a switch takes true or false. A settings file
+ is a set of defaults: a games.dat entry for the game still overrides it
+ and the command line always does. Anything naming the game, naming a
+ directory, or acting before the file could be found (--framefile,
+ --gamedir, --datadir, --entry, --help, --apiversion, --noconsole, the
+ packing tools) belongs on the command line, and a key that is not an
+ option, is one of those, or carries a value the option will not take is
+ an error naming the file and the key. Singe/settings.cfg.example lists
+ every key with its default.
+
+- Options a cabinet owner has been missing, none of them with a short
+ letter since the alphabet ran out: --screen picks the display (counting
+ from 1; a number too large lists the displays that are there),
+ --idleexit quits after so many seconds with nothing touched,
+ --startsilent starts muted until the first input, --monochrome starts
+ the disc picture in grey, --linearscale chooses the overlay's filter,
+ --nogamepad ignores the pads as --nomouse ignores the mice, --haptic
+ caps the rumble a game may ask for (0 turns it off), --keymapfile names
+ one controls.cfg instead of searching four places, --altaudio (and the
+ AUDIO_SUFFIX games.dat key) plays .ogg beside the disc's
+ video for another language, --xratio and --yratio carry the gun
+ coordinate scales a Hypseus light gun game reads with ratioGetX and
+ ratioGetY, --fvalue carries one number to the game through getFValue,
+ and --apiversion prints one machine readable line for a front end and
+ exits. --nosound, which had been accepted and quietly ignored, now
+ actually mutes.
+
+- Every controller Singe opens is now a gamepad, whatever it started life
+ as. SDL calls a device a gamepad only when it has a mapping for it, and
+ an arcade encoder board, a spinner, a wheel or a flight stick usually has
+ none, so such a device used to be invisible. Singe now writes a
+ straight-through mapping for it -- axes in order to the two sticks and
+ then the triggers, buttons in order to the standard names, the first hat
+ to the D-pad -- and opens it as an ordinary pad, so it reaches
+ controls.cfg, GAMEPAD_0 to GAMEPAD_3 and every controller call like any
+ other. A device with more controls than the gamepad model can name loses
+ the extras, and one whose buttons are not wired in that order will have
+ them in the wrong places: --program traces the mapping in SDL's own
+ format so it can be pasted into a gamecontrollerdb.txt and rearranged.
+ Singe now reads that file, from the four places it reads controls.cfg,
+ and it and SDL's own database both win over the mapping Singe would
+ write. --mapjoysticks=false turns the writing off.
+
+- Input options for a cabinet: --gamepad_reorder says which physical pad
+ fills which slot (enumeration positions from 0, as Hypseus writes them),
+ --trigger_threshold says how far an analogue trigger must travel before
+ it counts as a button (a per cent of full travel, Hypseus's unit),
+ --joymouse lets the first pad's left stick drive the mouse cursor at
+ --js_range speed so a light gun game is playable on a stick-only
+ cabinet, --manymouse turns the many mouse mode on whatever the game asks
+ for so a two gun cabinet can be fixed from outside, and
+ --absolutes_only keeps only the devices that report an absolute
+ position, which is what a real gun does. controls.cfg gains DEAD_ZONES,
+ a dead zone for one axis at a time, and TRIGGER_THRESHOLD; both leave
+ every axis on DEAD_ZONE unless set, so nothing changes for anyone who
+ does not ask.
+
+- SWITCH_MOUSE_DISCONNECT reaches onInputPressed when a mouse or light gun
+ is unplugged, so a gun game can say so instead of appearing to have
+ died, and joyMouseEnable, joyMouseIsEnabled, controllerGetName and
+ mouseGetName let a script drive and describe all of it.
+
+- Alt-Enter switches between full screen and a window while the game runs,
+ as it does in Hypseus. Singe could not change the mode at all once it
+ had started.
+
+- Subtitles. srtLoad reads a SubRip .srt and turns its timestamps into
+ disc frames at the disc's own frame rate, so a line is found again after
+ any search or skip; srtEnable shows them, srtPosition puts them where a
+ game wants (1 to 95 per cent down the picture) and srtClear takes one
+ off early. They are drawn through a shipped RmlUi document,
+ Singe/subtitle.rml with Singe/subtitle.rcss, both of which a game or a
+ theme may replace, over the picture and following it wherever the scale,
+ a shift, a bezel cutout or a rotation put it. A malformed file answers
+ false rather than dying. overlayBanner puts a message of its own up the
+ same way for about thirty frames.
+
+- The disc picture gains three controls beside vldpSetMonochrome:
+ vldpFlash puts one white picture up the way a real player flashes when
+ it is searched, vldpSetBlend smooths the picture down its rows to take
+ the edges off an interlaced transfer, and vldpSetLuma brightens or
+ darkens it in eighths from half to one and a half without washing the
+ colour out. All three work on the decoded frame, cost nothing while
+ they are off, and follow a frame file from one segment to the next --
+ which vldpSetMonochrome now does too, and did not before.
+
+- Sprites. spriteRotateFrame turns one frame of a sheet and
+ spriteDrawRotatedFrame draws it centred on a point, which is what
+ spriteDrawFrame will not do since it ignores rotation and scale by
+ design; spriteDrawGrid draws any rectangle of a sprite at its own size,
+ which Singe had no way to do at all; spriteGetFrames counts a sheet's
+ frames; and spriteLoadData and soundLoadData build a sprite or a sound
+ from bytes held in a Lua string, for a game that unpacks its own assets.
+ setOverlayOpacity fades the whole overlay and controllerHowMany counts
+ the open gamepads.
+
+- Music has its own volume. The music family (musicLoad, musicPlay,
+ musicStop with a real fade, musicPause, musicResume, musicIsPlaying,
+ musicSetVolume, musicUnload) is now in the engine rather than shimmed
+ over the sound family, and carries a gain of its own on Hypseus's 0 to
+ 128 scale: musicSetVolume moves no sound effect and soundSetVolume
+ moves no music. Music starts as loud as the sound effects, at whatever
+ --volume_nonvldp set. Ten more Hypseus names -- hypseusGetWidth,
+ hypseusGetHeight, takeScreenshot, mouseHowManyReal and the six
+ spriteAnim ones -- are aliases in Framework.singe.
+
+- trace.txt now opens with a header a bug report can be built on: the
+ version, the whole command line, the operating system, the processor,
+ the renderer and GPU backend that were actually chosen, how video will
+ be decoded, which audio decoders the build carries, the SDL versions
+ built and linked against, the settings that were applied and the game. It sits between two rules so it can be
+ pasted whole.
+
- lfs.dir, lfs.attributes and lfs.symlinkattributes see inside a packed
game (the union of its loose directory, its data overlay and the
database), and lfs.mkdir and lfs.rmdir act on its data overlay, so
@@ -152,6 +325,20 @@ API Changes
layers, filter passes, mask writes, layer bytes, passes). Test scenes
scene40 to scene43 cover clip masks, layers, filters and gradients.
+- The Pi decodes video through its V4L2 memory-to-memory device (the
+ h264_v4l2m2m family of decoders) when the codec has one, falling back
+ to software when the device is missing, as on a Pi 5 for H.264. The
+ trace reports which decoder is in use and the format the device hands
+ back; --softwarevideo turns it off like the other hardware decoders.
+
+- The Menu.sh and Menu.bat the engine writes now look beside themselves
+ for a file whose name starts with Singe and run the first one, the way
+ the launchers BatMaker writes already did, instead of naming the
+ executable. The executable's name carries its version, so a launcher
+ that named it stopped working at the next release, and the thing that
+ would have rewritten it was the launcher itself. With --gamedir the
+ executable is elsewhere and its full path is still written.
+
- Third-party libraries brought up to their current releases: ffmpeg
9.0.1 (from a 2023 development snapshot; libpostproc is gone with it),
OpenSSL 3.5.8 LTS (from a 2023 development snapshot), Lua 5.4.9, zlib
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2e4346dec..520fe1c15 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -187,11 +187,16 @@ add_custom_command(
# Support files extracted at first run.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework_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/MenuClassic.singe ${GENERATED_DIR}/MenuClassic_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 "")
# Menu background video: two clips cropped to 4:3, scaled to 720x480, keyframed every second so the
@@ -528,6 +533,8 @@ if(KANGAROO_OS STREQUAL "linux")
endif()
elseif(KANGAROO_OS STREQUAL "pi")
set(SYSTEM_LIBS -ldl)
+ # The Pi decodes video through its V4L2 memory-to-memory device (videoPlayer.c).
+ target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_DECODE)
if(CMAKE_BUILD_TYPE STREQUAL "Release")
list(APPEND SYSTEM_LIBS -s)
endif()
diff --git a/assets/Framework.singe b/assets/Framework.singe
index aef62cdea..a9385c9c2 100644
--- a/assets/Framework.singe
+++ b/assets/Framework.singe
@@ -418,6 +418,61 @@ 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
@@ -451,6 +506,70 @@ if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil and not SINGE_LEGACY_SPRITE_WR
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, sourceX, sourceY, width, height)
+ return newSpriteDrawGrid(id, x, y, sourceX, sourceY, width, height)
+ 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
diff --git a/assets/controls.cfg b/assets/controls.cfg
index 76b0ac407..330ef42b4 100644
--- a/assets/controls.cfg
+++ b/assets/controls.cfg
@@ -24,8 +24,25 @@
-- 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 analogue 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 analogue 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.
INPUT_UP = { SCANCODE.UP, SCANCODE.KP_8, GAMEPAD_0.AXIS_LEFT_Y_U, GAMEPAD_0.AXIS_RIGHT_Y_U, GAMEPAD_0.DPAD_UP }
@@ -52,3 +69,6 @@ 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 }
+-- 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 = { }
diff --git a/assets/scoreBezel.rcss b/assets/scoreBezel.rcss
new file mode 100644
index 000000000..ab432f13a
--- /dev/null
+++ b/assets/scoreBezel.rcss
@@ -0,0 +1,96 @@
+/*
+ * Singe 3
+ * Copyright (C) 2006-2026 Scott Duensing
+ *
+ * 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. A game or a theme may replace this file.
+ * 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;
+}
diff --git a/assets/scoreBezel.rml b/assets/scoreBezel.rml
new file mode 100644
index 000000000..c75c78780
--- /dev/null
+++ b/assets/scoreBezel.rml
@@ -0,0 +1,34 @@
+
+
+
+ Score
+
+
+
+
+
+
1UP
+
888888
000000
+
LIVES
8
0
+
+
+
CREDITS
+
88
00
+
+
+
2UP
+
888888
000000
+
LIVES
8
0
+
+
+
+
diff --git a/assets/settings.cfg b/assets/settings.cfg
new file mode 100644
index 000000000..02469e8d5
--- /dev/null
+++ b/assets/settings.cfg
@@ -0,0 +1,124 @@
+--[[
+ *
+ * Singe 3
+ * Copyright (C) 2006-2026 Scott Duensing
+ *
+ * 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 directory Singe was started in
+-- 2. the directory above the game's data directory
+-- 3. the game's data directory
+-- 4. the game's own script directory
+--
+-- 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
+
+
+-- 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 grey
+
+
+-- 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 .ogg beside the disc video instead
+-- audiodelay = 0 -- milliseconds the audio is heard late (negative if early)
+
+
+-- 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 recognise
+-- 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
+-- 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
+-- program = false -- trace engine activity to trace.txt
+-- 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
diff --git a/assets/subtitle.rcss b/assets/subtitle.rcss
new file mode 100644
index 000000000..413a143f4
--- /dev/null
+++ b/assets/subtitle.rcss
@@ -0,0 +1,44 @@
+/*
+ * Singe 3
+ * Copyright (C) 2006-2026 Scott Duensing
+ *
+ * What a subtitle looks like: centred lines of light text on a dark bar just wide enough to hold
+ * them, at whatever height the game asked for, over the picture. A game or a theme may replace
+ * this file; the engine only ever sets #slot's contents and the .cue's top.
+ * 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);
+}
diff --git a/assets/subtitle.rml b/assets/subtitle.rml
new file mode 100644
index 000000000..283d97042
--- /dev/null
+++ b/assets/subtitle.rml
@@ -0,0 +1,19 @@
+
+
+
+ Subtitles
+
+
+
+
+
+
diff --git a/docs/Manual.adoc b/docs/Manual.adoc
index f6f60eea5..7c9843a05 100644
--- a/docs/Manual.adoc
+++ b/docs/Manual.adoc
@@ -102,6 +102,7 @@ previous menu, drawn with sprites and `fontPrint` straight into the overlay,
kept for one release for exactly that machine: edit `Menu.sh` (or `Menu.bat`)
to name it in place of `Singe/Menu.singe`.
+[[controls]]
=== Customizing the Controls
By default, Singe maps controllers as if they were Xbox gamepads. If your
@@ -109,6 +110,61 @@ controller is not recognized or you wish to change the default mappings as they
appear to Singe, you can use the free SDL2 Gamepad Tool from
http://generalarcade.com/gamepadtool/.
+[[devices]]
+==== Every Device Is a Gamepad
+
+Singe has one family of controller, `GAMEPAD_0` through `GAMEPAD_3`, and every
+device it opens is in it. SDL calls a device a gamepad only when it has a
+mapping for it, and an arcade encoder board, a spinner, a wheel or a flight
+stick often has none. Rather than carry a second kind of device with its own
+names, Singe writes a mapping for such a device itself and then opens it as an
+ordinary gamepad, so it reaches `controls.cfg`, `controllerGetAxis`,
+`controllerGetButton` and every `INPUT_*` switch exactly as a recognised pad
+does.
+
+The mapping Singe writes is a straight-through one: the device's axes in order
+become the left stick, the right stick and then the two triggers; its buttons
+in order become `BUTTON_A`, `BUTTON_B`, `BUTTON_X`, `BUTTON_Y`, `BUTTON_BACK`,
+`BUTTON_GUIDE`, `BUTTON_START`, `BUTTON_LEFT_STICK`, `BUTTON_RIGHT_STICK`,
+`BUTTON_LEFT_BUMPER` and `BUTTON_RIGHT_BUMPER`; and its first hat becomes the
+D-pad. A device with no hat spends its next four buttons on the D-pad instead.
+**A device with more controls than the gamepad model can name loses the
+extras** -- more than six axes, more than fifteen buttons, or a second hat --
+and a device whose buttons are not wired in that order will have them in the
+wrong places. That is the price of one family instead of two, and it is
+straightforward to correct: `--program` traces the mapping it wrote, in SDL's
+own format, so you can paste it into a `gamecontrollerdb.txt`, rearrange it and
+have it used instead.
+
+`--mapjoysticks=false` turns the writing off, in which case a device SDL does
+not recognise is not opened at all, exactly as in earlier releases.
+
+[[gamecontrollerdb]]
+==== gamecontrollerdb.txt
+
+SDL ships with a large database of controller mappings, and the community keeps
+a larger one -- `gamecontrollerdb.txt`, from
+https://github.com/mdqinc/SDL_GameControllerDB. Singe looks for that file in
+the same four places it looks for `controls.cfg`:
+
+. the directory Singe was started in;
+. the directory above the game's data directory;
+. the game's data directory;
+. the game's main script directory.
+
+Every file found is loaded, in that order, so one beside a game wins over a
+machine-wide one, and both win over SDL's own database. Only a device that
+none of them names gets the mapping Singe writes for it, so the order of
+precedence is: SDL's built-in database, then your `gamecontrollerdb.txt`, then
+the synthetic mapping. `--program` reports how many mappings each file added.
+
+A line of that file looks like this, and is exactly what the trace prints for a
+device Singe mapped itself:
+
+----
+ff00d87a475300004900000000007603,Arcade Encoder,platform:Linux,a:b0,b:b1,x:b2,y:b3,back:b4,guide:b5,start:b6,leftx:a0,lefty:a1,dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8,
+----
+
In addition to configuring your controller, you can remap all the input options
for Singe as a whole or for any individual game by using `controls.cfg` files.
When determining how inputs are mapped, Singe reads configuration settings in
@@ -116,7 +172,7 @@ the following order:
. The defaults from inside Singe.
. `controls.cfg` from the directory Singe was started in (the `Singe` directory holds a `controls.cfg.example` template you can copy).
-. `controls.cfg` from the root of the data directory.
+. `controls.cfg` from the directory above the game's data directory.
. `controls.cfg` from the game's data directory.
. `controls.cfg` from the game's main script directory.
@@ -128,6 +184,177 @@ After installing Singe, you can find a sample input configuration file at
options available to be used in this file, read through
`/Singe/Framework.singe`.
+`--keymapfile` names one file to read instead of making that search, which is
+useful to a front end that wants no doubt about which mappings a game got.
+
+[[deadzones]]
+==== Dead Zones and Triggers
+
+`DEAD_ZONE` in `controls.cfg` is how far an axis must move before it counts as
+a pressed switch, in the raw units SDL reports: `32767` at full deflection, so
+the default of `15000` is a little under half way. It covers every axis of
+every pad, the two analogue triggers included, and a script reads it as
+`SINGE_DEAD_ZONE`.
+
+Two settings narrow that down.
+
+`DEAD_ZONES` is a list of `{ axis, dead zone }` pairs for one axis at a time,
+in those same raw units, for a stiff stick or a worn trigger that should not
+move everything else:
+
+[source,lua]
+----
+DEAD_ZONES = {
+ { GAMEPAD_0.AXIS_LEFT_X, 8000 },
+ { GAMEPAD_1.AXIS_LEFT_TRIGGER, 30000 },
+}
+----
+
+The axis may be written as the axis itself or as either of its two directions
+(`GAMEPAD_0.AXIS_LEFT_X`, `AXIS_LEFT_X_L` and `AXIS_LEFT_X_R` all name the same
+axis). Anything not listed keeps `DEAD_ZONE`.
+
+`TRIGGER_THRESHOLD` is how far an analogue trigger must travel before it counts
+as a button, as a *per cent of full travel*, which is the unit Hypseus's
+`-trigger-threshold` uses and whose default there is `99.5`. It applies to both
+triggers of all four pads. Singe's default is `0`, meaning the triggers stay on
+`DEAD_ZONE`, so nothing changes for anyone who does not ask; a script reads the
+result in raw units as `SINGE_TRIGGER_THRESHOLD`. The two units convert
+directly: a per cent is `32767 / 100` raw units, so Singe's `DEAD_ZONE` of
+`15000` is `45.8` per cent and Hypseus's `99.5` per cent is `32603` raw units.
+`--trigger_threshold` sets the same number from the command line and wins over
+`controls.cfg`; `DEAD_ZONES` wins over both, being the most specific.
+
+[[joymouse]]
+==== Driving the Mouse With a Stick
+
+A light gun game on a cabinet with a stick and no mouse is unplayable unless
+the stick can move the cursor. `--joymouse` makes it do so: the **left stick of
+gamepad slot 0** moves the pointer the game sees, through the same
+`onMouseMoved` a real mouse reaches, and the cursor is held inside the picture.
+`--js_range` is the speed, `1` to `20` with `5` the default, measured in video
+pixels a frame at full deflection, so `5` crosses a 720 pixel picture in about
+two seconds. The axis obeys its own dead zone, so a drifting stick does not
+creep.
+
+It is off by default, which is where Singe differs from Hypseus: the shipped
+`controls.cfg` already binds that stick to `INPUT_UP`, `INPUT_DOWN`,
+`INPUT_LEFT` and `INPUT_RIGHT`, and having it drive the cursor as well without
+being asked would surprise every existing game. `--joymouse=false` is
+Hypseus's `-nojoymouse`, and a script turns it on and off with `joyMouseEnable`
+and reads it with `joyMouseIsEnabled`. A reload puts it back where the command
+line left it.
+
+[[mousedevices]]
+==== Mice, Guns, and Who Chooses
+
+`mouseSetMode(MOUSE_MANY)` is a game's decision to tell up to four mice apart,
+which a two gun cabinet needs and which a game that never makes the call cannot
+be given. `--manymouse` is the user's decision and outranks it: the mode is on
+from the start and `mouseSetMode(MOUSE_SINGLE)` no longer switches it off.
+
+`--absolutes_only` keeps only the devices that report an absolute position,
+which is what a real light gun does and an ordinary mouse does not, so a
+cabinet with two guns and a desk mouse plugged in is not confused by the mouse.
+Be aware of how it decides: **ManyMouse, the backend Singe uses to tell mice
+apart, offers no way to ask a device what kind it is**, so a device counts as
+absolute once it has reported an absolute position and not before. A gun does
+that with its first movement; until then it is silent, and a relative-only
+device stays silent for good. The option is therefore a filter by observed
+behaviour rather than by device class, and on a machine where no device ever
+reports an absolute position it silences every mouse.
+
+`SWITCH_MOUSE_DISCONNECT` is a switch like any other, except that no key or
+button produces it: the engine raises it when a mouse or a light gun is
+unplugged, once as a press and once as a release, so a gun game can say so on
+screen instead of appearing to have died. It is Hypseus's switch of the same
+name. `controls.cfg` leaves it bound to nothing; bind a key to it as well if
+you want to see what a game does without pulling a cable.
+
+[[hotkeys]]
+==== Hotkeys
+
+`Alt-Enter` switches between full screen and a window while the game runs, as
+it does in Hypseus. The game never sees the key. A window that started in
+exclusive full screen (`--fullscreen`) returns to it in the display mode it was
+given; one that started windowed gets a borderless window covering the desktop.
+Nothing else has to change with it: the renderer presents a logical size in
+video coordinates and scales that to whatever the window now is, the video
+rectangle is built in those same coordinates, and the rotation target is the
+size of the picture rather than of the window, so the picture, the overlay, the
+GUIs and the mouse mapping all follow by themselves.
+
+The other switches a user can press -- quit, pause, screenshot, the console and
+the mouse capture toggle -- are ordinary `controls.cfg` mappings and are
+described with the `INPUT_*` names in the sample file.
+
+[[settings]]
+=== The Settings File
+
+Everything on the command line can also be written down once, in a
+`settings.cfg` file, so a cabinet does not need a launcher script carrying the
+same dozen options for every game. It is Lua, like `controls.cfg`, and Singe
+looks for it in exactly the same four places, in this order:
+
+. `settings.cfg` from the directory Singe was started in (the `Singe` directory holds a `settings.cfg.example` template you can copy).
+. `settings.cfg` from the directory above the game's data directory.
+. `settings.cfg` from the game's data directory.
+. `settings.cfg` from the game's main script directory.
+
+Each place is read once, and a later file wins key by key, so a machine-wide
+file can set the display and the volumes while a file beside one game turns off
+its crosshair. You do not have to write every key in a file; write the ones you
+want changed.
+
+Every key is the long name of a command line option with the leading dashes
+removed, so there is no second vocabulary to learn:
+
+[source,lua]
+----
+-- The cabinet's own settings.cfg
+screen = 2 -- the second monitor
+fullscreen = true
+volume_vldp = 80
+haptic = 0 -- this cabinet has no rumble
+idleexit = 900 -- hand the screen back after fifteen quiet minutes
+startsilent = true
+----
+
+An option that takes no value on the command line takes `true` or `false` here
+(`yes`, `no`, `on`, `off`, `1` and `0` are accepted too). Setting one to `false`
+is the same as leaving it out. An option that takes a value takes the same value
+it would on the command line, as a number or a string.
+
+==== What wins
+
+. The built in default.
+. The settings file.
+. The game's `games.dat` entry, for the handful of settings it carries (see <>).
+. The command line, which always wins.
+
+A settings file is a set of defaults, in other words, not something you typed:
+if a `games.dat` entry gives a game a resolution or a Sinden border, that entry
+still describes the game better than a machine-wide file does. Anything actually
+typed on the command line beats both.
+
+Options that name the game (`--framefile`, `--disc`, `--entry`), name a
+directory (`--gamedir`, `--datadir`), or have to act before the file could be
+found (`--help`, `--apiversion`, `--noconsole`, `--pack`, `--patch`,
+`--unpack`) cannot be set in a settings file. A key that is not an option, one
+of those, or a value the option will not take is an error naming the file and
+the key, so a typo is never silently ignored:
+
+----
+settings.cfg: volume_vldpp is not an option.
+settings.cfg: datadir cannot be set here; it belongs on the command line.
+settings.cfg: Display scale must be between 25 and 100 percent.
+----
+
+With `--program` or `--trace`, the header at the top of `trace.txt` lists every
+setting that was applied, so there is never a question about which file a
+running game was given.
+
+[[commandlineoptions]]
=== Command Line Options
----
@@ -149,40 +376,257 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile.
| Option | Purpose
| `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See <>.
| `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`).
-| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`50` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. Cannot be combined with `--sindengun`.
+| `-B`, `--bezel=FILENAME` | Draw cabinet artwork around the picture. The file is looked for in the `bezels` folder of the game directory (a packed game may carry its own) and then in the `bezels` folder of the data directory; `--bezeldir` names a different folder. Any format SDL_image reads will do, and transparency is kept. A sidecar beside it says where the picture goes inside it; with none, the picture keeps the whole window and `--shiftx`, `--shifty` and `--scalefactor` place it. Artwork that cannot be found ends the program. See <>.
+| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`25` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. A script may change it while the game runs with `vldpSetScale`. Combines with `--shiftx`, `--shifty`, `--rotate` and `--sindengun`; see <>.
| `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging.
| `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc.
| `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored.
| `-d`, `--datadir=PATHNAME` | Directory for everything Singe writes: video indexes, `trace.txt`, screenshots, saves, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it (for a packed game, one named for the database and then for the script's directory inside it). Defaults to `data` in the game directory. Singe 2.x wrote beside the game instead; game directories may be read only, so nothing is written there any more.
| `-E`, `--entry=N` | Run the Nth entry of the `games.dat` inside a `.game` file (default 1). See <>.
| `-e`, `--volume_nonvldp=PERCENT` | Sound effect and extra video volume, `0` to `100`.
+| `-F`, `--bezelflip` | Draw the bezel artwork in front of the picture instead of behind it, which is what Hypseus does with its own bezels; the artwork's transparency is then the hole the game shows through. Without it the artwork is behind everything and the picture covers its cutout. A script chooses whether the overlay and the GUIs stay above the artwork with `setOverlayOnTop`. See <>.
| `-f`, `--fullscreen` | Exclusive full screen at the desktop resolution.
| `-G`, `--gamedir=PATHNAME` | The directory holding the games, the packed `.game` files and the `Singe` support folder, when the executable lives somewhere else. It becomes the working directory, so the game name, a relative `--datadir` and the packer's names all count from it; absolute paths work as they are. Defaults to the current directory.
-| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. Cannot be combined with `--scalefactor`.
-| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use.
+| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. The rings are drawn around whatever `--shiftx`, `--shifty` and `--scalefactor` left, so a border stays a border however the picture is placed; see <>.
+| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, the V4L2 decoder on the Pi, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use.
| `-h`, `--help` | Show the option summary and exit.
+| `-I`, `--bezeldir=PATHNAME` | The folder holding `--bezel`'s artwork, in place of `bezels`. Relative to the game directory, or absolute. Only that folder is searched. See <>.
| `-k`, `--nologos` | Skip the splash screens.
| `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`.
| `-m`, `--nomouse` | Disable mouse and lightgun input entirely.
| `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`.
| `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases.
| `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See <>.
-| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory.
+| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see <>.
+| `-S`, `--sindenedge=WHERE` | Where the Sinden border sits: `video` draws it around the picture, `window` at the edge of the screen. Without the option a bezel decides: artwork around the picture puts the border at the window's edge, because the gun's camera sees the whole screen, and with no artwork it goes around the picture as it always has. At the window's edge the two rings are drawn as rings, last of all, so the artwork between them and the picture stays visible. See <>.
| `-s`, `--nosound` | Mute all audio.
| `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit.
-| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`.
+| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see <>.
+| `-r`, `--rotate=DEGREES` | Turn the whole presentation clockwise by `0`, `90`, `180` or `270` degrees, for a cabinet whose panel is mounted on its side. The video, the overlay, the GUIs, the 3D scene and the particles all turn together, a quarter turn swaps the shape of the picture so a portrait window is filled, and mouse and light gun positions are turned back so a click still lands where it is drawn. Only the four right angles are accepted. A script may change it with `vldpSetRotate`.
| `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work.
| `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused.
| `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio.
| `-v`, `--framefile=FILENAME` | Video file or framefile to use instead of the one found next to the script.
| `-w`, `--fullscreen_window` | Borderless window covering the desktop.
+| `-X`, `--shiftx=PERCENT` | Move the picture horizontally inside the room `--scalefactor` left it, `-100` (hard against the left) to `100` (hard against the right), `0` centred. As in Hypseus, the shift is a percentage of that room and nothing more, so at `--scalefactor=100` there is no room and the shift does nothing: reduce the scale first. See <>.
| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted.
+| `-Y`, `--shifty=PERCENT` | Move the picture vertically inside the room `--scalefactor` left it, `-100` (hard against the top) to `100` (hard against the bottom), `0` centred. It does nothing at full scale, for the same reason. See <>.
| `-y`, `--yresolution=VALUE` | Window height.
| `-z`, `--noconsole` | Print nothing (and open no console window on Windows).
+| `--absolutes_only` | Keep only the mice that report an absolute position, which is what a real light gun does and an ordinary mouse does not. ManyMouse cannot be asked what a device is, so a device counts as absolute once it has reported an absolute position and not before; see <>. Hypseus writes it `-absolutes-only`; the name here uses an underscore because a settings file key has to be a Lua name. Default: off.
+| `--altaudio=SUFFIX` | Play `.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none.
+| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <>. Nothing else is printed.
+| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`.
+| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order.
+| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for.
+| `--idleexit=SECONDS` | Quit after `SECONDS` with no input of any kind -- no key, no button, no pad axis, no mouse. For an attract cabinet that should hand the screen back. `0` never quits. `0` to `86400`. Default: `0`.
+| `--joymouse=BOOLEAN` | Let the left stick of gamepad slot `0` drive the mouse cursor, for a light gun game on a cabinet with no mouse. `--joymouse=false` is Hypseus's `-nojoymouse`, and a script sets it with `joyMouseEnable()`. See <>. Default: `false`, where Hypseus's default is on -- the shipped `controls.cfg` already binds that stick to the four directions.
+| `--js_range=SPEED` | How fast that stick drives it, `1` to `20`, in video pixels a frame at full deflection. Default: `5`, as in Hypseus.
+| `--keymapfile=FILENAME` | Read the control mappings from this file instead of searching the four places for `controls.cfg`, so a front end can be certain which mappings a game got. The built in defaults still apply underneath it, so a partial file still works, and a file that is not there ends the program. Default: the four place search, described in <>.
+| `--linearscale=BOOLEAN` | Smooth the overlay as the window scales it (`true`) or take the nearest pixel (`false`, which keeps pixel art crisp). Takes `true`, `false`, `yes`, `no`, `on`, `off`, `1` or `0`. A script changes it while running with `setOverlayLinearScale`. Default: `true`, which is what every Singe release has drawn; Hypseus's own default is the opposite.
+| `--manymouse` | Tell up to four mice apart whatever the game asks for, which a two gun cabinet needs from a game that never calls `mouseSetMode(MOUSE_MANY)` itself. The user's choice outranks the game's: `mouseSetMode(MOUSE_SINGLE)` no longer switches it off. Default: off, and the game decides.
+| `--mapjoysticks=BOOLEAN` | Write a gamepad mapping for a device SDL does not recognise, so an arcade encoder, a spinner, a wheel or a flight stick is opened as `GAMEPAD_0` to `GAMEPAD_3` like anything else. `false` leaves such a device unopened, as earlier releases did. See <>. Default: `true`.
+| `--monochrome` | Start with the disc picture in grey. The overlay, the GUIs and the 3D scene keep their colour; a script does the same with `vldpSetMonochrome`, and undoes it. Default: off.
+| `--nogamepad` | Ignore every gamepad, the counterpart of `--nomouse`. None is opened and no pad event reaches the game, so a stuck arcade encoder cannot press anything. Default: off.
+| `--screen=N` | Open the window on display `N`, counting from `1` as Hypseus counts them. A number larger than the number of displays lists the displays that are there and exits. Default: the primary display.
+| `--startsilent` | Start muted and stay muted until the first input of any kind, then play at the configured volumes. For an attract cabinet in a quiet room. `--nosound` outranks it. Default: off.
+| `--trigger_threshold=PERCENT` | How far an analogue trigger must travel before it counts as a button, as a per cent of full travel -- Hypseus's unit, whose own default is `99.5`. `0` leaves the triggers on `DEAD_ZONE`, which is what Singe has always given them. Wins over `TRIGGER_THRESHOLD` in `controls.cfg`; `DEAD_ZONES` wins over both. `0` to `100`; see <>. Default: `0`.
+| `--xratio=FACTOR` | The horizontal scale a light gun game reads with `ratioGetX()` to stretch its own gun coordinates for a display whose shape does not match the video. Singe does nothing with it; the game does the arithmetic, exactly as in Hypseus. `0` to `100`, kept to two decimals as Hypseus keeps it. Default: `0`, which every game that reads it treats as `1`.
+| `--yratio=FACTOR` | The same for `ratioGetY()`. Default: `0`.
|===
Options may be given as `-x 640`, `-x640`, or `--xresolution=640`. Numeric
-values are validated; a bad value prints the usage text and exits.
+values are validated; a bad value prints the usage text and exits. The options
+at the end of the table have no short letter; the alphabet ran out, and a letter
+picked at random would help nobody.
+
+Anything here except the options that name the game, name a directory, or must
+act before a file can be found may also be set in a settings file, so a cabinet
+does not need a launcher script for every game. See <>.
+
+[[apiversion]]
+==== The Version Line
+
+`--apiversion` prints one line and exits with status `0`:
+
+----
+singe version=3.00 protocol=1
+----
+
+This is a contract for front ends and will not change shape:
+
+* The line ends with a newline and is the only thing written to standard
+ output. Nothing else is printed, whatever else is on the command line.
+* The first field is always `singe`.
+* Every field after it is `key=value`, separated by one space, with no spaces
+ inside a field.
+* `version` is the release, the same number the banner shows.
+* `protocol` is the version of this line's format. It becomes `2` only if the
+ rules above ever have to change.
+* Later releases may add fields. A reader must ignore keys it does not know
+ rather than treating them as an error.
+
+[[videorect]]
+=== The Video Rectangle
+
+Everything a game shows -- the disc, the overlay, the GUIs, the 3D scene and
+the particles -- is drawn into one rectangle, and a handful of options decide
+where that rectangle is. They apply in this order:
+
+. *The window, or the bezel's cutout.* The rectangle starts as the whole
+ window. Singe works in the video's own coordinates and lets the window
+ letterbox them (`--stretch` fills instead), so nothing here depends on the
+ window's size. When `--bezel` loaded artwork that declares a cutout, the
+ rectangle starts as that hole instead and everything below places the picture
+ inside it; see <>.
+. *`--scalefactor`.* The rectangle shrinks to that percentage of what it
+ started as, about its own centre.
+. *`--shiftx` and `--shifty`.* The rectangle then moves inside the room the
+ scale left, `-100` hard against one side and `100` hard against the other,
+ which is what Hypseus's own `-shiftx` and `-shifty` mean. Two consequences
+ follow from that and are worth knowing: the picture can never be pushed off
+ the screen, and *at `--scalefactor=100` there is no room and a shift does
+ nothing at all*. To move the picture on a badly mounted panel, reduce the
+ scale first: `--scalefactor=90 --shiftx=-100` puts the shrunken picture hard
+ against the left.
+. *`--sindengun`.* The border is applied last, to whatever the shift and the
+ scale left. The black ring takes the rectangle's edge, the white ring sits
+ inside it, and the game goes inside both, so a border is still a border
+ however the picture was placed. A scale small enough to leave no room for the
+ game squeezes the two rings rather than swallowing it. With bezel artwork
+ loaded the border goes to the window's edge instead and leaves the picture the
+ size it already was; `--sindenedge` decides it either way.
+
+`--rotate` is not part of that order. The frame is drawn unrotated and the
+finished picture is turned clockwise as a whole, so a shift moves the picture
+along the video's own axes and turns with it. A quarter turn also swaps the
+shape of the presented area, so a portrait window is filled rather than
+letterboxed into a strip.
+
+Mouse and light gun positions travel the same path backwards: the rotation
+comes off first, and the position then maps into the rectangle, so a click
+lands where the player sees it under any combination of these options.
+`vldpGetScale`, `vldpSetScale`, `vldpGetRotate` and `vldpSetRotate` let a
+script move the scale and the rotation while the game runs; the shift is a
+display setting and has no script call. None of them is a `games.dat` key:
+like `--scalefactor`, they describe the player's screen, not the game.
+
+[[bezels]]
+=== Bezels
+
+A bezel is the artwork around an arcade cabinet's screen. Singe draws one from a
+single image file, with the game's picture in a hole cut out of it, and can show
+an arcade scoreboard in the space that leaves. Three options control it, and
+they follow Hypseus's own convention so a cabinet's command line moves across
+unchanged.
+
+`--bezel cabinet.png` names the artwork. It is looked for in this order:
+
+. `bezels/cabinet.png` in the game directory -- inside a packed `.game`, its own
+ copy, so a game can carry its bezel with it;
+. `bezels/cabinet.png` in the data directory, where a user's own artwork can
+ live without touching a read-only game directory.
+
+`--bezeldir art/cabinets` replaces the `bezels` folder with the one it names,
+relative to the game directory or absolute, and then only that folder is
+searched. Any format SDL_image reads will do (`.png` is the sensible choice,
+because transparency matters). Artwork the option names and Singe cannot find
+ends the program rather than running without it.
+
+The artwork is drawn over the whole window, whatever shape either of them is, so
+it stretches with the window exactly as the picture does. Give it the aspect
+ratio you expect to play at.
+
+==== The cutout
+
+Beside the image, and named after it, a sidecar file says where the picture
+goes: `bezels/cabinet.png` is described by `bezels/cabinet.cfg`. It is a Lua
+file, as `controls.cfg` is, and sets one table in the artwork's *own pixels*:
+
+[source,lua]
+----
+-- The hole in cabinet.png the picture is drawn in, in the artwork's own pixels.
+CUTOUT = { x = 200, y = 120, width = 800, height = 500 }
+----
+
+All four fields are required and the rectangle must lie inside the artwork; a
+sidecar that will not run, or that names a rectangle outside the image, ends the
+program with a message. Because the numbers are the artwork's own, they stay
+right at any window size: the image and the hole are scaled together.
+
+With a cutout, the picture is drawn in that hole -- stretched to it, so the hole
+decides the shape of the picture -- and `--scalefactor`, `--shiftx` and
+`--shifty` then work *inside* it, shrinking and nudging the picture in the hole.
+With no sidecar the picture keeps the whole window, exactly as it does with no
+bezel, and those three options place it as they always have. See
+<>.
+
+The hole itself should be transparent in the image. Singe draws the artwork
+behind the picture by default, so an opaque middle would simply be covered, but
+`--bezelflip` puts the artwork in front and then only its transparency lets the
+game through.
+
+==== Draw order
+
+Without `--bezelflip` the artwork is the first thing in the frame: the picture,
+the overlay, the GUIs, the 3D scene and the particles all draw over it. With
+`--bezelflip` the artwork draws in front of the picture instead, which is the
+order Hypseus uses for its own bezels, so a frame with soft or shaped edges
+overlaps the picture properly.
+
+A script decides what happens to the overlay in that case:
+<> draws the overlay and the GUIs above
+the artwork, and `false` (the default, and Hypseus's) leaves the artwork on top
+of them. With the artwork behind the picture the setting changes nothing,
+because everything is already above it.
+<> tells a script whether artwork is loaded
+and gives it the artwork's identifier, the same number Hypseus reports, so a
+game written for a particular bezel can recognise it.
+
+Screen space GUIs and the score panel always draw above the artwork, since the
+point of them is to sit on it.
+
+==== The Sinden border
+
+A Sinden light gun watches the screen for a bright border. With bezel artwork
+loaded that border belongs at the *edge of the window*, not around the picture,
+because the gun's camera sees the whole screen, so that is where `--sindengun`
+puts it as soon as a bezel is loaded. There it is drawn as two rings, after
+everything else, so the artwork between the border and the picture stays visible
+and nothing paints over the white ring. With no bezel the border goes around the
+picture, as it always has, and shrinks it.
+
+`--sindenedge video` or `--sindenedge window` overrides that choice in either
+direction: a gun cabinet whose artwork is only a thin frame may still want the
+border around the picture, and a cabinet with no artwork at all may want it at
+the screen's edge.
+
+==== The score panel
+
+Sixteen of the Hypseus Singe games can drive an arcade scoreboard: credits, and
+a score and a life count for each of two players. In Singe that scoreboard is
+the RmlUi document `Singe/scoreBezel.rml`, styled by `Singe/scoreBezel.rcss`,
+both written beside the other support files and both replaceable -- a theme or a
+game may restyle the panel by editing them, exactly as it may restyle
+`Singe/gui.rcss`. The shipped style is a seven segment look: every readout shows
+its digits dimly lit behind the live value, the way a real display does.
+
+The panel is *off* until a game asks for it with
+<>, which is how Hypseus ships as
+well, so a game that never asks behaves exactly as before and draws its own
+scoreboard with sprites. <> is the
+question every such game asks first.
+
+Where it goes is decided for you: the panel takes the deeper of the bands the
+picture leaves above and below it -- the room a bezel's cutout usually leaves --
+centred on the picture, and lies along the bottom of the picture when neither
+band is deep enough to hold it. It is drawn as a screen space GUI, so a game may
+put its own GUIs on the artwork beside it with
+<>.
+
+Hypseus's `scoreBezelEnable` takes a second argument choosing among its five
+scoreboard backends. Singe has one, so the argument is accepted and ignored.
== Frequently Asked Questions
@@ -334,7 +778,11 @@ packed `.game` files, and a `data` directory for everything the games and
the engine write. The executable usually sits there too, but need not:
`--gamedir` names the game directory from anywhere, and the `Menu.sh` (or
`Menu.bat`) the engine writes there then runs the executable by its full
-path.
+path. Otherwise the launcher searches beside itself for a file whose name
+begins with `Singe` and runs the first one it finds, the same way the
+launchers `BatMaker` writes do, so an upgrade that changes the executable's
+version in its name does not leave a launcher pointing at a file that is no
+longer there.
----
Singe/ Support files extracted by the engine
@@ -750,12 +1198,15 @@ GAMES = {
The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
-`AUDIO_DELAY`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
+`AUDIO_DELAY`, `AUDIO_SUFFIX`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
script, through `scriptExecute` / `scriptPush`) launches the entry; they
override the command line. The `VIDEO` line is the disc: an entry that names
one is a laserdisc game, and an entry that leaves it out or blank runs
without a disc and may set `CANVAS_X` / `CANVAS_Y` (default 720x480); see
-<>. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10
+<>. `AUDIO_SUFFIX = "-es"` plays
+`-es.ogg` beside the disc's video instead of the audio inside it, for a
+release whose other languages ship as separate files, the same thing
+`--altaudio` does. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10
with the old sprite argument order (see <>). The remaining keys are read by the menu for display.
@@ -767,8 +1218,10 @@ script (entry paths count from the directory above the `games.dat`, so
keys apply. The one difference from a menu launch is precedence: an option
typed on the command line (`--framefile`, `--disc`, `--stretch`, `--nomouse`,
`--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`,
-`--canvas`) keeps its value, since the person typing it meant it. A
-`games.dat` that fails to run is reported and ignored for a direct launch.
+`--altaudio`, `--canvas`) keeps its value, since the person typing it meant it.
+A value from a settings file does not: it is a default, and the entry describes
+the game better (see <>). A `games.dat` that fails
+to run is reported and ignored for a direct launch.
[#withoutadisc]
=== Games Without a Disc
@@ -1229,6 +1682,65 @@ The same page on the screen of a modeled cabinet is the two lines from
<> in place of `guiDraw`, and a click on the
monitor presses the button under it.
+[#subtitles]
+=== Subtitles
+
+A game can ship a SubRip subtitle file and have Singe show it over the disc as it plays. Three calls do the whole job: <> reads the file, <> switches it on, and <> says where the lines sit. <> takes whatever is up off again. The feature came from Hypseus Singe and the four calls are its own.
+
+[source,lua]
+----
+if srtLoad("subs/english.srt") then
+ srtPosition(80)
+ srtEnable(true)
+end
+----
+
+==== The file format
+
+SubRip (`.srt`) is a plain text file of numbered cues:
+
+----
+1
+00:00:04,120 --> 00:00:06,880
+Stay away from the caves.
+
+2
+00:00:07,000 --> 00:00:09,500
+I mean it -- the caves
+are where they nest.
+----
+
+Each cue is a number on a line of its own, then a line holding two timestamps separated by `-->`, then one or more lines of text, then a blank line. A timestamp is `HH:MM:SS,mmm`: hours, minutes and seconds separated by colons, and milliseconds after a comma. The cue number is read and thrown away, so the numbers need not be in order or even correct.
+
+The parser is deliberately forgiving in the places a hand-edited file goes wrong. Carriage returns, leading spaces and trailing spaces are stripped from every line, so a file written on Windows loads unchanged. A cue whose time line has no `-->`, or whose timestamps do not parse, is skipped and the file goes on loading. A cue with no text is skipped. A cue's lines are joined with line breaks and shown as written, and `<`, `>` and `&` reach the screen as themselves rather than being taken for markup.
+
+What is *not* supported: the positioning coordinates some tools append to the time line are ignored (`srtPosition` decides the height), and SubRip's occasional `` and `` tags are shown as text rather than obeyed. Cues may overlap in time; the last one that has started wins.
+
+`srtLoad` answers `false` and loads nothing when the file cannot be read, when nothing in it parses, or when there is no disc -- so a broken file is a `false` a game can act on and not a crash. It prints a warning naming the file in each case.
+
+==== The timing
+
+Timestamps are turned into *disc frame numbers* as the file is loaded, using the frame rate of the disc that is loaded at the time, and it is the frame number the engine matches every drawn frame. Two things follow from that.
+
+First, **a subtitle survives a seek.** Sending the disc anywhere -- `discSearch`, `discSkipForward`, a step, a frame file crossing into another segment -- puts up the cue covering wherever it landed, on the next drawn frame, forwards or backwards. Nothing has to be reloaded and no clock has to be reset.
+
+Second, **the file must be timed against the disc, not against the wall clock.** A `.srt` cut for a video at a different frame rate lands in the wrong place, and one loaded before the disc has been opened does not load at all. Load subtitles after the disc, which for an ordinary game means from the script's body or from `onStart`.
+
+A cue is shown while the disc's frame is between its two frames inclusive. Only the cue is redrawn when it changes, so subtitles cost nothing while a line is up.
+
+==== Styling
+
+The lines are an RmlUi document, not a blitted text line, so they are styled the way a GUI is. Two files ship in the `Singe` folder and are extracted beside the others at first run:
+
+* `Singe/subtitle.rml` -- the document. It has one element, `#slot`, that the engine writes the current cue or banner into.
+* `Singe/subtitle.rcss` -- the look. `.cue` is the block that carries the height `srtPosition` asked for, as an inline `top`; `.cue .text` is the bar of text inside it, which by default is white on a dark rounded panel that hugs the text rather than spanning the picture.
+
+Replace either file to restyle. The engine only ever sets `#slot`'s contents and the `.cue`'s `top`, so anything else -- the face, the size, the colours, the panel, a border, a shadow -- belongs in the RCSS and survives an engine upgrade. The document is drawn over the picture, above the overlay, below the screen space GUIs, follows the video rectangle wherever `--scalefactor`, `--shiftx`, a bezel cutout or `--rotate` put it, and takes no mouse input, so a click passes straight through it.
+
+<> uses the same document, so a banner and a subtitle look alike and a game that restyles one restyles both.
+
+On a machine with no GPU there are no GUIs at all; `srtLoad` still parses the file and answers truthfully, and nothing is drawn. A warning says so once.
+
[#scenes3d]
=== 3D Scenes
@@ -2477,7 +2989,7 @@ available to `controls.cfg` and to `Framework.singe` alike:
[cols="2,3",options="header"]
|===
| Constant | Meaning
-| `SWITCH_UP` ... `SWITCH_GRAB` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`.
+| `SWITCH_UP` ... `SWITCH_MOUSE_DISCONNECT` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` is raised by the engine itself when a mouse or light gun is unplugged, once as a press and once as a release; no key or button produces it.
| `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments for `fontQuality`.
| `MODE_NORMAL`, `MODE_FULL` | Arguments for `keyboardSetMode`.
| `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`.
@@ -2499,7 +3011,8 @@ available to `controls.cfg` and to `Framework.singe` alike:
| `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Tone curves for `sceneSetTonemap`.
| `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free.
| `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns.
-| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`.
+| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`, in raw axis units.
+| `SINGE_TRIGGER_THRESHOLD` | The analogue trigger threshold in those same raw units; `0` when the triggers are on `SINGE_DEAD_ZONE`, which is the default.
| `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order.
| `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world.
| `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE` | Layout of the controller and mouse input codes; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them.
@@ -2588,8 +3101,12 @@ lost.
Singe decodes video with FFmpeg's libraries directly, using the platform's hardware
decoder when it offers one for the codec (VA-API or VDPAU on Linux, D3D11VA on
-Windows, VideoToolbox on macOS) and its own software decoder otherwise; the
-program trace reports which, and `--softwarevideo` forces software. Any container
+Windows, VideoToolbox on macOS, and on the Pi the V4L2 memory-to-memory decoder,
+which on a Pi 4 covers H.264; a Pi 5 has no H.264 decoder and its HEVC
+decoder needs a kind of driver FFmpeg does not yet ship, so encode Pi games
+as H.264 and expect software decoding on a Pi 5) and its own software
+decoder otherwise; the program trace reports which, and `--softwarevideo` forces
+software. Any container
and codec the
bundled FFmpeg can demux and decode will play: MP4, MKV, MPEG program streams,
AVI, and the classic Daphne `.m2v` elementary streams with a matching `.ogg`
@@ -2959,6 +3476,223 @@ function onWaveFinished()
end
----
+[#bezelref]
+=== Bezel
+
+Cabinet artwork and the arcade scoreboard that goes with it. `mainBezelLoaded`
+asks about the artwork `--bezel` loaded; the `scoreBezel` family drives the score
+panel, an RmlUi document the engine ships and either a theme or a game may
+restyle. Every call here is a Hypseus Singe extension, kept so its games run
+unchanged, and every one of them is described from the player's side in
+<>.
+
+The panel starts switched off, as it does in Hypseus, and only a game turns it
+on. Values given to it are remembered whether it is showing or not, so a game
+may set them before it enables the panel. Each readout has the number of digits
+Hypseus's scoreboard has -- six for a score, two for the credits, one for a life
+count -- and a value is shown right justified in them with leading zeros; a
+score of more than six digits shows its last six. Hypseus's negative values are
+understood too: `-2` lights a dash in every digit, `-4` an `H` and `-5` an `A`,
+and `-1`, `-3` and any other negative number light nothing.
+
+[#mainbezelloaded]
+==== mainBezelLoaded
+
+[source,text]
+----
+loaded, id = mainBezelLoaded()
+----
+
+Hypseus extension. Whether `--bezel` loaded cabinet artwork, and the identifier
+of the file it loaded. The identifier is the 64 bit FNV-1a hash of the file's
+bytes written out as a decimal string, which is what Hypseus reports, so a game
+that recognises a particular bezel by its number recognises the same file here.
+With no artwork loaded the answers are `false` and `"0"`.
+
+*Returns:* boolean, and the identifier as a string.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Only draw the sprite frame when the player has no artwork of their own.
+local loaded, id = mainBezelLoaded()
+if not loaded then
+ spriteDraw(frameSprite, 0, 0)
+end
+----
+
+[#scorebezelclear]
+==== scoreBezelClear
+
+[source,text]
+----
+scoreBezelClear()
+----
+
+Hypseus extension. Blanks every readout on the score panel: the credits, both
+scores and both life counts show nothing at all, leaving only the dim digits
+behind them. The values are not forgotten, but the next `scoreBezelCredits`,
+`scoreBezelScore` or `scoreBezelLives` lights the panel again with whatever it is
+then holding. Safe to call with the panel switched off.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+[#scorebezelcredits]
+==== scoreBezelCredits
+
+[source,text]
+----
+scoreBezelCredits(credits)
+----
+
+Hypseus extension. Sets the credit readout, which has two digits. Also lights a
+panel that `scoreBezelClear` blanked.
+
+*Parameters:*
+
+* `credits` -- integer; the last two digits are shown.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+[#scorebezelenable]
+==== scoreBezelEnable
+
+[source,text]
+----
+scoreBezelEnable(enabled)
+scoreBezelEnable(enabled, type)
+----
+
+Hypseus extension. Shows or hides the score panel. The first `true` builds it
+from `Singe/scoreBezel.rml`; hiding and showing it again after that costs
+nothing and keeps its values. It starts hidden, which is how Hypseus ships as
+well, so a game that never calls this behaves exactly as it did before -- and
+should ask <> before assuming it has one.
+
+Hypseus's `type` chooses among its five scoreboard backends. Singe has one, an
+RmlUi document, so the argument is checked and ignored. A machine with no GPU has
+no GUIs at all; there the panel cannot be built, `scoreBezelGetState` keeps
+answering `false`, and a warning is printed once. A non-boolean first argument
+aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+* `type` -- integer, accepted and ignored.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- The engine's panel when there is one, sprites when there is not.
+scoreBezelEnable(true, 0)
+if scoreBezelGetState() then
+ scoreBezelTwinScoreOn(twoPlayer)
+ scoreBezelCredits(credits)
+else
+ drawSpriteScoreboard()
+end
+----
+
+[#scorebezelgetstate]
+==== scoreBezelGetState
+
+[source,text]
+----
+state = scoreBezelGetState()
+----
+
+Hypseus extension. Whether the score panel is showing. Every Hypseus game asks
+this before using the panel and draws its own scoreboard with sprites when the
+answer is `false`, which is the right fallback and not a failure.
+
+*Returns:* boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+[#scorebezellives]
+==== scoreBezelLives
+
+[source,text]
+----
+scoreBezelLives(player, lives)
+----
+
+Hypseus extension. Sets a player's life readout, which has one digit. `player` is
+`1` or `2`; any other number is ignored, and so is `2` until
+<> has put the second
+player's readouts on the panel. Also lights a panel that `scoreBezelClear`
+blanked.
+
+*Parameters:*
+
+* `player` -- `1` or `2`.
+* `lives` -- integer; the last digit is shown.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+[#scorebezelscore]
+==== scoreBezelScore
+
+[source,text]
+----
+scoreBezelScore(player, score)
+----
+
+Hypseus extension. Sets a player's score readout, which has six digits. `player`
+is `1` or `2`, with the same rule for `2` as `scoreBezelLives`. Also lights a
+panel that `scoreBezelClear` blanked.
+
+*Parameters:*
+
+* `player` -- `1` or `2`.
+* `score` -- integer; the last six digits are shown.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+.Example
+[source,lua]
+----
+function addScore(points)
+ score = score + points
+ if scoreBezelGetState() then
+ scoreBezelScore(1, score)
+ end
+end
+----
+
+[#scorebezeltwinscoreon]
+==== scoreBezelTwinScoreOn
+
+[source,text]
+----
+scoreBezelTwinScoreOn(enabled)
+----
+
+Hypseus extension. Puts the second player's score and life readouts on the panel,
+or takes them off again and leaves the room to the other two. Switching them off
+also blanks the panel, exactly as it does in Hypseus, so follow it with a value
+if the first player's readouts should stay lit. While they are off,
+`scoreBezelScore(2, ...)` and `scoreBezelLives(2, ...)` do nothing. A non-boolean
+argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
[#body]
=== Body
@@ -3637,9 +4371,148 @@ end
----
[#controller]
+[#commandlineref]
+=== Command Line
+
+Three calls report numbers the user chose when Singe was started. They exist so
+a game can be tuned from the launcher rather than by editing the game. All three
+are Hypseus Singe extensions, and Singe does nothing with the values itself: the
+game decides what they mean. The options are described in
+<>, and they may also be set in a
+settings file (see <>).
+
+None of the three changes during a run, so read each one once and keep it.
+
+[#getfvalue]
+==== getFValue
+
+[source,text]
+----
+value = getFValue()
+----
+
+Hypseus extension. Returns the number given as `--fvalue`, or `0` when the
+option was not used. It is a channel of one number from whoever started Singe to
+the game, meant for a value a front end wants to vary between launches -- a
+difficulty, a starting credit count, a menu index. The engine only carries it.
+
+The value runs from `0` to `100000` and keeps three decimals, which is what
+Hypseus keeps, so a game comparing the number sees the same one it saw there.
+
+*Returns:* number.
+
+*Since:* 3.00 (Hypseus)
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- The launcher passes --fvalue 2 for the hard cabinet.
+local difficulty = getFValue()
+if difficulty == 0 then
+ difficulty = 1
+end
+----
+
+[#ratiogetx]
+==== ratioGetX
+
+[source,text]
+----
+factor = ratioGetX()
+----
+
+Hypseus extension. Returns the number given as `--xratio`, or `0` when the
+option was not used. Light gun games written for Hypseus read it and stretch
+their own gun coordinates with it, for a cabinet whose screen is not the shape
+the video was mastered in -- `1.33` maps a 16:9 panel onto 4:3 footage. Singe
+does not apply it to anything; the game does the arithmetic, exactly as in
+Hypseus, which is why every game that reads it treats `0` and anything below `1`
+as `1`.
+
+The value runs from `0` to `100` and keeps two decimals, as Hypseus keeps it.
+
+*Returns:* number.
+
+*Since:* 3.00 (Hypseus)
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- The shape of the gun coordinates this cabinet wants.
+local ratioX = ratioGetX()
+if ratioX <= 1 then
+ ratioX = 1
+end
+local offsetX = (ratioX - 1) * (overlayGetWidth() / 2)
+----
+
+[#ratiogety]
+==== ratioGetY
+
+[source,text]
+----
+factor = ratioGetY()
+----
+
+Hypseus extension. The vertical half of the pair: the number given as
+`--yratio`, or `0` when the option was not used. See <>.
+
+*Returns:* number.
+
+*Since:* 3.00 (Hypseus)
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+local ratioY = ratioGetY()
+if ratioY <= 1 then
+ ratioY = 1
+end
+----
+
=== Controller
-Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise.
+Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise. One axis at a time may be given a different threshold with `DEAD_ZONES`, and the analogue triggers a threshold of their own with `TRIGGER_THRESHOLD` (read back as `SINGE_TRIGGER_THRESHOLD`); see <>. A device SDL does not recognise as a gamepad is opened as one anyway, through a mapping Singe writes for it, so an arcade encoder or a flight stick appears in these slots like any pad -- see <>.
+
+[#controllerdorumble]
+==== controllerDoRumble
+
+[source,text]
+----
+controllerDoRumble(strength, duration)
+controllerDoRumble(controller, strength, duration)
+----
+
+Hypseus extension. Rumbles a controller with both motors at one of four strengths for one of four durations. The two argument form addresses controller `0`. A slot with no controller does nothing (Hypseus aborts there, so its games guard with `controllerIsValid`); a strength or duration outside `1` to `4`, or an index outside `0` to `3`, aborts the script. A controller without rumble support ignores the call.
+
+*Parameters:*
+
+* `controller` -- controller index, `0` through `3`.
+* `strength` -- `1` to `4`, a quarter to full strength.
+* `duration` -- `1` to `4`, giving 150, 300, 600 or 1200 ms.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A short kick on every hit, a long full-strength one when the player dies.
+function onHit()
+ if controllerIsValid(0) then
+ controllerDoRumble(0, 2, 1)
+ end
+end
+
+function onPlayerDied()
+ if controllerIsValid(0) then
+ controllerDoRumble(0, 4, 4)
+ end
+end
+----
[#controllergetaxis]
==== controllerGetAxis
@@ -3713,11 +4586,130 @@ function onOverlayUpdate()
end
----
+[#controllergetname]
+==== controllerGetName
+
+[source,text]
+----
+name = controllerGetName(controller)
+----
+
+The name of the device in a controller slot, as SDL reports it, or `nil` when the slot is empty. A device SDL did not recognise reports its own name through the mapping Singe wrote for it, so a service screen can list an arcade encoder by name as readily as a branded pad. An index outside `0` to `3` aborts the script.
+
+*Parameters:*
+
+* `controller` -- controller index, `0` through `3`.
+
+*Returns:* string, or `nil`.
+
+*Since:* 3.00
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A service screen listing what is plugged in.
+for slot = 0, 3 do
+ if controllerIsValid(slot) then
+ fontPrint(20, 20 + slot * 18, string.format("Pad %d: %s", slot + 1, controllerGetName(slot)))
+ else
+ fontPrint(20, 20 + slot * 18, string.format("Pad %d: not connected", slot + 1))
+ end
+end
+----
+
+[#controllerhowmany]
+==== controllerHowMany
+
+[source,text]
+----
+count = controllerHowMany()
+----
+
+Hypseus extension. How many gamepads are open, of the four slots Singe has. Since every joystick Singe can open becomes a gamepad (see <>), an arcade encoder or a spinner is counted here as well.
+
+The count changes as devices are plugged in and out, so read it when you need it rather than once at startup. It says nothing about which slots are filled: a device may leave slot 0 empty while slot 1 is in use, so <> is still the way to ask about a particular slot.
+
+*Parameters:* none.
+
+*Returns:* number, `0` to `4`.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Offer two player only when there is something for player two to hold.
+local twoPlayer = controllerHowMany() >= 2
+----
+
+[#controllerisvalid]
+==== controllerIsValid
+
+[source,text]
+----
+valid = controllerIsValid(controller)
+----
+
+Hypseus extension. Returns whether a controller is open in the slot right now; controllers come and go with hot plugging, so ask before every `controllerDoRumble`. An index outside `0` to `3` aborts the script.
+
+*Parameters:*
+
+* `controller` -- controller index, `0` through `3`.
+
+*Returns:* boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Show the pad hints only when there is a pad to hint about.
+function drawAttract()
+ if controllerIsValid(0) then
+ fontPrint(HINT_X, HINT_Y, "Press A to start")
+ else
+ fontPrint(HINT_X, HINT_Y, "Press 1 to start")
+ end
+end
+----
+
[#debug]
=== Debug
Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough.
+[[traceheader]]
+==== The Trace Header
+
+Whichever of `--program` and `--trace` opened it, `trace.txt` begins with a
+block describing the machine and the run, between two rules so it can be pasted
+whole into a bug report:
+
+----
+----------------------------------------------------------------
+Singe: v3.00
+Command: ./Singe -w -p -d data --screen 2 ActionMax
+OS: Linux 6.8.0-124-generic (x86_64)
+CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz, 8 cores, 20177 MB
+Renderer: gpu, GPU driver vulkan
+Decoder: hardware vaapi or vdpau where the codec allows, software otherwise
+SDL: built 3.4.16, linked 3.4.16
+Settings: haptic=0 screen=2 volume_vldp=80
+Game: ActionMax/ActionMax.singe
+----------------------------------------------------------------
+----
+
+`Renderer` is the renderer that was actually created and the GPU backend it
+chose, or a note that there is no GPU device and 3D is unavailable. `Decoder`
+is what will be tried for video on this machine; which decoder a particular
+video actually got is traced as that video opens, a few lines further down,
+since a codec may offer no hardware path. `Settings` lists what the settings
+file supplied, `none` when there was none. Every game in a queue gets its own
+file and its own header.
+
[#debugprint]
==== debugPrint
@@ -3820,6 +4812,40 @@ function startGame()
end
----
+[#discaudiosuffix]
+==== discAudioSuffix
+
+[source,text]
+----
+opened = discAudioSuffix(suffix)
+----
+
+Hypseus extension. Switches the disc to a separate audio file beside its video: `.ogg`, where the base is the video file's name without its extension, so `movie.m2v` with `"-es"` plays `movie-es.ogg`. The file is opened at the current frame and playback realigns, as `discSetAudioTrack` does, keeping the volume, the play state and the track index when the new file has that many tracks. With a frame file every segment switches. An empty suffix restores the default audio: the `.ogg` beside an `.m2v`, or the audio inside any other container. Returns `true` when the file opened; when it is missing the previous audio keeps playing, nothing is remembered, and `false` comes back (Hypseus leaves silence in that case). The suffix is forgotten when the script reloads. Without a disc the call returns `false`. Use it for a language menu whose tracks ship as separate files; tracks inside one file are the job of `discSetAudioTrack`.
+
+*Parameters:*
+
+* `suffix` -- string appended to the video's base name, such as `"-es"`; `""` for the default audio.
+
+*Returns:* boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Language files ship as movie.ogg, movie-es.ogg and movie-fr.ogg.
+languages = { { "English", "" }, { "Spanish", "-es" }, { "French", "-fr" } }
+
+function applyLanguage(index)
+ if not discAudioSuffix(languages[index][2]) then
+ discAudioSuffix("")
+ index = 1
+ end
+ settings.language = index
+end
+----
+
[#discchangespeed]
==== discChangeSpeed
@@ -5523,6 +6549,39 @@ function onOverlayUpdate()
end
----
+[#guidrawscreen]
+==== guiDrawScreen
+
+[source,text]
+----
+guiDrawScreen(gui)
+guiDrawScreen(gui, x, y)
+guiDrawScreen(gui, x, y, width, height)
+----
+
+The same as <>, except that the rectangle is measured across the whole window instead of across the picture. The coordinates are still overlay coordinates -- `0, 0` is the top left of the window and `overlayGetWidth(), overlayGetHeight()` the bottom right -- so a GUI can sit on the bezel artwork beside the picture, above it, or below it. With no bezel, and with no `--scalefactor`, `--shiftx` or `--shifty`, the picture is the whole window and this is exactly `guiDraw`.
+
+Screen space GUIs draw after everything else, the bezel artwork included, and share the sixteen draws a frame with `guiDraw`. A click finds them the same way: the pointer is carried into screen space before it is tested, so a screen space GUI that takes input receives the mouse wherever it was drawn.
+
+*Parameters:*
+
+* `x`, `y` -- the top left corner, in overlay coordinates measured across the window.
+* `width`, `height` -- the size to draw at; the GUI's own size when left out.
+
+*Since:* 3.00.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A map on the left hand pillar of the cabinet artwork, outside the picture.
+function onOverlayUpdate()
+ overlayClear()
+ guiDrawScreen(mapGui, 8, 44, 38, 114)
+ return OVERLAY_UPDATED
+end
+----
+
[#guigetheight]
==== guiGetHeight
@@ -6024,11 +7083,96 @@ function onCollision(a, b, x, y, z, speed)
end
----
+[#joystick]
+=== Joystick
+
+Every controller Singe opens is a gamepad and is read through the `controller*`
+calls; a device SDL does not recognise reaches them through a mapping Singe
+writes for it, so there is nothing else to call. See <>. The two calls here are about letting a stick stand in for a mouse.
+
+[#joymouseenable]
+==== joyMouseEnable
+
+[source,text]
+----
+joyMouseEnable(enabled)
+----
+
+Hypseus extension. Turns joystick-to-mouse on or off while the game runs. With it on, the left stick of gamepad slot `0` drives the pointer the game sees, through the same `onMouseMoved` a real mouse reaches, held inside the picture; `--js_range` is the speed and the axis obeys its own dead zone. It is what makes a light gun game playable on a cabinet with a stick and no mouse.
+
+It starts from `--joymouse`, which is off by default, and a `singeReload` puts it back there. Singe differs from Hypseus in that default: the shipped `controls.cfg` already binds that stick to the four direction switches, and driving the cursor as well without being asked would surprise every existing game.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Returns:* nothing.
+
+*Since:* 3.00 (Hypseus)
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A gun game that lets the player aim with the stick when no mouse is plugged in.
+if mouseHowMany() == 0 then
+ joyMouseEnable(true)
+end
+----
+
+[#joymouseisenabled]
+==== joyMouseIsEnabled
+
+[source,text]
+----
+enabled = joyMouseIsEnabled()
+----
+
+Whether the stick is driving the mouse cursor right now, whether that was decided by `--joymouse` or by `joyMouseEnable`.
+
+*Returns:* boolean.
+
+*Since:* 3.00
+*See also:* <>
+
[#keyboard]
=== Keyboard
Singe reads the keyboard in one of two modes chosen with `keyboardSetMode`. In `MODE_NORMAL` (the default) keys are translated through the mappings in `controls.cfg` into the `SWITCH_*` values that reach `onInputPressed` and `onInputReleased`, and unmapped keys are ignored; in `MODE_FULL` every key reaches the script raw, through `onKeyPressed` and `onKeyReleased` as well as `onInputPressed` and `onInputReleased`. Whatever the mode, `keyboardIsDown`, `keyboardGetLastDown` and `keyboardGetLastUp` poll the live keyboard state, which is what the threaded model wants (see <>). A scancode names a physical key position (the `A` key is scancode `4` on every layout) while a keysym is the character the key produces; the `SCANCODE` table in `Framework.singe` names every scancode, `keyboardIsDown` takes scancodes, and `onKeyPressed` delivers both. The callbacks themselves are described under <>.
+[#keyboardcatchquit]
+==== keyboardCatchQuit
+
+[source,text]
+----
+keyboardCatchQuit(catch)
+----
+
+Hypseus extension. Hands the quit switch to the script, exactly as `singeSetQuitKeyEnabled(not catch)` does: `keyboardCatchQuit(true)` stops the engine quitting on `INPUT_QUIT`, and `false` returns it. It is a function defined in `Framework.singe`, kept so games written for Hypseus Singe run unchanged.
+
+*Notes:* Legacy alias.
+
+WARNING: New code should call <>.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Hypseus game code; the modern spelling is singeSetQuitKeyEnabled(false).
+if keyboardCatchQuit ~= nil then
+ keyboardCatchQuit(true)
+end
+
+function onInputPressed(what)
+ if what == SWITCH_QUIT then
+ askToQuit()
+ end
+end
+----
+
[#keyboardgetlastdown]
==== keyboardGetLastDown
@@ -7535,6 +8679,35 @@ function cutsceneEnd()
end
----
+[#mousegetname]
+==== mouseGetName
+
+[source,text]
+----
+name = mouseGetName(mouse)
+----
+
+The name the mouse driver gives a device, or `nil` when there is no device with that index. Two guns on a cabinet are told apart by it on a service screen, which is otherwise guesswork when both report as mouse `0` and `1`. An index outside `0` to `3` aborts the script.
+
+*Parameters:*
+
+* `mouse` -- device index, `0` through `3`.
+
+*Returns:* string, or `nil`.
+
+*Since:* 3.00
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Name the guns on the calibration screen.
+mouseSetMode(MOUSE_MANY)
+for device = 0, mouseHowMany() - 1 do
+ fontPrint(20, 20 + device * 18, string.format("Gun %d: %s", device + 1, mouseGetName(device)))
+end
+----
+
[#mousegetposition]
==== mouseGetPosition
@@ -7662,7 +8835,7 @@ end
mouseSetMode(mode)
----
-Chooses how mice are read. In `MOUSE_SINGLE` (the default) the window's pointer events drive one cursor reported as device `0`, with absolute positions and relative motion both converted to overlay coordinates. In `MOUSE_MANY` each physical device is read separately: relative motion is integrated into a per-device position clamped to the video area, absolute devices such as tablets and some lightguns are mapped onto the video, and `onMouseMoved` reports the device index. Both modes deliver the same five buttons and the vertical wheel as switches. `SINGLE_MOUSE` and `MANY_MOUSE` are accepted as older spellings; any other value aborts the script.
+Chooses how mice are read. In `MOUSE_SINGLE` (the default) the window's pointer events drive one cursor reported as device `0`, with absolute positions and relative motion both converted to overlay coordinates. In `MOUSE_MANY` each physical device is read separately: relative motion is integrated into a per-device position clamped to the video area, absolute devices such as tablets and some lightguns are mapped onto the video, and `onMouseMoved` reports the device index. Both modes deliver the same five buttons and the vertical wheel as switches. `SINGLE_MOUSE` and `MANY_MOUSE` are accepted as older spellings; any other value aborts the script. `--manymouse` is the user's own decision and outranks this call: with it given the mode is `MOUSE_MANY` from the start and `mouseSetMode(MOUSE_SINGLE)` does not switch it off, so a two gun cabinet can be made to work from outside a game that never asks.
*Parameters:*
@@ -7685,6 +8858,235 @@ function onMouseMoved(x, y, xr, yr, which)
end
----
+[#musicref]
+=== Music
+
+Background music, loaded and played apart from the sound effects and carrying its own volume. Hypseus runs music on a second mixer for exactly that reason, and Singe gives it its own gain instead: <> never moves a sound effect and <> never moves the music.
+
+A music handle is its own kind of handle, not a sound handle and not a channel: one handle is one track, and it plays, pauses, resumes and stops on its own. There is no channel limit, unlike the sixteen sound effect channels.
+
+The volume scale is Hypseus's, `0` to `128`, twice the `0` to `63` the `sound` family uses, so a game written for Hypseus keeps its own numbers. Music starts as loud as the sound effects do, at whatever `--volume_nonvldp` set; Hypseus instead starts its music mixer a third of the way up, which every Hypseus game immediately overwrites with `musicSetVolume(soundGetVolume() * 2)`.
+
+Every format the sound family reads is read here: WAV, Ogg Vorbis, Opus, FLAC, MP3, WavPack and the rest. The trace header written by `--program` and `--trace` lists exactly which decoders the build carries, on its `Audio:` line.
+
+Pausing the engine (the pause key, or `singeSetPauseFlag`) pauses the music with it, and resuming brings it back; a reload stops and frees every track.
+
+[#musicisplaying]
+==== musicIsPlaying
+
+[source,text]
+----
+playing = musicIsPlaying([id])
+----
+
+Hypseus extension. Whether that track is playing, or -- with no handle -- whether any music is. A paused track is not playing.
+
+*Parameters:*
+
+* `id` -- optional number; a music handle.
+
+*Returns:* boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+if not musicIsPlaying(theme) then
+ musicPlay(theme, -1)
+end
+----
+
+[#musicload]
+==== musicLoad
+
+[source,text]
+----
+id = musicLoad(filename)
+----
+
+Hypseus extension. Loads a music track through the virtual file system and returns its handle. The whole file is decoded up front, as a sound is, so a long track costs memory; the handle is valid until <> or a reload.
+
+A file that is missing, or that no compiled-in decoder can read, terminates the script, as <> does.
+
+*Parameters:*
+
+* `filename` -- string; a path the virtual file system can reach.
+
+*Returns:* number, the music handle.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+local theme = musicLoad("music/attract.mp3")
+musicSetVolume(96)
+musicPlay(theme, -1)
+----
+
+[#musicpause]
+==== musicPause
+
+[source,text]
+----
+musicPause([id])
+----
+
+Hypseus extension. Pauses that track where it is, or every piece of music when no handle is given. <> picks it up again.
+
+*Parameters:*
+
+* `id` -- optional number; a music handle.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- The player opened the map; hold the music rather than restart it.
+musicPause()
+----
+
+[#musicplay]
+==== musicPlay
+
+[source,text]
+----
+musicPlay(id [, loops])
+----
+
+Hypseus extension. Plays a track from its beginning. `loops` means what <>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there.
+
+Playing a track that is already playing starts it again from the beginning.
+
+*Parameters:*
+
+* `id` -- number; a music handle.
+* `loops` -- optional number; `-1` to `64`. Default `0`.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+musicPlay(theme, -1) -- Round and round until something stops it.
+----
+
+[#musicresume]
+==== musicResume
+
+[source,text]
+----
+musicResume([id])
+----
+
+Hypseus extension. Resumes a paused track, or every paused piece of music when no handle is given.
+
+Hypseus's own zero argument form does nothing at all; Singe's resumes everything, which is what pairs with <>.
+
+*Parameters:*
+
+* `id` -- optional number; a music handle.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+.Example
+[source,lua]
+----
+musicResume()
+----
+
+[#musicsetvolume]
+==== musicSetVolume
+
+[source,text]
+----
+volume = musicSetVolume(volume)
+----
+
+Hypseus extension. Sets the gain over every piece of music at once, on Hypseus's `0` to `128` scale, and answers the level it took. It moves nothing but music: the sound effects keep whatever <> gave them, and `soundSetVolume` leaves the music alone.
+
+A level outside `0` to `128` terminates the script. The level applies to tracks already playing and to any loaded afterwards, and goes back to the starting level on a reload.
+
+*Parameters:*
+
+* `volume` -- number; `0` to `128`.
+
+*Returns:* number, the level that was set.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- What a Hypseus game writes: the sound level, doubled onto the music scale.
+musicSetVolume(soundGetVolume() * 2)
+----
+
+[#musicstop]
+==== musicStop
+
+[source,text]
+----
+musicStop([id [, fadeMs]])
+----
+
+Hypseus extension. Stops that track, or every piece of music when no handle is given. `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script.
+
+*Parameters:*
+
+* `id` -- optional number; a music handle.
+* `fadeMs` -- optional number; milliseconds to fade out over. Default `0`.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+musicStop(theme, 1500) -- Away over a second and a half.
+----
+
+[#musicunload]
+==== musicUnload
+
+[source,text]
+----
+musicUnload(id)
+----
+
+Hypseus extension. Stops the track and frees it. The handle is invalid afterwards and using it terminates the script.
+
+*Parameters:*
+
+* `id` -- number; a music handle.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+.Example
+[source,lua]
+----
+musicUnload(theme)
+----
+
[#nav]
=== Nav
@@ -9297,6 +10699,35 @@ end
The overlay is a 32-bit RGBA surface composited over the video every frame; all 2D drawing lands on it. Its coordinates are the overlay resolution set by `overlaySetResolution`, which defaults to half the video's width and height, and are scaled to the window at display time. Draw from `onOverlayUpdate` (or from `singeMain` in the <> model) and return `OVERLAY_UPDATED` when something changed. The `overlay*` primitives draw one-pixel outlines in the current foreground color and silently clip whatever falls outside the surface. `overlayPrint` is the exception: it takes character-cell coordinates and copies the built-in console font as is, ignoring the color settings.
+[#overlaybanner]
+==== overlayBanner
+
+[source,text]
+----
+overlayBanner(text [, height])
+----
+
+Hypseus extension. Shows a short message over the picture for about thirty drawn frames and then takes it away again. `height` is where it sits, as a per cent down the picture, from 1 to 95; the default is 47, which is Hypseus's own, and a number outside that range is replaced by the default rather than refused.
+
+A message longer than 60 characters is ignored, exactly as Hypseus ignores it. The banner is drawn by the same shipped document the subtitles use (`Singe/subtitle.rml`, styled by `Singe/subtitle.rcss`), so it looks like a subtitle and is styled with them; while it is up it takes the place of any subtitle, and the subtitle comes back when it times out. The text is drawn as text, so `<`, `>` and `&` reach the screen as themselves.
+
+*Parameters:*
+
+* `text` -- string; up to 60 characters.
+* `height` -- optional number; 1 to 95, a per cent down the picture. Default 47.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Tell the player what just happened without building a HUD for it.
+overlayBanner("Extra life!", 20)
+----
+
[#overlaybox]
==== overlayBox
@@ -9560,6 +10991,33 @@ function onOverlayUpdate()
end
----
+[#overlaysetmonochrome]
+==== overlaySetMonochrome
+
+[source,text]
+----
+overlaySetMonochrome(enabled)
+----
+
+Hypseus extension. Shows the overlay in grey: before each upload the engine converts a copy of it to luma with the BT.601 weights, keeping the alpha channel as it is (Hypseus thresholds it). The overlay's own pixels stay in colour, so nothing the script reads back changes, and drawing goes on as usual. Pair it with `vldpSetMonochrome` for a black and white mode. A non-boolean argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- One setting drives both the disc and the overlay.
+function applyChroma(colour)
+ vldpSetMonochrome(not colour)
+ overlaySetMonochrome(not colour)
+end
+----
+
[#overlaysetresolution]
==== overlaySetResolution
@@ -9590,6 +11048,99 @@ MARQUEE_X = overlayGetWidth() - MARGIN_X - MARQUEE_W
CABINET_H = overlayGetHeight() - MARGIN_Y - CABINET_Y
----
+[#setoverlaylinearscale]
+==== setOverlayLinearScale
+
+[source,text]
+----
+setOverlayLinearScale(enabled)
+----
+
+Hypseus extension. Chooses how the overlay is filtered on its way to the window. With `true`, the default and what a reload restores, it is sampled linearly, so a low resolution overlay softens rather than blocking up; with `false` the nearest pixel is taken, which keeps pixel art, one-pixel outlines and small text crisp. Linear is the default because it is how every Singe release has drawn the overlay; Hypseus defaults to the nearest pixel instead, so a game moved from Hypseus that never calls this will look slightly softer until it does. Nothing about the drawing changes, only the final scale from the overlay resolution to the window, so everything on the overlay -- the primitives, fonts, sprites and the 3D layer -- goes through the one filter together, and the disc picture behind it is untouched. The setting survives `overlaySetResolution`, which replaces the overlay. A non-boolean argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A 320x240 overlay on a 4K screen; smooth the artwork, sharpen the text.
+overlaySetResolution(320, 240)
+setOverlayLinearScale(true)
+...
+setOverlayLinearScale(false)
+overlayPrint(1, 1, "CREDITS 2")
+----
+
+[#setoverlayontop]
+==== setOverlayOnTop
+
+[source,text]
+----
+loaded, id = setOverlayOnTop(enabled)
+----
+
+Hypseus extension. Chooses whether the overlay and the GUIs drawn over the picture are above the bezel artwork or below it. Only artwork drawn in front of the picture (`--bezelflip`) is ever above them, so with the artwork behind -- which is the default -- this changes nothing. `false` is the setting a game starts with, and Hypseus's own default. It is remembered until the script reloads.
+
+The setting is only recorded while artwork is loaded; without it the call does nothing and answers `false` and `"0"`, exactly as Hypseus does. A non-boolean argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean; `true` puts the overlay and the GUIs above the artwork.
+
+*Returns:* boolean, whether artwork is loaded, and its identifier as a string (`"0"` when none is).
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- The cabinet frame overlaps the picture, but the score line has to stay readable.
+if mainBezelLoaded() then
+ setOverlayOnTop(true)
+end
+----
+
+[#setoverlayopacity]
+==== setOverlayOpacity
+
+[source,text]
+----
+setOverlayOpacity(alpha)
+----
+
+Hypseus extension. How solid the whole overlay is, from `0` (invisible) to `255` (as the game drew it). It multiplies whatever alpha the overlay's own pixels already carry, so a half transparent sprite stays half as solid as the rest; it does not change the overlay's contents, and nothing has to be redrawn.
+
+The level is clamped to `0` to `255` rather than refused, and a script starts at `255`. It is remembered until the script reloads, and it survives a change of overlay resolution.
+
+The GUIs, the 3D scene and the bezel artwork are drawn separately and are not affected.
+
+*Parameters:*
+
+* `alpha` -- number; `0` to `255`.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Fade the whole heads-up display out over half a second.
+local fade = 255
+function onOverlayUpdate()
+ fade = math.max(0, fade - 8)
+ setOverlayOpacity(fade)
+ return OVERLAY_UPDATED
+end
+----
+
[#physicsref]
=== Physics
@@ -11694,6 +13245,38 @@ function endInitials()
end
----
+[#singesetquitkeyenabled]
+==== singeSetQuitKeyEnabled
+
+[source,text]
+----
+singeSetQuitKeyEnabled(enabled)
+----
+
+Hypseus extension. Chooses whether the engine quits on the switch mapped to `INPUT_QUIT` in `controls.cfg`, from the keyboard or a controller. While enabled (the default, restored on every reload) pressing it ends the game. While disabled the engine ignores it, so a game can confirm or save first; the script receives `SWITCH_QUIT` through `onInputPressed` and `onInputReleased` either way, and `singeQuit` still ends the game. The window's close button always quits. `Framework.singe` defines `keyboardCatchQuit(catch)`, Hypseus Singe's name for the same setting with the opposite sense. A non-boolean argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Ask before leaving a game in progress.
+singeSetQuitKeyEnabled(false)
+
+function onInputPressed(what)
+ if what == SWITCH_QUIT then
+ if confirmQuit() then
+ singeQuit()
+ end
+ end
+end
+----
+
[#singeversion]
==== singeVersion
@@ -12137,6 +13720,38 @@ coinClip = soundLoad(DIR .. "sounds/coin.wav")
ambientClip = soundLoad(DIR .. "sounds/wind.wav")
----
+[#soundloaddata]
+==== soundLoadData
+
+[source,text]
+----
+id = soundLoadData(data)
+----
+
+Hypseus extension. Builds a sound from bytes held in a Lua string instead of from a file. Every format <> reads is read here, and the handle it returns is used with the rest of the `sound` family in exactly the same way.
+
+The string may hold zero bytes; its length, not its first zero, decides how much is read. The bytes are decoded during the call and the string is not kept, so it may be discarded afterwards. Bytes that are not audio, or an empty string, terminate the script, as an unreadable file does in `soundLoad`.
+
+Use it for audio a game generates, unpacks or decrypts for itself. Audio the virtual file system can reach by name -- including audio inside a packed `.game` -- is better loaded with `soundLoad`, which does not hold the whole file in a Lua string.
+
+*Parameters:*
+
+* `data` -- string of bytes.
+
+*Returns:* number, the sound handle.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A sound effect the game unpacks from its own archive.
+local bytes = myArchive:extract("beep.wav")
+local beep = soundLoadData(bytes)
+soundPlay(beep)
+----
+
[#soundpause]
==== soundPause
@@ -12453,6 +14068,8 @@ end
Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text with `fontToSprite`, and drawn onto the overlay in overlay coordinates. Every `sprite*` call takes the integer handle as its first argument (Singe 2.10 put it last; see <>), and a handle that has been unloaded ends the script with an error. Any format SDL_image reads is accepted; an animated GIF or WEBP with two or more frames loads as an animation that sits on frame `0`, stopped and non-looping, until `spritePlay` and `spriteLoop` say otherwise. Pixels whose raw value is `0` in the image's own pixel format are drawn transparent (palette index `0`, or black in an RGB image without alpha), and a real alpha channel is honored as well. Release every sprite with `spriteUnload`, usually from <>.
+*Sprite sheets.* Hypseus Singe extends the family with sprite sheets: `spriteLoadFrames` slices a single row strip of equal frames into an animation that is not playing, and `spriteDrawFrame` draws one chosen frame (1-based) without touching playback. A sheet is otherwise an ordinary animation, so `spriteSetFrame` (0-based), `spritePlay`, `spriteDraw`, `nodeSetSprite` and `emitterSetTexture` all accept it, and `spriteFrameWidth` and `spriteFrameHeight` report the unscaled size of one frame. The entries marked "Hypseus extension" exist so games written for Hypseus Singe run unchanged.
+
[#spritedraw]
==== spriteDraw
@@ -12492,6 +14109,169 @@ function onOverlayUpdate()
end
----
+[#spritedrawframe]
+==== spriteDrawFrame
+
+[source,text]
+----
+spriteDrawFrame(id, x, y, frame)
+spriteDrawFrame(id, x, y, frame, scale)
+spriteDrawFrame(id, x, y, frame, scaleX, scaleY)
+----
+
+Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`.
+
+*Parameters:*
+
+* `id` -- sprite handle from `spriteLoadFrames` or `spriteLoad`.
+* `x`, `y` -- top-left corner in overlay coordinates; fractions are truncated.
+* `frame` -- frame number, `1` to the frame count; fractions are truncated.
+* `scale`, `scaleX`, `scaleY` -- size multipliers, `1` for natural size.
+
+*Notes:* `spriteSetFrame` counts frames from `0`; `spriteDrawFrame` counts from `1`.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A ten frame digit strip draws the score, one digit per frame.
+digits = spriteLoadFrames(10, DIR .. "digits.png")
+
+function drawScore(x, y, score)
+ local text = tostring(score)
+ for i = 1, #text do
+ spriteDrawFrame(digits, x + (i - 1) * spriteFrameWidth(digits), y, tonumber(text:sub(i, i)) + 1)
+ end
+end
+----
+
+[#spritedrawgrid]
+==== spriteDrawGrid
+
+[source,text]
+----
+spriteDrawGrid(id, x, y, sourceX, sourceY, width, height)
+----
+
+Hypseus extension. Draws one rectangle out of a sprite, at its own size, with its top left corner at `x`, `y`. It is the only call that takes a source rectangle: <> stretches the whole sprite into a destination and <> takes a whole frame of a sheet, while this takes any rectangle at all -- which is how a game cuts a tile, a digit or a letter out of one image without loading it many times over.
+
+The rectangle is in the sprite's own pixels and must lie inside it; a rectangle that does not, or one with no width or height, terminates the script naming the sprite's size. The sprite drawn from is the sprite as it is currently drawn, so a <> or a <> changes what the coordinates mean.
+
+Under `LEGACY_SPRITE_ARGS` the handle is third, `spriteDrawGrid(x, y, spr, sourceX, sourceY, width, height)`, which is where Hypseus puts it.
+
+*Parameters:*
+
+* `id` -- number; a sprite handle.
+* `x`, `y` -- numbers; where the rectangle's top left corner lands, in overlay coordinates.
+* `sourceX`, `sourceY` -- numbers; the rectangle's top left corner in the sprite.
+* `width`, `height` -- numbers; its size, at least 1.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A number drawn from one strip of ten digits, each 16 by 24.
+local function drawNumber(sheet, x, y, value)
+ for i = 1, #value do
+ local digit = tonumber(value:sub(i, i))
+ spriteDrawGrid(sheet, x + (i - 1) * 16, y, digit * 16, 0, 16, 24)
+ end
+end
+----
+
+[#spritedrawrotatedframe]
+==== spriteDrawRotatedFrame
+
+[source,text]
+----
+spriteDrawRotatedFrame(id, x, y)
+spriteDrawRotatedFrame(id, x, y, scale)
+spriteDrawRotatedFrame(id, x, y, scaleX, scaleY)
+----
+
+Hypseus extension. Draws the frame <> last turned, *centred* on `x`, `y`. This is the pair to that call and the reason both exist: <> deliberately ignores a sprite's rotation and scale, so a game that wants one frame of a sheet turned uses these two instead.
+
+Given a scale, the turned frame is stretched by it as it is drawn -- on top of whatever scale it was turned with -- and the result is still centred on `x`, `y`. `scaleX` alone scales both axes.
+
+Drawing before any `spriteRotateFrame` terminates the script, since there is no turned frame to draw.
+
+Under `LEGACY_SPRITE_ARGS` the handle is last, as Hypseus writes it.
+
+*Parameters:*
+
+* `id` -- number; a sprite handle.
+* `x`, `y` -- numbers; the *centre* of the drawn frame, in overlay coordinates.
+* `scale` or `scaleX`, `scaleY` -- optional numbers; a further scale at draw time.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A compass needle: one frame of the sheet, pointing where the player is going.
+spriteRotateFrame(needle, heading, 1)
+spriteDrawRotatedFrame(needle, 320, 240)
+----
+
+[#spriteframeheight]
+==== spriteFrameHeight
+
+[source,text]
+----
+height = spriteFrameHeight(id)
+----
+
+Hypseus extension. Returns the height of one frame of the sprite before any scaling or rotation: the strip's height for a `spriteLoadFrames` sheet, a frame's height for an animation, and the image's height for a still (Hypseus reports the drawn surface here). `spriteGetHeight` reports the size as drawn instead.
+
+*Returns:* integer.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Centre a frame on a point.
+function drawCentred(sheet, frame, x, y)
+ spriteDrawFrame(sheet, x - spriteFrameWidth(sheet) / 2, y - spriteFrameHeight(sheet) / 2, frame)
+end
+----
+
+[#spriteframewidth]
+==== spriteFrameWidth
+
+[source,text]
+----
+width = spriteFrameWidth(id)
+----
+
+Hypseus extension. Returns the width of one frame of the sprite before any scaling or rotation: the strip's width divided by the frame count for a `spriteLoadFrames` sheet, a frame's width for an animation, and the image's width for a still (Hypseus reports `0` there). `spriteGetWidth` reports the size as drawn instead.
+
+*Returns:* integer.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Lay the frames of a strip out side by side.
+function drawStrip(sheet, count, x, y)
+ for frame = 1, count do
+ spriteDrawFrame(sheet, x + (frame - 1) * spriteFrameWidth(sheet), y, frame)
+ end
+end
+----
+
[#spritegetframe]
==== spriteGetFrame
@@ -12522,6 +14302,35 @@ function onOverlayUpdate()
end
----
+[#spritegetframes]
+==== spriteGetFrames
+
+[source,text]
+----
+count = spriteGetFrames(id)
+----
+
+Hypseus extension. How many frames a sprite has: the frame count of a sheet loaded by <> or of an animated GIF or WEBP, and `0` for a still image. It is the count <>'s zero based frame number runs up to, and one more than the largest it will take.
+
+*Parameters:*
+
+* `id` -- number; a sprite handle.
+
+*Returns:* number; `0` for a still image.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Step to the next frame, whatever the sheet turned out to be.
+local frames = spriteGetFrames(walk)
+if frames > 0 then
+ spriteSetFrame(walk, (spriteGetFrame(walk) + 1) % frames)
+end
+----
+
[#spritegetheight]
==== spriteGetHeight
@@ -12639,6 +14448,72 @@ spriteLoop(attractLoop, true)
spritePlay(attractLoop)
----
+[#spriteloaddata]
+==== spriteLoadData
+
+[source,text]
+----
+id = spriteLoadData(data)
+----
+
+Hypseus extension. Builds a sprite from an image held in a Lua string instead of from a file. Every format <> reads is read here, animated GIF and WEBP included, and an animation loaded this way behaves exactly as one loaded from a file.
+
+The string may hold zero bytes; its length, not its first zero, decides how much is read. The image is decoded during the call and the string is not kept. Bytes that are not an image, or an empty string, terminate the script, as an unreadable file does in `spriteLoad`.
+
+Use it for artwork a game generates, unpacks or decrypts for itself. Artwork the virtual file system can reach by name -- including artwork inside a packed `.game` -- is better loaded with `spriteLoad`, which does not hold the whole file in a Lua string.
+
+*Parameters:*
+
+* `data` -- string of bytes.
+
+*Returns:* number, the sprite handle.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- An image the game keeps inside its own data file.
+local bytes = myArchive:extract("logo.png")
+local logo = spriteLoadData(bytes)
+spriteDraw(logo, 10, 10)
+----
+
+[#spriteloadframes]
+==== spriteLoadFrames
+
+[source,text]
+----
+result = spriteLoadFrames(count, filename)
+----
+
+Hypseus extension. Loads an image holding `count` equal frames in a single row and returns a sprite handle. The frame width is the image width divided by `count` (integer division; leftover columns on the right are dropped) and the frame height is the image height. The sheet becomes an ordinary animation parked on frame `0`, not playing and not looping, so `spriteDraw` shows the current frame rather than the whole strip (as Hypseus draws it), and `spriteDrawFrame` picks any frame directly. The name goes through the virtual file system like `spriteLoad`, and pixels with a raw value of `0` become transparent in every frame. A count outside `2` to `512`, an image narrower than `count` pixels, or a file that cannot be read ends the script with an error.
+
+*Parameters:*
+
+* `count` -- number of frames across the image, `2` to `512`.
+* `filename` -- the strip image.
+
+*Returns:* integer sprite handle.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A five frame car strip; frame 3 is the car pointing straight ahead.
+carSprite = spriteLoadFrames(5, DIR .. "car.png")
+carWidth = spriteFrameWidth(carSprite)
+
+function onOverlayUpdate()
+ overlayClear()
+ spriteDrawFrame(carSprite, (overlayGetWidth() - carWidth) / 2, CAR_Y, carFrame)
+ return OVERLAY_UPDATED
+end
+----
+
[#spriteloop]
==== spriteLoop
@@ -12754,6 +14629,32 @@ spriteScale(marqueeSprite, 0.5)
spriteScale(playerSprite, 3)
----
+[#spriteresetcolorkey]
+==== spriteResetColorKey
+
+[source,text]
+----
+spriteResetColorKey(id, enabled)
+----
+
+Hypseus extension. Accepted for compatibility and does nothing. In Hypseus the flag re-applies the transparent colour to the copies `spriteRotate` and `spriteScale` produce, which would otherwise draw their background opaque; Singe's transformed copies carry the transparency in their alpha channel, so there is nothing to re-key. A bad handle or a non-boolean argument aborts the script, as for every other sprite call.
+
+*Parameters:*
+
+* `id` -- sprite handle.
+* `enabled` -- boolean, ignored.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Hypseus game code; harmless in Singe.
+badge = spriteLoad(DIR .. "badge.png")
+spriteResetColorKey(badge, true)
+----
+
[#spriterotate]
==== spriteRotate
@@ -12819,6 +14720,41 @@ function onOverlayUpdate()
end
----
+[#spriterotateframe]
+==== spriteRotateFrame
+
+[source,text]
+----
+spriteRotateFrame(id, degrees, frame)
+----
+
+Hypseus extension. Takes one frame out of a sprite sheet, turns it clockwise by `degrees` and scales it by the sprite's own <>, and keeps the result for <>. The sprite's own drawn surface is untouched, so <> and `spriteDrawFrame` go on showing what they showed.
+
+Frames are numbered from 1, as `spriteDrawFrame` numbers them (and unlike `spriteGetFrame` and `spriteSetFrame`, which count from 0); a frame outside the sheet is frame 1 rather than an error. A still image turns its whole picture.
+
+Turning is not free: the frame is redrawn pixel by pixel each time, so turn it when the angle changes rather than every frame. The angle is taken modulo 360, and <> decides whether the turned edges are smoothed.
+
+Under `LEGACY_SPRITE_ARGS` the handle is last, `spriteRotateFrame(degrees, frame, spr)`, as Hypseus writes it.
+
+*Parameters:*
+
+* `id` -- number; a sprite handle.
+* `degrees` -- number; clockwise.
+* `frame` -- number; 1 to the frame count.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A ship sprite whose hull is frame 1 and whose thrust is frame 2.
+spriteRotateFrame(ship, angle, thrusting and 2 or 1)
+spriteDrawRotatedFrame(ship, shipX, shipY)
+----
+
[#spritescale]
==== spriteScale
@@ -12866,8 +14802,10 @@ spriteSetFrame(id, frame)
Jumps an animation to the given frame and restarts that frame's delay timer, so the frame is shown for its full duration before the next one. Playback state is not changed: a paused sprite stays paused on the new frame and a playing one continues from it. The call is ignored for still images, for frames outside `0` to the last frame, and when the sprite already shows that frame.
+*Notes:* Frames are numbered from `0` here; `spriteDrawFrame`, the Hypseus extension, numbers them from `1`.
+
*Since:* 2.10
-*See also:* <>, <>
+*See also:* <>, <>, <>
.Example
[source,lua]
@@ -12914,6 +14852,123 @@ function onShutdown()
end
----
+[#subtitleref]
+=== Subtitle
+
+SubRip subtitles over the disc picture. A game loads a `.srt` with <>, switches it on with <>, and the engine puts each cue up and takes it down again as the disc reaches it. See <> for the file format, the timing and how to restyle them.
+
+The subtitles and <> share one shipped document, `Singe/subtitle.rml`, styled by `Singe/subtitle.rcss`; a game or a theme may replace either. Both are drawn over the picture, above the overlay and below the screen space GUIs, and they take no mouse input.
+
+[#srtclear]
+==== srtClear
+
+[source,text]
+----
+srtClear()
+----
+
+Hypseus extension. Takes whatever is on screen off at once -- a cue or a banner -- and leaves the loaded cues and the enabled state alone. The cue that was showing stays off until the disc reaches the next one; nothing has to be reloaded and nothing has to be switched back on.
+
+*Parameters:* none.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A cutscene ends early; drop the line that was still up.
+srtClear()
+----
+
+[#srtenable]
+==== srtEnable
+
+[source,text]
+----
+srtEnable(enabled)
+----
+
+Hypseus extension. Whether the loaded cues are shown as the disc plays. Switching it off takes any cue that is showing off with it; switching it on again puts up whatever cue covers the frame the disc is on. A script starts with subtitles off, so a game that loads them must switch them on.
+
+A non-boolean argument terminates the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+srtEnable(SETTINGS.subtitles)
+----
+
+[#srtload]
+==== srtLoad
+
+[source,text]
+----
+loaded = srtLoad(filename)
+----
+
+Hypseus extension. Reads a SubRip file through the virtual file system and turns its timestamps into disc frames at the disc's own frame rate. Loading replaces whatever was loaded before; <> then shows it.
+
+It answers `false`, having loaded nothing, when the file cannot be read, when it holds no cue that parses, or when there is no disc to time it against -- so a malformed file is a `false`, not a crash. A warning naming the file is printed in every one of those cases. Cues that do not parse inside an otherwise good file are skipped, as Hypseus skips them.
+
+Because the timestamps become frame numbers when the file is loaded, and the frame number is what the engine matches, a cue is found again wherever the disc is sent: a search, a skip or a step lands on the right line at once.
+
+*Parameters:*
+
+* `filename` -- string; a path the virtual file system can reach.
+
+*Returns:* boolean, whether any cue was loaded.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+if srtLoad("subs/" .. language .. ".srt") then
+ srtEnable(true)
+end
+----
+
+[#srtposition]
+==== srtPosition
+
+[source,text]
+----
+srtPosition(percent)
+----
+
+Hypseus extension. Where a subtitle sits, as a per cent down the picture, from 1 to 95. The default is 80. A number outside that range is ignored, as Hypseus ignores it, rather than being clamped or refused.
+
+The height applies to the cue on screen straight away and to every cue after it. <> carries its own height and is not affected.
+
+*Parameters:*
+
+* `percent` -- number; 1 to 95.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Keep the lines clear of the scoreboard along the bottom.
+srtPosition(70)
+----
+
[#terrain]
=== Terrain
@@ -14318,6 +16373,69 @@ The `vldp*` namespace predates Singe 2.00 and exists to keep older scripts runni
WARNING: *Legacy.* Prefer <> and <> in new code. `vldpGetPixel` remains current.
+Hypseus Singe added eleven more members, kept under this prefix so its games run unchanged: `vldpFocusArea` and `vldpResetFocus` crop the disc picture, `vldpGetScale` and `vldpSetScale` report and change the display scale factor, `vldpGetRotate` and `vldpSetRotate` do the same for the presentation rotation, `vldpGetYUVPixel` reads raw samples, and `vldpFlash`, `vldpSetBlend`, `vldpSetLuma` and `vldpSetMonochrome` change how the picture itself looks. Each is marked "Hypseus extension" below. The scale and the rotation are two of the inputs to <>.
+
+The four picture controls work on the decoded frame, on its luma and chroma planes, before it reaches the screen: `vldpSetBlend` and `vldpSetLuma` are applied one after the other as each frame is uploaded, `vldpSetMonochrome` replaces the chroma, and `vldpFlash` replaces the picture outright for one frame. They cost a pass over the luma plane per frame while they are on and nothing at all while they are off, they leave `vldpGetPixel` and `vldpGetYUVPixel` reading the picture as it was decoded, and with a frame file they follow the playback from segment to segment. All four go back to off when the script reloads (except monochrome, which goes back to whatever `--monochrome` asked for) and none of them touches a video drawn with `videoDraw`.
+
+[#vldpflash]
+==== vldpFlash
+
+[source,text]
+----
+vldpFlash()
+----
+
+Hypseus extension. Puts one white picture on the disc, the way a real laserdisc player flashes when it is searched. It goes up at once and the next disc update takes it off again, so it lasts one drawn frame whether the disc is playing, paused or stopped.
+
+The white is the value Hypseus flashes, 90 per cent luma with neutral chroma, not pure white, which is what a video signal's white actually is. The overlay, the GUIs and the bezel artwork are drawn over it as usual, so a heads-up display stays readable through the flash.
+
+Without a disc, or on a disc whose first frame has not been decoded yet, the call does nothing.
+
+*Parameters:* none.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Punctuate a hit.
+vldpFlash()
+----
+
+[#vldpfocusarea]
+==== vldpFocusArea
+
+[source,text]
+----
+vldpFocusArea(x, y, width, height)
+vldpFocusArea()
+----
+
+Hypseus extension. Shows only a rectangle of the disc frame, given in disc pixels, stretched into the space the whole frame normally fills, with the renderer's usual smooth scaling; the aspect follows whatever `width` and `height` give. The rectangle is held inside the frame, and one that lies entirely outside it aborts the script. With no arguments the whole frame is shown again (Hypseus ignores that call and uses `vldpResetFocus`; Singe accepts both). The focus is a display effect only: `vldpGetPixel`, `vldpGetYUVPixel`, the mouse mapping and the disc on a 3D material all keep unfocused coordinates, so hit detection on a zoomed picture must be scaled by the caller. Without a disc the call does nothing. Reset when the script reloads.
+
+*Parameters:*
+
+* `x`, `y` -- top-left corner in disc pixels.
+* `width`, `height` -- size of the rectangle in disc pixels.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Zoom into the middle 80% of the picture during the chase.
+local w, h = vldpGetWidth(), vldpGetHeight()
+vldpFocusArea(w * 0.1, h * 0.1, w * 0.8, h * 0.8)
+
+function onChaseEnded()
+ vldpFocusArea()
+end
+----
+
[#vldpgetheight]
==== vldpGetHeight
@@ -14382,6 +16500,58 @@ function onInputPressed(what)
end
----
+[#vldpgetrotate]
+==== vldpGetRotate
+
+[source,text]
+----
+degrees = vldpGetRotate()
+----
+
+Hypseus extension. Returns the presentation rotation in degrees clockwise: the `--rotate` option, or whatever `vldpSetRotate` last set, and `0` when neither has. Only `0`, `90`, `180` and `270` are ever returned, because those are the only angles Singe turns to.
+
+*Returns:* integer, `0`, `90`, `180` or `270`.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A portrait cabinet wants its score panel down the long side.
+if vldpGetRotate() == 90 or vldpGetRotate() == 270 then
+ layoutPanel(PANEL_TALL)
+else
+ layoutPanel(PANEL_WIDE)
+end
+----
+
+[#vldpgetscale]
+==== vldpGetScale
+
+[source,text]
+----
+scale = vldpGetScale()
+----
+
+Hypseus extension. Returns the display scale factor as a percentage: the `--scalefactor` option, or whatever `vldpSetScale` last applied, and `100` when neither has been given. Hypseus games divide `100` by it to size their overlay geometry to the shrunken picture. It has nothing to do with `vldpFocusArea` or the overlay resolution.
+
+*Returns:* integer, `25` to `100`.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- Hypseus game code: the gun sights scale with the picture.
+if vldpGetScale ~= nil then
+ gunScale = 100 / vldpGetScale()
+else
+ gunScale = 1
+end
+----
+
[#vldpgetwidth]
==== vldpGetWidth
@@ -14410,6 +16580,208 @@ centerX = vldpGetWidth() / 2
centerY = vldpGetHeight() / 2
----
+[#vldpgetyuvpixel]
+==== vldpGetYUVPixel
+
+[source,text]
+----
+y, u, v = vldpGetYUVPixel(x, y)
+----
+
+Hypseus extension. Reads the raw samples of one pixel of the disc frame currently shown, before any colour conversion: the luma and the two chroma samples, each `0` to `255`, with chroma at the 4:2:0 resolution of the decoded picture (neutral is `128`). The coordinates are overlay coordinates, scaled and truncated exactly as `vldpGetPixel` does. Returns `-1, -1, -1` when there is no disc, when no frame has been decoded yet, or when the scaled coordinates fall outside the video. Unaffected by `vldpFocusArea` and `vldpSetMonochrome`. Cheaper than `vldpGetPixel` when only the brightness matters.
+
+*Parameters:*
+
+* `x`, `y` -- overlay coordinates; fractional values are accepted.
+
+*Returns:* three integers, `Y`, `U` and `V`, each `0` to `255`, or `-1, -1, -1`.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- The road is dark; anything bright under the car is an obstacle.
+function carHitsSomething()
+ local y = vldpGetYUVPixel(carX + carWidth / 2, carY + carHeight)
+ return y >= 120
+end
+----
+
+[#vldpresetfocus]
+==== vldpResetFocus
+
+[source,text]
+----
+vldpResetFocus()
+----
+
+Hypseus extension. Shows the whole disc frame again after `vldpFocusArea`, exactly as `vldpFocusArea()` with no arguments does. Hypseus's own name for the reset, kept so its games run unchanged.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>
+
+.Example
+[source,lua]
+----
+function onChaseEnded()
+ vldpResetFocus()
+end
+----
+
+[#vldpsetblend]
+==== vldpSetBlend
+
+[source,text]
+----
+vldpSetBlend(enabled)
+----
+
+Hypseus extension. Smooths the disc picture down its rows: each luma sample becomes the average of itself and the samples immediately above and below it. This is Hypseus's own three tap blend filter, and it exists to take the hard edges off an interlaced transfer without softening the picture sideways.
+
+The chroma planes are left alone, so the smoothing changes the brightness detail and not the colour. Turning it off puts the picture back exactly as it was decoded.
+
+Without a disc the call does nothing but is still remembered, so it takes effect if a disc is loaded later. A non-boolean argument terminates the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A service screen switch for a noisy transfer.
+vldpSetBlend(SETTINGS.softenVideo)
+----
+
+[#vldpsetluma]
+==== vldpSetLuma
+
+[source,text]
+----
+vldpSetLuma(enabled [, level])
+----
+
+Hypseus extension. Brightens or darkens the disc picture. `level` runs from 0 to 8 and 4, the default, changes nothing: the distance from 4 is added to every luma sample in eighths, so 0 halves the brightness, 8 raises it by half, and the steps between are even. A level outside 0 to 8 terminates the script.
+
+Since only the luma is changed, the colour stays where it is: raising the level brightens the picture without washing the colour out, which is what a real player's picture control does and what a colour multiplier would not. Turning it off, or asking for level 4, puts the picture back exactly as it was decoded.
+
+`vldpSetLuma(true)` with no level therefore does nothing visible, which is Hypseus's behaviour as well.
+
+Without a disc the call does nothing but is still remembered, so it takes effect if a disc is loaded later.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+* `level` -- optional number; 0 to 8, 4 being neutral. Default 4.
+
+*Returns:* nothing.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>, <>
+
+.Example
+[source,lua]
+----
+-- A night scene the player cannot see; let them lift it.
+vldpSetLuma(true, 6)
+----
+
+[#vldpsetmonochrome]
+==== vldpSetMonochrome
+
+[source,text]
+----
+vldpSetMonochrome(enabled)
+----
+
+Hypseus extension. Shows the disc in luma only: the chroma planes are replaced by neutral samples when each frame is uploaded, so a paused disc changes at once. With a frame file the setting follows the playback from segment to segment. `vldpGetPixel` and `vldpGetYUVPixel` keep returning the colour samples, and extra videos drawn with `videoDraw` are unaffected. Pair it with `overlaySetMonochrome` for a black and white mode. Without a disc the call does nothing; reset when the script reloads. A non-boolean argument aborts the script.
+
+*Parameters:*
+
+* `enabled` -- boolean.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A dip switch picks colour or black and white.
+vldpSetMonochrome(not dipChroma)
+overlaySetMonochrome(not dipChroma)
+----
+
+[#vldpsetrotate]
+==== vldpSetRotate
+
+[source,text]
+----
+applied = vldpSetRotate(degrees)
+----
+
+Hypseus extension. Turns the whole presentation clockwise: the disc, the overlay, the GUIs, the 3D scene and the particles all turn together, and mouse and light gun positions are turned back so a click still lands where it is drawn. A quarter turn swaps the shape of the presented area, so a portrait window is filled rather than letterboxed into a strip.
+
+Hypseus accepts any angle from `0` to `359`; Singe takes only `0`, `90`, `180` and `270`, and answers `false` for anything else instead of ending the script. An arbitrary angle would leave the mouse mapping and the Sinden border without a sensible meaning, and no game in the Hypseus library asks for one.
+
+The rotation costs one screen sized render target and one extra blit per frame while it is not zero, and nothing at all while it is. Reset to the `--rotate` value when the script reloads.
+
+*Parameters:*
+
+* `degrees` -- integer, `0`, `90`, `180` or `270`.
+
+*Returns:* boolean, true when the rotation was applied.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- A vertical shooter section on a pivoting monitor.
+if not vldpSetRotate(90) then
+ debugPrint("This build will not turn the screen.")
+end
+----
+
+[#vldpsetscale]
+==== vldpSetScale
+
+[source,text]
+----
+applied = vldpSetScale(percent)
+----
+
+Hypseus extension. Shrinks the picture about its centre to `percent` of the window, the running equivalent of `--scalefactor`, and rebuilds the video rectangle so the disc, the overlay, the GUIs, the 3D scene, the particles, any Sinden border and the mouse mapping all follow it. Values outside `25` to `100` answer `false` and change nothing, rather than ending the script.
+
+Changes are limited to one every 15 milliseconds, as Hypseus limits its own held-key zoom; a call inside that window answers `false`, so a script driving the scale from a held key should keep calling until it takes. Reset to the `--scalefactor` value when the script reloads.
+
+*Parameters:*
+
+* `percent` -- integer, `25` to `100`.
+
+*Returns:* boolean, true when the scale was applied.
+
+*Since:* 3.00, as a Hypseus Singe extension.
+*See also:* <>, <>
+
+.Example
+[source,lua]
+----
+-- Zoom out while the player holds the adjust key.
+function onInputPressed(input)
+ if input == SWITCH_BUTTON3 then
+ vldpSetScale(vldpGetScale() - 5)
+ end
+end
+----
+
[#vldpsetverbose]
==== vldpSetVerbose
@@ -14488,7 +16860,7 @@ function onInputReleased(what)
end
----
-Called when a logical input goes down or up. What arrives depends on the keyboard mode. In `MODE_NORMAL`, `what` is a `SWITCH_*` value: keys, controller buttons, axis directions past the dead zone, mouse buttons and the mouse wheel all arrive this way, translated through `controls.cfg`, each press once with no key repeat, and inputs mapped to no switch are ignored. `SWITCH_PAUSE` arrives only after `singeSetPauseKeyEnabled(false)`; otherwise the engine freezes the game on that key and the script never sees it. In `MODE_FULL`, `what` is the keysym of the key (the character value, not the scancode), repeats are delivered, and controller and mouse buttons pass `0`; every event also reaches `onKeyPressed` and `onKeyReleased`, which carry the scancode or button code. When the pause key freezes the game every switch the script holds is released through `onInputReleased` first, and on thawing whatever is still physically down is pressed again, so a game never sees a stale button. Presses within the first second after the script starts, or after the window regains focus, are treated as held over from before and swallowed together with their release.
+Called when a logical input goes down or up. What arrives depends on the keyboard mode. In `MODE_NORMAL`, `what` is a `SWITCH_*` value: keys, controller buttons, axis directions past the dead zone, mouse buttons and the mouse wheel all arrive this way, translated through `controls.cfg`, each press once with no key repeat, and inputs mapped to no switch are ignored. `SWITCH_PAUSE` arrives only after `singeSetPauseKeyEnabled(false)`; otherwise the engine freezes the game on that key and the script never sees it. In `MODE_FULL`, `what` is the keysym of the key (the character value, not the scancode), repeats are delivered, and controller and mouse buttons pass `0`; every event also reaches `onKeyPressed` and `onKeyReleased`, which carry the scancode or button code. When the pause key freezes the game every switch the script holds is released through `onInputReleased` first, and on thawing whatever is still physically down is pressed again, so a game never sees a stale button. Presses within the first second after the script starts, or after the window regains focus, are treated as held over from before and swallowed together with their release. One switch arrives from no control at all: `SWITCH_MOUSE_DISCONNECT` is raised by the engine when a mouse or light gun is unplugged, once as a press and immediately once as a release, so a gun game can notice its gun going (see <>).
*See also:* <>, <>, <>, <>
@@ -14803,6 +17175,41 @@ end
----
[#unimplementedandlegacy]
+=== Hypseus Compatibility
+
+Hypseus Singe registers a few names in its engine that Singe answers from
+`Singe/Framework.singe` instead, so that a game written for Hypseus runs
+unchanged. Each one is defined only when the engine does not already provide
+it, so a future engine implementation wins. `keyboardCatchQuit` is documented
+under <>; `setOverlayLinearScale` and `setOverlayOnTop` are
+real engine calls under <>; `mainBezelLoaded` and the seven
+`scoreBezel` calls are real engine calls under <>; and
+`getFValue`, `ratioGetX` and `ratioGetY` are real engine calls under
+<>; `joyMouseEnable` is a real engine call
+under <>; and the whole `music` family is now a set of real
+engine calls under <>. The six `spriteAnim` names below are
+plain aliases set after the legacy sprite wrapping, so they take whichever
+argument order the rest of the sprite family is taking: Hypseus's own (handle
+last) under `LEGACY_SPRITE_ARGS`, and Singe 3.00's (handle first) without it.
+
+[cols="1,1",options="header"]
+|===
+| Function | Provided by Framework.singe as
+| `setOverlaySize(preset [, width, height])` | `overlaySetResolution` with a chosen size: `1` is the disc's video size, `3` is `360x240`, `4` is the size given (ignored when it is missing, as in Hypseus) and anything else, `2` included, is half the disc on both axes. Without a disc `discGetWidth` and `discGetHeight` report the canvas, which is what a disc-less game draws into.
+| `setOverlayResolution(width, height)` | An alias of <>. Hypseus registers one function under both names.
+| `rewriteStatus()` | Stub returning `false`. In Hypseus it reports whether asset paths are being rewritten for an `-espath` install; Singe's virtual file system finds a game's files where they were installed, so nothing is rewritten and the game keeps its own asset directory.
+| `hypseusGetHeight()` | An alias of <>.
+| `hypseusGetWidth()` | An alias of <>.
+| `takeScreenshot()` | An alias of <