Added Hypseus extensions.

This commit is contained in:
Scott Duensing 2026-09-09 15:55:26 -05:00
parent 1aede80d34
commit d024a50c8d
40 changed files with 9274 additions and 759 deletions

187
CHANGELOG
View file

@ -32,6 +32,179 @@ API Changes
- Framework.singe applies the legacy sprite argument order once even when - Framework.singe applies the legacy sprite argument order once even when
a game loads it more than once. 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 <base><suffix>.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 - lfs.dir, lfs.attributes and lfs.symlinkattributes see inside a packed
game (the union of its loose directory, its data overlay and the 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 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 layers, filter passes, mask writes, layer bytes, passes). Test scenes
scene40 to scene43 cover clip masks, layers, filters and gradients. 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 - Third-party libraries brought up to their current releases: ffmpeg
9.0.1 (from a 2023 development snapshot; libpostproc is gone with it), 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 OpenSSL 3.5.8 LTS (from a 2023 development snapshot), Lua 5.4.9, zlib

View file

@ -187,11 +187,16 @@ add_custom_command(
# Support files extracted at first run. # Support files extracted at first run.
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework_singe.h "") 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/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/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuClassic.singe ${GENERATED_DIR}/MenuClassic_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.rml ${GENERATED_DIR}/Menu_rml.h "")
singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.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/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 "") 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 # 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() endif()
elseif(KANGAROO_OS STREQUAL "pi") elseif(KANGAROO_OS STREQUAL "pi")
set(SYSTEM_LIBS -ldl) 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") if(CMAKE_BUILD_TYPE STREQUAL "Release")
list(APPEND SYSTEM_LIBS -s) list(APPEND SYSTEM_LIBS -s)
endif() endif()

View file

@ -418,6 +418,61 @@ if singeSetPauseKeyEnabled ~= nil then
singeEnablePauseKey = function() singeSetPauseKeyEnabled(true) end singeEnablePauseKey = function() singeSetPauseKeyEnabled(true) end
singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end
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 -- 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 -- 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 end
local newSpriteSetFrame = spriteSetFrame local newSpriteSetFrame = spriteSetFrame
spriteSetFrame = function(frame, id) return newSpriteSetFrame(id, frame) end 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 end
if videoGetLanguageDescription ~= nil then if videoGetLanguageDescription ~= nil then

View file

@ -24,8 +24,25 @@
-- Default Mappings -- 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 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 ...). -- 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. -- 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 } 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_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 }
INPUT_TILT = { SCANCODE.T } INPUT_TILT = { SCANCODE.T }
INPUT_GRAB = { SCANCODE.G } 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 = { }

96
assets/scoreBezel.rcss Normal file
View file

@ -0,0 +1,96 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* The score panel's look: an arcade scoreboard's seven segment readouts, each a row of dim
* "all segments lit" digits with the live value glowing over them, so an unused digit still
* shows its ghost the way a real display does. 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;
}

34
assets/scoreBezel.rml Normal file
View file

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

124
assets/settings.cfg Normal file
View file

@ -0,0 +1,124 @@
--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- settings.cfg: the options you would otherwise type on every command line.
--
-- Copy this file to settings.cfg and uncomment what you want. Singe reads it
-- from the same four places it reads controls.cfg, in this order, and a later
-- one wins key by key:
--
-- 1. the 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 <base><suffix>.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

44
assets/subtitle.rcss Normal file
View file

@ -0,0 +1,44 @@
/*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* 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);
}

19
assets/subtitle.rml Normal file
View file

@ -0,0 +1,19 @@
<rml>
<!--
Singe 3
Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
Subtitles: the document behind srtLoad, srtEnable, srtPosition and overlayBanner. The engine
writes one cue, or one banner, into #slot as a .cue whose inline style carries the height it was
asked for, so everything a subtitle looks like lives in Singe/subtitle.rcss and everything it
says comes from the game or from its .srt file.
Licensed under the GNU General Public License, version 3 or later.
-->
<head>
<title>Subtitles</title>
<link type="text/rcss" href="Singe/subtitle.rcss"/>
</head>
<body id="subtitle">
<div id="slot"></div>
</body>
</rml>

File diff suppressed because it is too large Load diff

View file

@ -31,11 +31,16 @@
#include "generated/singeLogo.h" #include "generated/singeLogo.h"
#include "generated/Framework_singe.h" #include "generated/Framework_singe.h"
#include "generated/controls_cfg.h" #include "generated/controls_cfg.h"
#include "generated/settings_cfg.h"
#include "generated/Menu_singe.h" #include "generated/Menu_singe.h"
#include "generated/MenuClassic_singe.h" #include "generated/MenuClassic_singe.h"
#include "generated/Menu_rml.h" #include "generated/Menu_rml.h"
#include "generated/menu_rcss.h" #include "generated/menu_rcss.h"
#include "generated/gui_rcss.h" #include "generated/gui_rcss.h"
#include "generated/scoreBezel_rml.h"
#include "generated/scoreBezel_rcss.h"
#include "generated/subtitle_rml.h"
#include "generated/subtitle_rcss.h"
#include "generated/FreeSansBold_ttf.h" #include "generated/FreeSansBold_ttf.h"
#include "generated/menuBackground_mkv.h" #include "generated/menuBackground_mkv.h"
#include "generated/click_wav.h" #include "generated/click_wav.h"

View file

@ -35,6 +35,9 @@
#define REPORT_COLUMN_WIDTH 8 #define REPORT_COLUMN_WIDTH 8
#define LEGACY_VIDEO_EXTENSION "m2v" // The one container whose audio is always a sidecar
typedef struct FrameLineS { typedef struct FrameLineS {
int32_t videoHandle; int32_t videoHandle;
int64_t frame; // First laserdisc frame number in this segment int64_t frame; // First laserdisc frame number in this segment
@ -85,6 +88,7 @@ static void _selectSegment(FrameFileT *f, int32_t index, int64_t frame, int32_t
// Transfer previous video's properties to this one // Transfer previous video's properties to this one
videoGetVolume(oldHandle, &left, &right); videoGetVolume(oldHandle, &left, &right);
videoSetVolume(newHandle, left, right); videoSetVolume(newHandle, left, right);
videoSetMonochrome(newHandle, videoGetMonochrome(oldHandle));
track = videoGetAudioTrack(oldHandle); track = videoGetAudioTrack(oldHandle);
if ((track >= 0) && (track < videoGetAudioTracks(newHandle))) { if ((track >= 0) && (track < videoGetAudioTracks(newHandle))) {
videoSetAudioTrack(newHandle, track); videoSetAudioTrack(newHandle, track);
@ -127,6 +131,31 @@ static void _showCalculated(const FrameFileT *f) {
} }
// The sidecar audio file a video plays with: "<base><suffix>.ogg", or NULL for the audio inside
// the container. With no suffix only the old m2v/ogg pairs look for one, and only when it exists;
// the audio may be packed like the video, so the vfs is asked. With a suffix any container may
// have one, and its absence is reported as NULL too, so the caller checks the file itself.
char *frameFileAudioName(const char *filename, const char *suffix) {
const char *extension = utilGetFileExtension(filename);
int32_t baseLength = (int32_t)(extension - filename);
char *audio = NULL;
if ((suffix[0] == 0) && (utilStricmp(extension, LEGACY_VIDEO_EXTENSION) != 0)) {
return NULL;
}
if ((extension > filename) && (extension[-1] == '.')) {
baseLength--;
}
audio = utilCreateString("%.*s%s.ogg", baseLength, filename, suffix);
if (!vfsExists(audio)) {
free(audio);
audio = NULL;
}
return audio;
}
int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle) { int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle) {
FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileGetFrame"); FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileGetFrame");
int32_t i = 0; int32_t i = 0;
@ -221,15 +250,7 @@ int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer
files = newFiles; files = newFiles;
files[count].frame = frame; files[count].frame = frame;
files[count].filename = utilCreateString("%s%s", path, name); files[count].filename = utilCreateString("%s%s", path, name);
// Is this an old m2v/ogg pair? The audio may be packed like the video, so ask the vfs. audio = frameFileAudioName(files[count].filename, "");
audio = NULL;
if (utilStricmp(utilGetFileExtension(files[count].filename), "m2v") == 0) {
audio = utilCreateString("%.*s.ogg", (int32_t)(strlen(files[count].filename) - strlen("m2v") - 1), files[count].filename);
if (!vfsExists(audio)) {
free(audio);
audio = NULL;
}
}
files[count].videoHandle = videoLoad(files[count].filename, audio, indexPath, renderer, false); files[count].videoHandle = videoLoad(files[count].filename, audio, indexPath, renderer, false);
free(audio); free(audio);
count++; count++;
@ -301,6 +322,35 @@ void frameFileSeek(int32_t frameFileHandle, int64_t seekFrame, int32_t *videoHan
} }
// Points every segment at the "<base><suffix>.ogg" beside its video, or back at the default audio
// for an empty suffix, reopening each at its current frame. Nothing changes unless every segment
// has the file.
bool frameFileSetAudioSuffix(int32_t frameFileHandle, const char *suffix) {
FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileSetAudioSuffix");
int32_t i = 0;
char *audio = NULL;
if (suffix[0] != 0) {
for (i = 0; i < f->count; i++) {
audio = frameFileAudioName(f->files[i].filename, suffix);
if (audio == NULL) {
return false;
}
free(audio);
}
}
for (i = 0; i < f->count; i++) {
audio = frameFileAudioName(f->files[i].filename, suffix);
if (!videoReopenAudio(f->files[i].videoHandle, audio)) {
utilDie("Unable to open %s for audio.", audio);
}
free(audio);
}
return true;
}
void frameFileUnload(int32_t frameFileHandle) { void frameFileUnload(int32_t frameFileHandle) {
FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileUnload"); FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileUnload");
int32_t i = 0; int32_t i = 0;

View file

@ -30,10 +30,12 @@
#include "common.h" #include "common.h"
char *frameFileAudioName(const char *filename, const char *suffix);
int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle); int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle);
int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer *renderer, bool showCalculated); int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer *renderer, bool showCalculated);
void frameFileQuit(void); void frameFileQuit(void);
void frameFileSeek(int32_t frameFileHandle, int64_t seekFrame, int32_t *videoHandle, int64_t *actualFrame); void frameFileSeek(int32_t frameFileHandle, int64_t seekFrame, int32_t *videoHandle, int64_t *actualFrame);
bool frameFileSetAudioSuffix(int32_t frameFileHandle, const char *suffix);
void frameFileUnload(int32_t frameFileHandle); void frameFileUnload(int32_t frameFileHandle);
void frameFileUpdate(int32_t frameFileHandle, int32_t *videoHandle); void frameFileUpdate(int32_t frameFileHandle, int32_t *videoHandle);

1397
src/main.c

File diff suppressed because it is too large Load diff

View file

@ -30,8 +30,22 @@
#define VOLUME_MIN 0 #define VOLUME_MIN 0
#define VOLUME_MAX 100 #define VOLUME_MAX 100
#define SCALE_FACTOR_MIN 50 #define SCALE_FACTOR_MIN 25
#define SCALE_FACTOR_MAX 100 #define SCALE_FACTOR_MAX 100
#define SHIFT_MIN -100
#define SHIFT_MAX 100
#define ROTATE_MIN 0
#define ROTATE_MAX 270
#define ROTATE_STEP 90
#define RUMBLE_LEVEL_NONE 0 // --haptic 0: the pads never rumble
#define RUMBLE_LEVEL_MIN 1 // controllerDoRumble strength and duration steps
#define RUMBLE_LEVEL_MAX 4
#define GAMEPAD_ORDER_DIGITS 10 // --gamepad_reorder names enumeration positions 0 to 9
#define JOY_MOUSE_RANGE_MIN 1 // --js_range, in Hypseus's own steps
#define JOY_MOUSE_RANGE_MAX 20
#define JOY_MOUSE_RANGE_DEFAULT 5
#define TRIGGER_THRESHOLD_MIN 0.0 // --trigger_threshold: 0 leaves the triggers on DEAD_ZONE
#define TRIGGER_THRESHOLD_MAX 100.0 // Per cent of full travel, as Hypseus writes it
#define DIRECTORY_MODE 0777 #define DIRECTORY_MODE 0777
#define SCRIPT_MODE 0755 // Menu.sh: chmod ignores the umask, so no world write #define SCRIPT_MODE 0755 // Menu.sh: chmod ignores the umask, so no world write
#define CANVAS_DEFAULT_WIDTH 720 #define CANVAS_DEFAULT_WIDTH 720

View file

@ -6126,6 +6126,7 @@ void sceneUpdateTransforms(void) {
void sceneUpdateVideo(SceneVideoSourceFn source) { void sceneUpdateVideo(SceneVideoSourceFn source) {
int32_t x; int32_t x;
bool any = false; bool any = false;
SDL_Texture *previous = SDL_GetRenderTarget(_scene.renderer);
SDL_Texture *frame; SDL_Texture *frame;
float width; float width;
float height; float height;
@ -6154,7 +6155,8 @@ void sceneUpdateVideo(SceneVideoSourceFn source) {
any = true; any = true;
} }
if (any) { if (any) {
SDL_SetRenderTarget(_scene.renderer, NULL); // Back to whatever was being drawn into, which is a texture when --rotate is turning the frame.
SDL_SetRenderTarget(_scene.renderer, previous);
SDL_FlushRenderer(_scene.renderer); SDL_FlushRenderer(_scene.renderer);
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,14 @@ typedef enum ToolModeE {
TOOL_UNPACK TOOL_UNPACK
} ToolModeE; } ToolModeE;
// Where the Sinden light gun border sits. Auto puts it at the window's edge when a bezel is
// loaded (the gun's camera sees the whole screen) and around the picture when none is.
typedef enum SindenEdgeE {
SINDEN_EDGE_AUTO = 0,
SINDEN_EDGE_VIDEO,
SINDEN_EDGE_WINDOW
} SindenEdgeE;
// Number of --sindengun arguments selects the border style. // Number of --sindengun arguments selects the border style.
typedef enum SindenModeE { typedef enum SindenModeE {
SINDEN_WHITE = 1, SINDEN_WHITE = 1,
@ -60,7 +68,8 @@ typedef enum GivenE {
GIVEN_SINDEN = 1 << 4, GIVEN_SINDEN = 1 << 4,
GIVEN_AUDIO_TRACK = 1 << 5, GIVEN_AUDIO_TRACK = 1 << 5,
GIVEN_AUDIO_DELAY = 1 << 6, GIVEN_AUDIO_DELAY = 1 << 6,
GIVEN_CANVAS = 1 << 7 GIVEN_CANVAS = 1 << 7,
GIVEN_AUDIO_SUFFIX = 1 << 8
} GivenE; } GivenE;
typedef struct ConfigS { typedef struct ConfigS {
@ -72,8 +81,13 @@ typedef struct ConfigS {
ToolModeE toolMode; ToolModeE toolMode;
uint32_t given; // GivenE bits uint32_t given; // GivenE bits
char *gameDir; // -G: where the games and the Singe folder live; becomes the working directory char *gameDir; // -G: where the games and the Singe folder live; becomes the working directory
char *bezelFile; // --bezel: the artwork image inside the bezels folder
char *bezelDir; // --bezeldir: the folder holding it, in place of "bezels"
char *dataDir; char *dataDir;
char *dataDirBase; char *dataDirBase;
char *keymapFile; // --keymapfile: the controls.cfg to read in place of the four place search
char *audioSuffix; // --altaudio: the disc audio suffix discAudioSuffix takes, applied at startup
char *gamepadOrder; // --gamepad_reorder: enumeration positions, one per gamepad slot, as Hypseus writes them
bool resolutionWasCalculated; bool resolutionWasCalculated;
bool isFrameFile; bool isFrameFile;
bool stretchVideo; bool stretchVideo;
@ -89,25 +103,56 @@ typedef struct ConfigS {
bool reload; // --reload: watch the loose script files and rerun the game when one changes; F5 too bool reload; // --reload: watch the loose script files and rerun the game when one changes; F5 too
bool scriptTracing; bool scriptTracing;
bool legacySpriteArgs; bool legacySpriteArgs;
bool bezelFlip; // --bezelflip: the artwork draws in front of the picture instead of behind it
bool disc; // Play a laserdisc video; otherwise the canvas is the world bool disc; // Play a laserdisc video; otherwise the canvas is the world
bool softwareVideo; // Skip the platform's hardware decoder bool softwareVideo; // Skip the platform's hardware decoder
bool noGamepad; // --nogamepad: ignore every gamepad, as --nomouse ignores the mice
bool mapJoysticks; // --mapjoysticks: write a gamepad mapping for a device SDL does not recognise
bool joyMouse; // --joymouse: the first gamepad's left stick drives the mouse cursor
bool manyMouse; // --manymouse: the user asks for one mouse device per player, whatever the game asks for
bool absolutesOnly; // --absolutes_only: keep only the mice that report absolute positions, which is what light guns do
bool startSilent; // --startsilent: muted until the first input of any kind
bool monochrome; // --monochrome: the disc picture starts in greyscale
bool linearScale; // --linearscale: the overlay starts filtered linearly, which is the default
int32_t bestRatioIndex; int32_t bestRatioIndex;
int32_t volumeVldp; int32_t volumeVldp;
int32_t volumeNonVldp; int32_t volumeNonVldp;
int32_t scaleFactor; int32_t scaleFactor; // --scalefactor: overscan compensation, per cent of the logical rectangle
int32_t shiftX; // --shiftx: per cent of half the logical width the picture moves right
int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down
int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees
int32_t xResolution; int32_t xResolution;
int32_t yResolution; int32_t yResolution;
int32_t canvasWidth; // World size when there is no disc int32_t canvasWidth; // World size when there is no disc
int32_t canvasHeight; int32_t canvasHeight;
int32_t sindenEdge; // --sindenedge: SindenEdgeE, where the border rings are drawn
int32_t sindenArgc; int32_t sindenArgc;
int32_t sindenArgv[SINDEN_ARG_MAX]; int32_t sindenArgv[SINDEN_ARG_MAX];
int32_t audioOutputTrack; int32_t audioOutputTrack;
int32_t audioDelayMs; // Positive when the audio device is heard later than it reports int32_t audioDelayMs; // Positive when the audio device is heard later than it reports
int32_t screen; // --screen: which display the window opens on, from 1; 0 is the primary
int32_t idleExitSeconds; // --idleexit: quit after this long with no input; 0 never quits
int32_t haptic; // --haptic: the strongest rumble step a script may ask for; 0 is no rumble
int32_t joyMouseRange; // --js_range: video pixels a frame the cursor moves at full stick deflection
double triggerThreshold; // --trigger_threshold: per cent of full travel a trigger counts as pressed at; 0 uses the dead zone
double ratioX; // --xratio: the gun coordinate scale a script reads with ratioGetX
double ratioY; // --yratio: the same for ratioGetY
double fValue; // --fvalue: the launcher's one number, read with getFValue
} ConfigT; } ConfigT;
// One key of the settings file, and the file that set it, so an error can name both.
typedef struct SettingS {
char *key;
char *value;
char *source;
} SettingT;
ConfigT *confFromDatabase(const ConfigT *conf); ConfigT *confFromDatabase(const ConfigT *conf);
ConfigT *confFromGamesDat(const ConfigT *conf); ConfigT *confFromGamesDat(const ConfigT *conf);
void settingsFree(SettingT *settings, int32_t count);
SettingT *settingsLoad(const ConfigT *conf, int32_t *count);
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf); void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);

View file

@ -87,12 +87,31 @@ typedef struct iso639_lang_t iso639_lang_t;
#define PERCENT_MAX 100 #define PERCENT_MAX 100
#define PERCENT_TO_SCALE 0.01f #define PERCENT_TO_SCALE 0.01f
#define PLANE_COUNT 3 // Y, U, V #define PLANE_COUNT 3 // Y, U, V
#define CHROMA_NEUTRAL 0x80 // U and V sample with no colour, for monochrome playback
#define FLASH_LUMA 0xd4 // videoFlash's white picture: 90 per cent luma, the value Hypseus flashes
#define LUMA_BLEND_TAPS 3 // videoSetBlend averages a luma sample with the rows above and below it
#define LUMA_LEVEL_SHIFT 3 // The distance from neutral is applied in eighths, as Hypseus applies it
#define LUMA_SAMPLE_MAX 255 // A luma sample is one byte
#define SCALER_PLANES 4 // libswscale reads four plane pointers and strides whatever the format #define SCALER_PLANES 4 // libswscale reads four plane pointers and strides whatever the format
#define SEEK_RETRY_MAX 3 // Keyframes to back up when a seek lands past its target #define SEEK_RETRY_MAX 3 // Keyframes to back up when a seek lands past its target
#define STRIDE_ALIGNMENT 64 // libswscale stores with aligned vector instructions; rows must start aligned #define STRIDE_ALIGNMENT 64 // libswscale stores with aligned vector instructions; rows must start aligned
// What one decode request came to. // What one decode request came to.
// The Pi has no VA-API or VDPAU; its decode hardware is a V4L2 memory-to-memory device driven by
// libavcodec's separate *_v4l2m2m decoders, which are chosen by name. Only the Pi build asks for
// them: on a desktop the probe would open every /dev/video* device.
#if defined(SINGE_V4L2_DECODE)
#define V4L2_DECODE true
#else
#define V4L2_DECODE false
#endif
typedef struct V4l2DecoderS {
enum AVCodecID id;
const char *name;
} V4l2DecoderT;
typedef enum DecodeResultE { typedef enum DecodeResultE {
DECODE_OK, // The wanted frame is in the back buffer DECODE_OK, // The wanted frame is in the back buffer
DECODE_NONE, // Nothing decodable there: past the end, or a seek that could not be satisfied DECODE_NONE, // Nothing decodable there: past the end, or a seek that could not be satisfied
@ -131,9 +150,17 @@ typedef struct AudioTrackS {
#pragma GCC diagnostic ignored "-Wpadded" #pragma GCC diagnostic ignored "-Wpadded"
typedef struct VideoPlayerS { typedef struct VideoPlayerS {
int32_t id; int32_t id;
char *filename; // The video file, for reopening its own audio
bool playing; bool playing;
bool resetTime; bool resetTime;
bool rgb; // BGRA frames for scripts to read; otherwise YUV for the GPU bool rgb; // BGRA frames for scripts to read; otherwise YUV for the GPU
bool monochrome; // Upload neutral chroma planes (YUV players only)
bool blend; // Smooth the luma plane down the picture (YUV players only)
bool flashed; // videoFlash put a white picture on the texture; the next update takes it off
int32_t luma; // Eighths added to every luma sample, -4 to 4; 0 leaves the picture alone
uint8_t *neutralChroma; // One chroma plane of CHROMA_NEUTRAL, allocated on first use
uint8_t *lumaPlane; // Scratch luma plane the blend, the luma level and the flash write into
size_t lumaBytes;
int32_t width; int32_t width;
int32_t height; int32_t height;
int32_t volumeLeft; int32_t volumeLeft;
@ -160,6 +187,7 @@ typedef struct VideoPlayerS {
bool videoDrained; bool videoDrained;
bool packetPending; // videoPacket holds data the decoder refused (EAGAIN) bool packetPending; // videoPacket holds data the decoder refused (EAGAIN)
bool hwReported; // First hardware frame has been traced bool hwReported; // First hardware frame has been traced
bool v4l2; // Decoding through a V4L2 memory-to-memory device
bool videoHasAudio; // The video file carries an audio stream of its own bool videoHasAudio; // The video file carries an audio stream of its own
// Audio demuxer, decoder, and resampler. Owned by the main thread. // Audio demuxer, decoder, and resampler. Owned by the main thread.
@ -211,11 +239,14 @@ typedef struct VideoPlayerS {
static void _allocateFrameBuffer(VideoPlayerT *v, FrameBufferT *buffer); static void _allocateFrameBuffer(VideoPlayerT *v, FrameBufferT *buffer);
static void _alsaSetQuiet(bool quiet); static void _alsaSetQuiet(bool quiet);
static int64_t _audioClock(VideoPlayerT *v, uint64_t now); static int64_t _audioClock(VideoPlayerT *v, uint64_t now);
static void _audioClose(VideoPlayerT *v);
static void _audioCloseTrack(VideoPlayerT *v); static void _audioCloseTrack(VideoPlayerT *v);
static bool _audioOpen(const char *filename, AVFormatContext **format, AudioTrackT **tracks, int32_t *count);
static void _audioQueueFrame(VideoPlayerT *v); static void _audioQueueFrame(VideoPlayerT *v);
static void _audioSeek(VideoPlayerT *v, int64_t ms); static void _audioSeek(VideoPlayerT *v, int64_t ms);
static void _audioSelectTrack(VideoPlayerT *v, int32_t track); static void _audioSelectTrack(VideoPlayerT *v, int32_t track);
static void _audioSetupResampler(VideoPlayerT *v); static void _audioSetupResampler(VideoPlayerT *v);
static void _audioStartTrack(VideoPlayerT *v, int32_t track);
static int _avioRead(void *opaque, uint8_t *buffer, int size); static int _avioRead(void *opaque, uint8_t *buffer, int size);
static int64_t _avioSeek(void *opaque, int64_t offset, int whence); static int64_t _avioSeek(void *opaque, int64_t offset, int whence);
static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath); static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath);
@ -230,10 +261,13 @@ static AVFormatContext *_formatOpen(const char *filename);
static int64_t _frameTime(VideoPlayerT *v, int64_t frame); static int64_t _frameTime(VideoPlayerT *v, int64_t frame);
static VideoPlayerT *_getPlayer(int32_t playerHandle, const char *caller); static VideoPlayerT *_getPlayer(int32_t playerHandle, const char *caller);
static char *_indexFileName(const char *filename, const char *indexPath); static char *_indexFileName(const char *filename, const char *indexPath);
static void _loadAudio(VideoPlayerT *v, const char *filename); static uint8_t *_lumaPlane(VideoPlayerT *v);
static void _measureDeviceQueue(void *udata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static void _measureDeviceQueue(void *udata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples);
static uint8_t *_neutralChroma(VideoPlayerT *v);
static bool _openDecoder(VideoPlayerT *v, const AVCodec *decoder, const AVStream *stream);
static void _openHardware(VideoPlayerT *v, const AVCodec *decoder); static void _openHardware(VideoPlayerT *v, const AVCodec *decoder);
static void _openVideo(VideoPlayerT *v, const char *filename); static void _openVideo(VideoPlayerT *v, const char *filename);
static const uint8_t *_pictureFilter(VideoPlayerT *v);
static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *expected); static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *expected);
static void _reportKeyframes(VideoPlayerT *v, const char *filename); static void _reportKeyframes(VideoPlayerT *v, const char *filename);
static void _requestFrame(VideoPlayerT *v); static void _requestFrame(VideoPlayerT *v);
@ -244,6 +278,7 @@ static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase);
static bool _takeDecodedFrame(VideoPlayerT *v); static bool _takeDecodedFrame(VideoPlayerT *v);
static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples);
static void _uploadFrame(VideoPlayerT *v); static void _uploadFrame(VideoPlayerT *v);
static const AVCodec *_v4l2Decoder(const AVCodec *decoder);
static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header); static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header);
@ -369,12 +404,82 @@ static int64_t _audioClock(VideoPlayerT *v, uint64_t now) {
} }
// Releases the whole audio side: the mixer track, the decoder, the demuxer and the track table.
static void _audioClose(VideoPlayerT *v) {
int32_t x = 0;
if (v->audioSourceCount > 0) {
MIX_DestroyTrack(v->track);
SDL_DestroyAudioStream(v->audioStream);
_audioCloseTrack(v);
av_frame_free(&v->audioFrame);
av_packet_free(&v->audioPacket);
_formatClose(&v->audioFormat);
for (x = 0; x < v->audioSourceCount; x++) {
free(v->audio[x].language);
}
free(v->audio);
free(v->audioBuffer);
}
v->track = NULL;
v->audioStream = NULL;
v->audio = NULL;
v->audioBuffer = NULL;
v->audioBufferSamples = 0;
v->audioSourceCount = 0;
v->currentAudioTrack = -1;
v->audioEof = false;
v->audioClockValid = false;
}
static void _audioCloseTrack(VideoPlayerT *v) { static void _audioCloseTrack(VideoPlayerT *v) {
avcodec_free_context(&v->audioCodec); avcodec_free_context(&v->audioCodec);
swr_free(&v->swr); swr_free(&v->swr);
} }
// Opens the audio side of a file (the video file, or a separate audio file) and lists its tracks.
// The track table is sized for every stream; only the audio ones fill it. A file with no audio
// stream leaves the demuxer closed and the count at zero. False when the file cannot be read.
static bool _audioOpen(const char *filename, AVFormatContext **format, AudioTrackT **tracks, int32_t *count) {
AVDictionaryEntry *tag = NULL;
int32_t x = 0;
*tracks = NULL;
*count = 0;
*format = _formatOpen(filename);
if (*format == NULL) {
return false;
}
if (avformat_find_stream_info(*format, NULL) < 0) {
_formatClose(format);
return false;
}
*tracks = (AudioTrackT *)calloc((size_t)(*format)->nb_streams + 1, sizeof(AudioTrackT));
if (!*tracks) {
utilDie("Unable to allocate audio tracks.");
}
for (x = 0; x < (int32_t)(*format)->nb_streams; x++) {
if ((*format)->streams[x]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
(*tracks)[*count].streamIndex = x;
tag = av_dict_get((*format)->streams[x]->metadata, "language", NULL, 0);
if (tag != NULL) {
(*tracks)[*count].language = strdup(tag->value);
}
(*count)++;
}
}
if (*count == 0) {
free(*tracks);
*tracks = NULL;
_formatClose(format);
}
return true;
}
// Resamples one decoded audio frame to the track's format and queues it, dropping whatever lies // Resamples one decoded audio frame to the track's format and queues it, dropping whatever lies
// before the seek target. // before the seek target.
static void _audioQueueFrame(VideoPlayerT *v) { static void _audioQueueFrame(VideoPlayerT *v) {
@ -492,6 +597,37 @@ static void _audioSetupResampler(VideoPlayerT *v) {
} }
// Starts the mixer track for an opened audio side on the given track: a track that never halts,
// fed from its own demuxer on the main thread, at the player's volume, paused unless the video plays.
static void _audioStartTrack(VideoPlayerT *v, int32_t track) {
SDL_PropertiesID playProps = 0;
v->audioPacket = av_packet_alloc();
v->audioFrame = av_frame_alloc();
if (!v->audioPacket || !v->audioFrame) {
utilDie("Unable to allocate audio decoding buffers.");
}
v->track = MIX_CreateTrack(_mixer);
if (!v->track) {
utilDie("%s", SDL_GetError());
}
_audioSelectTrack(v, track);
if (!MIX_SetTrackRawCallback(v->track, _trackMixed, v)) {
utilDie("%s", SDL_GetError());
}
playProps = SDL_CreateProperties();
SDL_SetBooleanProperty(playProps, MIX_PROP_PLAY_HALT_WHEN_EXHAUSTED_BOOLEAN, false);
if (!MIX_PlayTrack(v->track, playProps)) {
utilDie("%s", SDL_GetError());
}
SDL_DestroyProperties(playProps);
videoSetVolume(v->id, v->volumeLeft, v->volumeRight);
if (!v->playing) {
MIX_PauseTrack(v->track);
}
}
static int _avioRead(void *opaque, uint8_t *buffer, int size) { static int _avioRead(void *opaque, uint8_t *buffer, int size) {
int64_t got = vfsStreamRead((VfsStreamT *)opaque, buffer, size); int64_t got = vfsStreamRead((VfsStreamT *)opaque, buffer, size);
@ -686,6 +822,10 @@ static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) {
} }
_convertFrame(v, &v->back, v->hwFrame); _convertFrame(v, &v->back, v->hwFrame);
} else { } else {
if (v->v4l2 && !v->hwReported) {
v->hwReported = true;
utilTrace("Video %d: first V4L2 frame arrived as %s %dx%d", v->id, av_get_pix_fmt_name((enum AVPixelFormat)v->videoFrame->format), v->videoFrame->width, v->videoFrame->height);
}
_convertFrame(v, &v->back, v->videoFrame); _convertFrame(v, &v->back, v->videoFrame);
} }
av_frame_unref(v->videoFrame); av_frame_unref(v->videoFrame);
@ -930,45 +1070,18 @@ static char *_indexFileName(const char *filename, const char *indexPath) {
} }
// Opens the audio side of a file (the video file, or the separate audio file of an old framefile) and // The scratch luma plane the blend, the luma level and the flash write into, allocated the first
// lists its tracks. The track table is sized for every stream; only the audio ones fill it. // time one of them is asked for. It is the size of a decoded luma plane, stride and all.
static void _loadAudio(VideoPlayerT *v, const char *filename) { static uint8_t *_lumaPlane(VideoPlayerT *v) {
AVDictionaryEntry *tag = NULL; if (v->lumaPlane == NULL) {
int32_t x = 0; v->lumaBytes = (size_t)v->front.linesize[0] * (size_t)v->height;
v->lumaPlane = malloc(v->lumaBytes);
v->audioFormat = _formatOpen(filename); if (!v->lumaPlane) {
if (v->audioFormat == NULL) { utilDie("Unable to allocate the luma plane.");
utilDie("Unable to open %s for audio.", filename);
} }
if (avformat_find_stream_info(v->audioFormat, NULL) < 0) {
utilDie("Unable to read stream information from %s.", filename);
}
v->audio = (AudioTrackT *)calloc((size_t)v->audioFormat->nb_streams + 1, sizeof(AudioTrackT));
if (!v->audio) {
utilDie("Unable to allocate audio tracks.");
}
for (x = 0; x < (int32_t)v->audioFormat->nb_streams; x++) {
if (v->audioFormat->streams[x]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
v->audio[v->audioSourceCount].streamIndex = x;
tag = av_dict_get(v->audioFormat->streams[x]->metadata, "language", NULL, 0);
if (tag != NULL) {
v->audio[v->audioSourceCount].language = strdup(tag->value);
}
v->audioSourceCount++;
}
}
if (v->audioSourceCount == 0) {
free(v->audio);
v->audio = NULL;
_formatClose(&v->audioFormat);
return;
} }
v->audioPacket = av_packet_alloc(); return v->lumaPlane;
v->audioFrame = av_frame_alloc();
if (!v->audioPacket || !v->audioFrame) {
utilDie("Unable to allocate audio decoding buffers.");
}
} }
@ -1000,6 +1113,41 @@ static void _measureDeviceQueue(void *udata, MIX_Mixer *mixer, const SDL_AudioSp
} }
// One chroma plane of CHROMA_NEUTRAL, allocated the first time monochrome or the flash asks for
// it. Both chroma planes read from the one buffer, since they share a stride.
static uint8_t *_neutralChroma(VideoPlayerT *v) {
size_t planeBytes = (size_t)v->front.linesize[1] * (size_t)((v->height + 1) / 2);
if (v->neutralChroma == NULL) {
v->neutralChroma = malloc(planeBytes);
if (!v->neutralChroma) {
utilDie("Unable to allocate the neutral chroma plane.");
}
memset(v->neutralChroma, CHROMA_NEUTRAL, planeBytes);
}
return v->neutralChroma;
}
// Allocates the codec context for the stream, attaches the platform's hardware decoder when there
// is one, and opens it. Returns false with the context freed when the decoder will not open.
static bool _openDecoder(VideoPlayerT *v, const AVCodec *decoder, const AVStream *stream) {
v->videoCodec = avcodec_alloc_context3(decoder);
if (!v->videoCodec || (avcodec_parameters_to_context(v->videoCodec, stream->codecpar) < 0)) {
utilDie("Unable to set up the video decoder.");
}
v->videoCodec->thread_count = 0; // Let libavcodec pick
_openHardware(v, decoder);
if (avcodec_open2(v->videoCodec, decoder, NULL) < 0) {
avcodec_free_context(&v->videoCodec);
return false;
}
return true;
}
// Attaches the platform's hardware decoder to the codec context when it offers one for this codec. // Attaches the platform's hardware decoder to the codec context when it offers one for this codec.
// Failure of any step leaves the context decoding in software. // Failure of any step leaves the context decoding in software.
static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) { static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) {
@ -1050,6 +1198,7 @@ static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) {
// Opens the demuxer and decoder the decoder thread will use. // Opens the demuxer and decoder the decoder thread will use.
static void _openVideo(VideoPlayerT *v, const char *filename) { static void _openVideo(VideoPlayerT *v, const char *filename) {
const AVCodec *decoder = NULL; const AVCodec *decoder = NULL;
const AVCodec *v4l2 = NULL;
AVStream *stream = NULL; AVStream *stream = NULL;
int32_t x = 0; int32_t x = 0;
@ -1080,15 +1229,18 @@ static void _openVideo(VideoPlayerT *v, const char *filename) {
v->fps.den = DEFAULT_FPS_DENOMINATOR; v->fps.den = DEFAULT_FPS_DENOMINATOR;
} }
v->videoCodec = avcodec_alloc_context3(decoder); v4l2 = _v4l2Decoder(decoder);
if (!v->videoCodec || (avcodec_parameters_to_context(v->videoCodec, stream->codecpar) < 0)) { if ((v4l2 != NULL) && _openDecoder(v, v4l2, stream)) {
utilDie("Unable to set up the video decoder for %s.", filename); v->v4l2 = true;
utilTrace("Hardware decoding via %s.", v4l2->name);
} else {
if (v4l2 != NULL) {
utilTrace("Hardware decoding via %s is not available; decoding in software.", v4l2->name);
} }
v->videoCodec->thread_count = 0; // Let libavcodec pick if (!_openDecoder(v, decoder, stream)) {
_openHardware(v, decoder);
if (avcodec_open2(v->videoCodec, decoder, NULL) < 0) {
utilDie("Unable to open the video decoder for %s.", filename); utilDie("Unable to open the video decoder for %s.", filename);
} }
}
v->videoPacket = av_packet_alloc(); v->videoPacket = av_packet_alloc();
v->videoFrame = av_frame_alloc(); v->videoFrame = av_frame_alloc();
if (!v->videoPacket || !v->videoFrame) { if (!v->videoPacket || !v->videoFrame) {
@ -1099,6 +1251,39 @@ static void _openVideo(VideoPlayerT *v, const char *filename) {
} }
// The disc picture controls, on the decoded luma plane, in the order a viewer expects: the blend
// smooths each sample against the rows above and below it (Hypseus's own three tap average, which
// takes the hard edges off an interlaced transfer), and the luma level then brightens or darkens
// what that left, in eighths from half to one and a half. The chroma planes are untouched, so a
// change of level moves the brightness without draining the colour. Returns the plane to upload.
static const uint8_t *_pictureFilter(VideoPlayerT *v) {
const uint8_t *source = v->front.data[0];
uint8_t *plane = _lumaPlane(v);
int32_t pitch = v->front.linesize[0];
int32_t value = 0;
int32_t x = 0;
int32_t y = 0;
for (y = 0; y < v->height; y++) {
const uint8_t *above = source + ((y > 0) ? (y - 1) : y) * pitch;
const uint8_t *here = source + y * pitch;
const uint8_t *below = source + ((y < (v->height - 1)) ? (y + 1) : y) * pitch;
uint8_t *row = plane + y * pitch;
for (x = 0; x < v->width; x++) {
value = v->blend ? ((above[x] + here[x] + below[x]) / LUMA_BLEND_TAPS) : here[x];
if (v->luma != 0) {
value += (value * v->luma) >> LUMA_LEVEL_SHIFT;
value = SDL_clamp(value, 0, LUMA_SAMPLE_MAX);
}
row[x] = (uint8_t)value;
}
}
return plane;
}
// Loads a cached frame table if it matches this file. Returns false when the table must be rebuilt. // Loads a cached frame table if it matches this file. Returns false when the table must be rebuilt.
static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *expected) { static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *expected) {
size_t bytes = 0; size_t bytes = 0;
@ -1271,17 +1456,60 @@ static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec
} }
// Pushes the front buffer to the texture. YUV players convert on the GPU. // Pushes the front buffer to the texture. YUV players convert on the GPU, and the picture
// controls change the planes on the way past.
static void _uploadFrame(VideoPlayerT *v) { static void _uploadFrame(VideoPlayerT *v) {
const uint8_t *luma = v->front.data[0];
const uint8_t *cb = v->front.data[1];
const uint8_t *cr = v->front.data[2];
if (v->rgb) { if (v->rgb) {
SDL_UpdateTexture(v->videoTexture, NULL, v->front.data[0], v->front.linesize[0]); SDL_UpdateTexture(v->videoTexture, NULL, v->front.data[0], v->front.linesize[0]);
} else { } else {
SDL_UpdateYUVTexture(v->videoTexture, NULL, v->front.data[0], v->front.linesize[0], v->front.data[1], v->front.linesize[1], v->front.data[2], v->front.linesize[2]); if (v->blend || (v->luma != 0)) {
luma = _pictureFilter(v);
} }
if (v->monochrome) {
// Both chroma planes read from one neutral plane; the buffers share a stride.
cb = _neutralChroma(v);
cr = cb;
}
SDL_UpdateYUVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], cb, v->front.linesize[1], cr, v->front.linesize[2]);
}
v->flashed = false;
v->uploadedFrame = v->front.frame; v->uploadedFrame = v->front.frame;
} }
// The V4L2 memory-to-memory decoder for the stream's codec on a Pi build, NULL when there is none or
// hardware decoding is off. Whether the device exists is found out when the decoder opens.
static const AVCodec *_v4l2Decoder(const AVCodec *decoder) {
static const V4l2DecoderT table[] = {
{ AV_CODEC_ID_H263, "h263_v4l2m2m" },
{ AV_CODEC_ID_H264, "h264_v4l2m2m" },
{ AV_CODEC_ID_HEVC, "hevc_v4l2m2m" },
{ AV_CODEC_ID_MPEG1VIDEO, "mpeg1_v4l2m2m" },
{ AV_CODEC_ID_MPEG2VIDEO, "mpeg2_v4l2m2m" },
{ AV_CODEC_ID_MPEG4, "mpeg4_v4l2m2m" },
{ AV_CODEC_ID_VC1, "vc1_v4l2m2m" },
{ AV_CODEC_ID_VP8, "vp8_v4l2m2m" },
{ AV_CODEC_ID_VP9, "vp9_v4l2m2m" }
};
size_t x = 0;
if (!V4L2_DECODE || !_hardwareDecoding) {
return NULL;
}
for (x = 0; x < SDL_arraysize(table); x++) {
if (table[x].id == decoder->id) {
return avcodec_find_decoder_by_name(table[x].name);
}
}
return NULL;
}
static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header) { static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header) {
FILE *out = fopen(indexName, "wb"); FILE *out = fopen(indexName, "wb");
@ -1299,6 +1527,25 @@ static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const Index
} }
// One white picture on the disc, the way a laserdisc player flashes when it is searched. It goes
// up at once and the next decoded frame takes it off again; a player showing colour for a script to
// read is left alone.
void videoFlash(int32_t playerHandle) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoFlash");
uint8_t *luma = NULL;
uint8_t *chroma = NULL;
if (v->rgb || (v->front.frame < 0)) {
return;
}
luma = _lumaPlane(v);
chroma = _neutralChroma(v);
memset(luma, FLASH_LUMA, v->lumaBytes);
SDL_UpdateYUVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], chroma, v->front.linesize[1], chroma, v->front.linesize[2]);
v->flashed = true;
}
int32_t videoGetAudioCalibration(void) { int32_t videoGetAudioCalibration(void) {
return _audioCalibrationMs; return _audioCalibrationMs;
} }
@ -1325,6 +1572,15 @@ int32_t videoGetAudioTracks(int32_t playerHandle) {
} }
// Frames per second of the loaded video, which is what a subtitle's timestamps are turned into
// frame numbers with.
double videoGetFps(int32_t playerHandle) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetFps");
return (double)v->fps.num / (double)v->fps.den;
}
int64_t videoGetFrame(int32_t playerHandle) { int64_t videoGetFrame(int32_t playerHandle) {
return _getPlayer(playerHandle, "videoGetFrame")->frame; return _getPlayer(playerHandle, "videoGetFrame")->frame;
} }
@ -1377,6 +1633,31 @@ MIX_Mixer *videoGetMixer(void) {
} }
// What the decoder will be, for the trace header: the platform's hardware decoders in the order
// they are tried, or software when there are none or --softwarevideo ruled them out. Which one a
// given video actually got is traced as that video opens, since a codec may offer neither.
const char *videoGetDecoderDescription(void) {
if (!_hardwareDecoding) {
return "software only (--softwarevideo)";
}
if (V4L2_DECODE) {
return "hardware v4l2m2m where the codec allows, software otherwise";
}
#if defined(_WIN32)
return "hardware d3d11va or dxva2 where the codec allows, software otherwise";
#elif defined(__APPLE__)
return "hardware videotoolbox where the codec allows, software otherwise";
#else
return "hardware vaapi or vdpau where the codec allows, software otherwise";
#endif
}
bool videoGetMonochrome(int32_t playerHandle) {
return _getPlayer(playerHandle, "videoGetMonochrome")->monochrome;
}
// Reads one pixel of the frame being shown. Returns false if there is no frame yet. // Reads one pixel of the frame being shown. Returns false if there is no frame yet.
bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b) { bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixel"); VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixel");
@ -1442,6 +1723,22 @@ int32_t videoGetWidth(int32_t playerHandle) {
} }
// Reads the raw 4:2:0 samples of one pixel of the frame being shown. False for rgb players, before
// the first frame, or outside the frame.
bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma, uint8_t *cb, uint8_t *cr) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoGetYUVPixel");
if (v->rgb || (v->front.frame < 0) || (x < 0) || (y < 0) || (x >= v->width) || (y >= v->height)) {
return false;
}
*luma = v->front.data[0][y * v->front.linesize[0] + x];
*cb = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2)];
*cr = v->front.data[2][(y / 2) * v->front.linesize[2] + (x / 2)];
return true;
}
void videoInit(MIX_Mixer *mixer) { void videoInit(MIX_Mixer *mixer) {
uint64_t started = 0; uint64_t started = 0;
@ -1488,13 +1785,17 @@ bool videoIsPlaying(int32_t playerHandle) {
// scripts can read the pixels; everything else stays YUV and is converted by the GPU. // scripts can read the pixels; everything else stays YUV and is converted by the GPU.
int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb) { int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb) {
VideoPlayerT *v = NULL; VideoPlayerT *v = NULL;
SDL_PropertiesID playProps = 0; const char *name = audioFilename ? audioFilename : videoFilename;
// Create new videoPlayer // Create new videoPlayer
v = calloc(1, sizeof(VideoPlayerT)); v = calloc(1, sizeof(VideoPlayerT));
if (!v) { if (!v) {
utilDie("Unable to allocate new video player."); utilDie("Unable to allocate new video player.");
} }
v->filename = strdup(videoFilename);
if (!v->filename) {
utilDie("Unable to allocate new video player.");
}
// Set some starting values (everything else is zero from calloc) // Set some starting values (everything else is zero from calloc)
v->currentAudioTrack = -1; v->currentAudioTrack = -1;
@ -1534,34 +1835,21 @@ int32_t videoLoad(const char *videoFilename, const char *audioFilename, const ch
utilDie("%s", SDL_GetError()); utilDie("%s", SDL_GetError());
} }
// Audio: a mixer track that never halts, fed from its own demuxer on the main thread. A silent
// video file is not demuxed a second time to find that out.
if ((audioFilename != NULL) || v->videoHasAudio) {
_loadAudio(v, audioFilename ? audioFilename : videoFilename);
}
if (v->audioSourceCount > 0) {
v->track = MIX_CreateTrack(_mixer);
if (!v->track) {
utilDie("%s", SDL_GetError());
}
_audioSelectTrack(v, 0);
if (!MIX_SetTrackRawCallback(v->track, _trackMixed, v)) {
utilDie("%s", SDL_GetError());
}
playProps = SDL_CreateProperties();
SDL_SetBooleanProperty(playProps, MIX_PROP_PLAY_HALT_WHEN_EXHAUSTED_BOOLEAN, false);
if (!MIX_PlayTrack(v->track, playProps)) {
utilDie("%s", SDL_GetError());
}
SDL_DestroyProperties(playProps);
// Paused until the video plays.
MIX_PauseTrack(v->track);
}
// Add to player hash // Add to player hash
v->id = _nextId++; v->id = _nextId++;
HASH_ADD_INT(_videoPlayerHash, id, v); HASH_ADD_INT(_videoPlayerHash, id, v);
// Audio: a mixer track that never halts, fed from its own demuxer on the main thread. A silent
// video file is not demuxed a second time to find that out.
if ((audioFilename != NULL) || v->videoHasAudio) {
if (!_audioOpen(name, &v->audioFormat, &v->audio, &v->audioSourceCount)) {
utilDie("Unable to open %s for audio.", name);
}
}
if (v->audioSourceCount > 0) {
_audioStartTrack(v, 0);
}
return v->id; return v->id;
} }
@ -1604,6 +1892,38 @@ void videoQuit(void) {
} }
// Replaces the audio side with another file (NULL: the video's own audio, or silence when it has
// none) and realigns it at the current frame, keeping the volume, the play state and the track
// index when the new file has it. False, with the old audio untouched, when the file cannot be read.
bool videoReopenAudio(int32_t playerHandle, const char *audioFilename) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoReopenAudio");
AVFormatContext *format = NULL;
AudioTrackT *tracks = NULL;
int32_t count = 0;
int32_t track = v->currentAudioTrack;
const char *name = audioFilename;
if ((name == NULL) && v->videoHasAudio) {
name = v->filename;
}
if ((name != NULL) && !_audioOpen(name, &format, &tracks, &count)) {
return false;
}
_audioClose(v);
v->audioFormat = format;
v->audio = tracks;
v->audioSourceCount = count;
if (count > 0) {
_audioStartTrack(v, ((track >= 0) && (track < count)) ? track : 0);
}
utilTrace("Video %d: audio reopened from %s (%d track(s))", v->id, name ? name : "nothing", count);
// Drop the queued audio from the old file and realign.
v->resetTime = true;
return true;
}
void videoSeek(int32_t playerHandle, int64_t seekFrame) { void videoSeek(int32_t playerHandle, int64_t seekFrame) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSeek"); VideoPlayerT *v = _getPlayer(playerHandle, "videoSeek");
int64_t count = v->frameCount; int64_t count = v->frameCount;
@ -1644,12 +1964,60 @@ void videoSetAudioTrack(int32_t playerHandle, int32_t track) {
} }
// Smooths the picture down its rows, the way Hypseus's blend filter does: each luma sample becomes
// the average of itself and the samples above and below it. A player showing colour for a script to
// read is left alone. The change lands on the next decoded frame.
void videoSetBlend(int32_t playerHandle, bool enabled) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetBlend");
if (v->rgb) {
return;
}
v->blend = enabled;
if (v->front.frame >= 0) {
_uploadFrame(v);
}
}
// Chosen before any video loads; existing players keep whatever they opened with. // Chosen before any video loads; existing players keep whatever they opened with.
void videoSetHardwareDecoding(bool enabled) { void videoSetHardwareDecoding(bool enabled) {
_hardwareDecoding = enabled; _hardwareDecoding = enabled;
} }
// Brightens or darkens the picture. The level runs 0 to LUMA_LEVEL_MAX with LUMA_LEVEL_NEUTRAL
// changing nothing, and the distance from neutral is applied to every luma sample in eighths, so 0
// halves the brightness and 8 raises it by half. Turning it off, or asking for the neutral level,
// puts the picture back.
void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetLuma");
if (v->rgb) {
return;
}
v->luma = enabled ? SDL_clamp(level, 0, LUMA_LEVEL_MAX) - LUMA_LEVEL_NEUTRAL : 0;
if (v->front.frame >= 0) {
_uploadFrame(v);
}
}
// Shows a YUV player in luma only. The frame on the texture is re-uploaded so a paused video
// changes at once. Pixel reads keep returning colour; rgb players are unaffected.
void videoSetMonochrome(int32_t playerHandle, bool enabled) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetMonochrome");
if (v->rgb || (enabled == v->monochrome)) {
return;
}
v->monochrome = enabled;
if (v->front.frame >= 0) {
_uploadFrame(v);
}
}
void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent) { void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoSetVolume"); VideoPlayerT *v = _getPlayer(playerHandle, "videoSetVolume");
MIX_StereoGains gains; MIX_StereoGains gains;
@ -1688,21 +2056,11 @@ void videoUnload(int32_t playerHandle) {
av_buffer_unref(&v->hwDevice); av_buffer_unref(&v->hwDevice);
_formatClose(&v->videoFormat); _formatClose(&v->videoFormat);
free(v->frames); free(v->frames);
free(v->filename);
free(v->neutralChroma);
free(v->lumaPlane);
SDL_DestroyTexture(v->videoTexture); SDL_DestroyTexture(v->videoTexture);
_audioClose(v);
if (v->audioSourceCount > 0) {
MIX_DestroyTrack(v->track);
SDL_DestroyAudioStream(v->audioStream);
_audioCloseTrack(v);
av_frame_free(&v->audioFrame);
av_packet_free(&v->audioPacket);
_formatClose(&v->audioFormat);
for (x = 0; x < v->audioSourceCount; x++) {
free(v->audio[x].language);
}
free(v->audio);
free(v->audioBuffer);
}
#pragma GCC diagnostic push #pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wcast-align" #pragma GCC diagnostic ignored "-Wcast-align"
@ -1753,9 +2111,10 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
} }
} }
// Hand the decoder the frame we want, and show whatever it has finished. // Hand the decoder the frame we want, and show whatever it has finished. A flash left on the
// texture comes off here even when nothing new was decoded, so it cannot stick on a still disc.
_requestFrame(v); _requestFrame(v);
if (_takeDecodedFrame(v)) { if (_takeDecodedFrame(v) || v->flashed) {
_uploadFrame(v); _uploadFrame(v);
} }
*texture = v->videoTexture; *texture = v->videoTexture;

View file

@ -33,23 +33,30 @@
#define VIDEO_VOLUME_MAX 100 #define VIDEO_VOLUME_MAX 100
#define VIDEO_AUDIO_DELAY_MAX 1000 // Milliseconds either way #define VIDEO_AUDIO_DELAY_MAX 1000 // Milliseconds either way
#define LUMA_LEVEL_MAX 8 // videoSetLuma takes 0 to 8 ...
#define LUMA_LEVEL_NEUTRAL 4 // ... where this level changes nothing, 0 halves the luma and 8 raises it by half
void videoFlash(int32_t playerHandle);
int32_t videoGetAudioCalibration(void); int32_t videoGetAudioCalibration(void);
int32_t videoGetAudioDelay(void); int32_t videoGetAudioDelay(void);
int32_t videoGetAudioLatency(void); int32_t videoGetAudioLatency(void);
int32_t videoGetAudioTrack(int32_t playerHandle); int32_t videoGetAudioTrack(int32_t playerHandle);
int32_t videoGetAudioTracks(int32_t playerHandle); int32_t videoGetAudioTracks(int32_t playerHandle);
double videoGetFps(int32_t playerHandle);
int64_t videoGetFrame(int32_t playerHandle); int64_t videoGetFrame(int32_t playerHandle);
int64_t videoGetFrameCount(int32_t playerHandle); int64_t videoGetFrameCount(int32_t playerHandle);
int32_t videoGetHeight(int32_t playerHandle); int32_t videoGetHeight(int32_t playerHandle);
const char *videoGetLanguage(int32_t playerHandle, int32_t audioTrack); const char *videoGetLanguage(int32_t playerHandle, int32_t audioTrack);
const char *videoGetLanguageDescription(const char *languageCode); const char *videoGetLanguageDescription(const char *languageCode);
MIX_Mixer *videoGetMixer(void); MIX_Mixer *videoGetMixer(void);
const char *videoGetDecoderDescription(void);
bool videoGetMonochrome(int32_t playerHandle);
bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b); bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b);
bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch); bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch);
void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent); void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent);
int32_t videoGetWidth(int32_t playerHandle); int32_t videoGetWidth(int32_t playerHandle);
bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma, uint8_t *cb, uint8_t *cr);
void videoInit(MIX_Mixer *mixer); void videoInit(MIX_Mixer *mixer);
bool videoIsPlaying(int32_t playerHandle); bool videoIsPlaying(int32_t playerHandle);
int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb); int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb);
@ -57,11 +64,15 @@ void videoLockAudio(void);
void videoPause(int32_t playerHandle); void videoPause(int32_t playerHandle);
void videoPlay(int32_t playerHandle); void videoPlay(int32_t playerHandle);
void videoQuit(void); void videoQuit(void);
bool videoReopenAudio(int32_t playerHandle, const char *audioFilename);
void videoSeek(int32_t playerHandle, int64_t seekFrame); void videoSeek(int32_t playerHandle, int64_t seekFrame);
void videoSetAudioCalibration(int32_t milliseconds); void videoSetAudioCalibration(int32_t milliseconds);
void videoSetAudioDelay(int32_t milliseconds); void videoSetAudioDelay(int32_t milliseconds);
void videoSetAudioTrack(int32_t playerHandle, int32_t track); void videoSetAudioTrack(int32_t playerHandle, int32_t track);
void videoSetBlend(int32_t playerHandle, bool enabled);
void videoSetHardwareDecoding(bool enabled); void videoSetHardwareDecoding(bool enabled);
void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level);
void videoSetMonochrome(int32_t playerHandle, bool enabled);
void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent); void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent);
void videoUnload(int32_t playerHandle); void videoUnload(int32_t playerHandle);
void videoUnlockAudio(void); void videoUnlockAudio(void);

View file

@ -60,3 +60,11 @@ screenshot or two and quits by itself; the results are in screenshots/.
| scene41.singe | 41 | 46.2 | GUI layers: scene41.rml over the disc with two overlapping opaque squares under filter: opacity(0.5) (the overlap must stay as light as the rest) beside the same pair under the opacity property (it darkens), a panel cut out by mask-image: image(maskFade.png), a group inside a group, and a box-shadow without blur that RmlUi renders through a layer saved as a texture; the script drops the group to opacity(0.25) between the two screenshots | | scene41.singe | 41 | 46.2 | GUI layers: scene41.rml over the disc with two overlapping opaque squares under filter: opacity(0.5) (the overlap must stay as light as the rest) beside the same pair under the opacity property (it darkens), a panel cut out by mask-image: image(maskFade.png), a group inside a group, and a box-shadow without blur that RmlUi renders through a layer saved as a texture; the script drops the group to opacity(0.25) between the two screenshots |
| scene42.singe | 42 | 46.3 | GUI filters: scene42.rml over the disc with a panel under filter: blur(6dp), text under drop-shadow, a row of squares through grayscale, sepia, invert, hue-rotate(90deg) and saturate(3) beside an unfiltered one, a panel with a soft box-shadow, and a panel under blur(40dp) (the downscaled path); guiGetStats() printed at each screenshot, and both blurs narrowed through RmlUi's Lua API between the two | | scene42.singe | 42 | 46.3 | GUI filters: scene42.rml over the disc with a panel under filter: blur(6dp), text under drop-shadow, a row of squares through grayscale, sepia, invert, hue-rotate(90deg) and saturate(3) beside an unfiltered one, a panel with a soft box-shadow, and a panel under blur(40dp) (the downscaled path); guiGetStats() printed at each screenshot, and both blurs narrowed through RmlUi's Lua API between the two |
| scene43.singe | 43 | 46.4 | GUI gradients: scene43.rml over the disc with a three-stop linear-gradient panel, a radial-gradient disc, a conic-gradient wheel (its starting angle turned through RmlUi's Lua API between the two screenshots), a repeating-linear-gradient stripe, a twenty-stop gradient that trips the sixteen-stop warning once, and the shipped theme's gradient panel, buttons and progress bar | | scene43.singe | 43 | 46.4 | GUI gradients: scene43.rml over the disc with a three-stop linear-gradient panel, a radial-gradient disc, a conic-gradient wheel (its starting angle turned through RmlUi's Lua API between the two screenshots), a repeating-linear-gradient stripe, a twenty-stop gradient that trips the sixteen-stop warning once, and the shipped theme's gradient panel, buttons and progress bar |
| scene44.singe | 44 | 47 | Hypseus extensions over the disc: strip.png (five 32x32 frames) through spriteLoadFrames and the three spriteDrawFrame forms side by side (an out of range frame draws frame 1, a still ignores the frame), the sheet as an ordinary animation under spriteSetFrame and spriteDraw, vldpFocusArea on the middle of the frame (frame 60) then off (80), vldpSetMonochrome and overlaySetMonochrome on (90) then off (110), and a status line with vldpGetScale, spriteFrameWidth/Height, vldpGetYUVPixel beside vldpGetPixel and controllerIsValid(0); screenshots at 30, 70, 100 and 120 |
| scene45.singe | 45 | 48.1 | Hypseus parity shims: setOverlaySize preset 1 (the disc, 720x480), 2 (half, 360x240), 4 with a size (640x400), 3 (360x240), 4 with another size (320x240), 4 with none (ignored, still 320x240) and an unrecognised 9 (half again), then setOverlayResolution as the alias of overlaySetResolution (480x320), each redrawn and shot at its new size; setOverlayLinearScale true (170) then false (190) over checker.png, which the window scales four times up; musicLoad/musicPlay/musicStop/musicIsPlaying/musicSetVolume/musicUnload over tone.ogg (a 440 Hz sine); and a status line with scoreBezelGetState (the score panel is left off here; scene47 drives it), rewriteStatus, ratioGetX/Y and musicIsPlaying; screenshots at 20, 40, 60, 80, 100, 120, 140, 160, 180, 200 and 245 |
| scene46.singe | 46 | 48.2 | The video rectangle over the disc inside a Sinden border (`-g '10 5'`): a frame around the very edge of the picture, magenta corner ticks and a cyan crosshair a quarter of the way in, stepped through vldpSetScale 50, 25 and 100 and vldpSetRotate 90, 180, 270 and 0 with a screenshot at each; run three times, plain and with `--shiftx -40 --shifty -40` and `--shiftx 40 --shifty 40`, for the shifted stops, which show only once the scale factor has left room to move in |
| scene47.singe | 47 | 48.3 | Bezels over the disc: cabinet artwork (testScripts/bezels/cabinet.png, 1280x800, with a deliberately off centre 800x500 cutout at 200,120 declared in cabinet.cfg and a yellow ring painted just outside it) with the picture landing exactly in the cutout, a GUI (scene47.rml) drawn on the artwork's left pillar with guiDrawScreen, and the score panel stepped through scoreBezelEnable, credits, two scores, two lives, setOverlayOnTop, scoreBezelClear and a value that lights it again; run five times, plain, with `--bezelflip` (the artwork's corner brackets then cover the picture, and setOverlayOnTop puts the overlay back over them), with `-g '10 5'` (the Sinden border goes to the window's edge), with `-g '10 5' --sindenedge video` (and back around the picture), and with no `--bezel` at all |
| scene48.singe | 48 | 48.4 | User options and the settings file over the disc: a status page of everything a script can see of the new configuration -- `getFValue()`, `ratioGetX()`/`ratioGetY()`, `soundGetVolume()`, `vldpGetScale()`, `controllerIsValid(0)`, `mouseHowMany()` and `singeWantsCrosshairs()` -- with a six colour ramp in the overlay beside the disc, which stays in colour while `--monochrome` greys the disc, and one `controllerDoRumble` so the trace records the step `--haptic` left of it; run plain, with a `settings.cfg` setting several options and the command line overriding one of them, and with `--monochrome`, `--startsilent`, `--altaudio`, `--idleexit`, `--nogamepad` and `--screen 1` |
| scene49.singe | 49 | 48.5 | Input over the disc: every device the engine can see on one page -- the four gamepad slots with the name, the six axis values and the fifteen button states of whatever is in each, the mice ManyMouse found with their names, the last mouse position and how many times `SWITCH_MOUSE_DISCONNECT` has fired, the keyboard mode, and the switches the mappings are currently letting through -- with `DEAD_ZONE`, `TRIGGER_THRESHOLD` (raw and as a per cent of full travel) and `joyMouseIsEnabled()` across the top. The phases are timed on the wall clock (shots at 2, 6, 10 and 16 seconds, quitting at 18) so a device can be attached, pressed and unplugged while it runs; the LD_PRELOAD harness in the scratchpad drives an SDL virtual joystick through exactly that. Run plain, with `--joymouse --js_range 20`, with `--gamepad_reorder 10`, with `--trigger_threshold 99.5`, with `--mapjoysticks=false`, with `--manymouse --absolutes_only`, with a `gamecontrollerdb.txt` beside it, and with a `controls.cfg` setting `DEAD_ZONES` |
| scene50.singe | 50 | 48.6 | Subtitles and the disc picture over the disc: `testScripts/subtitles.srt` (four cues, one of them two lines and one carrying a `<bracket>` and an `&` that must reach the screen as text) parsed into disc frames at the disc's own frame rate and shown through the shipped `Singe/subtitle.rml`, with `testScripts/broken.srt` refused (`srtLoad` answers false and loads nothing); the disc searched to a known frame before every shot so a cue is read off the disc's frame number and not the wall clock; `srtPosition` at 15 and 80 per cent; `overlayBanner` taking the cue's place for thirty drawn frames and then giving it back; `srtEnable` off and on; `srtClear`; `vldpSetLuma` at 0, 8, 4 and off; `vldpSetBlend` on, off and together with the luma; and `vldpFlash`, asked for and shot in the same tick because it lasts one drawn frame. 23 screenshots |
| scene51.singe | 51 | 48.6 | Sprites, in-memory assets and music over the disc: `spriteRotateFrame` turning frames 1 to 4 of `testScripts/arrows.png` (a four frame strip whose arrows have a white notch, so a rotation is readable) at 0, 45, 135 and 225 degrees, drawn by `spriteDrawRotatedFrame` plain, at 1.5 and at 2 by 0.75, beside the same frame through `spriteDrawFrame` which ignores both; `spriteScale` before a rotation, so the pair honours the sprite's own scale; an out of range frame falling back to frame 1; `spriteDrawGrid` taking three rectangles out of the same file loaded as one wide still and a fourth out of a sprite `spriteLoadData` built from the file's bytes; `spriteGetFrames` on all three; `controllerHowMany`; `setOverlayOpacity` at 96 and back to 255; and the music family over `testScripts/tune.mp3` (six seconds of 330 Hz, so the built-in mp3 decoder is exercised) -- play looping, `musicSetVolume` at 128 and 16, `soundSetVolume` at 8 with a `soundLoadData` sound played through it to show the two volumes do not move each other, pause, resume, a 250 ms fade out, a play once and an unload. 16 screenshots |

BIN
testScripts/arrows.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,2 @@
-- 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 }

BIN
testScripts/bezels/cabinet.png (Stored with Git LFS) Normal file

Binary file not shown.

3
testScripts/broken.srt Normal file
View file

@ -0,0 +1,3 @@
This file is not SubRip at all.
It has no timestamps and no cues,
so srtLoad must answer false.

BIN
testScripts/checker.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -453,4 +453,92 @@ GAMES = {
DEVELOPER = "Test", DEVELOPER = "Test",
PUBLISHER = "Test", PUBLISHER = "Test",
}, },
{
TITLE = "Hypseus Extensions",
SCRIPT = "testScripts/scene44.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Hypseus extensions: a sprite sheet drawn by frame in the three spriteDrawFrame forms, the disc focus area on and off, both monochrome modes on and off, vldpGetScale, vldpGetYUVPixel beside vldpGetPixel, and the controller checks.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Hypseus Parity",
SCRIPT = "testScripts/scene45.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Hypseus parity shims: every setOverlaySize preset and setOverlayResolution redrawn at the new size, setOverlayLinearScale on and off over a checkerboard, the music wrappers over a generated tone, and a status line with scoreBezelGetState (the panel stays off here) and the rewriteStatus and ratio stubs.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Video Rectangle",
SCRIPT = "testScripts/scene46.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "The video rectangle: a crosshair and an edge frame over the disc inside a Sinden border, stepped through vldpSetScale at 50 and 25 and vldpSetRotate at 90, 180 and 270; run again with --shiftx and --shifty, which move the picture in the room the scale left and so show nothing until it shrinks.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Bezels",
SCRIPT = "testScripts/scene47.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Bezels: cabinet artwork from testScripts/bezels with an off centre cutout the picture lands in, a GUI on the artwork outside the picture with guiDrawScreen, and the score panel stepped through credits, two scores, two lives, setOverlayOnTop, scoreBezelClear and back. Run with --bezeldir testScripts/bezels --bezel cabinet.png, again with --bezelflip, twice more with -g '10 5' and with --sindenedge video, and once with no bezel at all.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "User Options",
SCRIPT = "testScripts/scene48.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "User options and the settings file: getFValue, ratioGetX and ratioGetY beside soundGetVolume, vldpGetScale, controllerIsValid and mouseHowMany over the disc, with a colour ramp in the overlay that stays in colour while --monochrome greys the disc. Run plain, with a settings.cfg the command line then overrides, and with --monochrome, --startsilent, --altaudio, --idleexit, --nogamepad and --screen 1.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Input",
SCRIPT = "testScripts/scene49.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Input: every device the engine can see, with its kind, slot and identity, the live state of every axis and button of each, the dead zone and trigger threshold in force, the switches those are letting through, and whether a stick is driving the mouse. Timed on the wall clock so a device can be attached, pressed and taken away while it runs. Run plain, with --joymouse, --gamepad_reorder, --trigger_threshold, --mapjoysticks=false, --manymouse and --absolutes_only, and with a controls.cfg setting DEAD_ZONES.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Subtitles and the Disc Picture",
SCRIPT = "testScripts/scene50.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Subtitles, the banner and the disc picture controls: a .srt parsed into disc frames and shown over the picture through an RmlUi document, at two heights, with a malformed file refused; overlayBanner over a cue; srtClear; and vldpSetLuma, vldpSetBlend and vldpFlash on the picture itself. The disc is searched to a known frame before each shot, so every cue is read off the disc's own frame number.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
{
TITLE = "Sprites, Data and Music",
SCRIPT = "testScripts/scene51.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "Sprite sheet rotation (spriteRotateFrame and spriteDrawRotatedFrame, which unlike spriteDrawFrame honour a rotation and a scale), the source rectangle blit spriteDrawGrid, spriteGetFrames, controllerHowMany, setOverlayOpacity, the in-memory loaders spriteLoadData and soundLoadData, and the whole music family over an mp3, with music and sound effects proved to carry independent volumes.",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
} }

61
testScripts/scene44.singe Normal file
View file

@ -0,0 +1,61 @@
-- Hypseus extensions over the disc: a sprite sheet drawn by frame in the three spriteDrawFrame
-- forms, the disc focus area, both monochrome modes, the scale, YUV and RGB pixel reads and the
-- controller checks. discAudioSuffix needs a sidecar file and is exercised by hand. The
-- framework supplies keyboardCatchQuit, the Hypseus name for the quit key setting.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
local strip = spriteLoadFrames(5, "testScripts/strip.png")
local crate = spriteLoad("testScripts/crate.png")
local frames = 0
local width, height = overlayGetWidth(), overlayGetHeight()
local discW, discH = discGetWidth(), discGetHeight()
fontSelect(font)
discPlay()
spriteResetColorKey(strip, true) -- Accepted; nothing to re-key in Singe.
singeSetQuitKeyEnabled(false) -- The quit switch would reach the script now ...
singeSetQuitKeyEnabled(true) -- ... and quits the engine again.
keyboardCatchQuit(false) -- The Hypseus alias, same setting.
controllerDoRumble(1, 1) -- No controller headless: a silent no-op in both forms.
if controllerIsValid(0) then
controllerDoRumble(0, 2, 1)
end
function onOverlayUpdate()
frames = frames + 1
local frame = (math.floor(frames / 10) % 5) + 1
overlayClear()
-- The three forms side by side, an out of range frame (draws frame 1) and a still through the same call.
spriteDrawFrame(strip, 20, 40, frame)
spriteDrawFrame(strip, 70, 40, frame, 2)
spriteDrawFrame(strip, 150, 40, frame, 3, 1.5)
spriteDrawFrame(strip, 260, 40, 9)
spriteDrawFrame(crate, 300, 40, 4, 0.5)
-- spriteDraw shows the sheet as an ordinary animation parked on a frame, 0-based through spriteSetFrame.
spriteSetFrame(strip, 2)
spriteDraw(strip, 20, 100)
-- The effects, on and off again, with a screenshot of each state.
if frames == 60 then
vldpFocusArea(discW * 0.25, discH * 0.25, discW * 0.5, discH * 0.5)
elseif frames == 80 then
vldpFocusArea()
elseif frames == 90 then
vldpSetMonochrome(true)
overlaySetMonochrome(true)
elseif frames == 110 then
vldpSetMonochrome(false)
overlaySetMonochrome(false)
end
local y1, u1, v1 = vldpGetYUVPixel(width / 2, height / 2)
local r, g, b = vldpGetPixel(width / 2, height / 2)
fontPrint(10, 130, string.format("frame %d disc frame %d scale %d", frames, discGetFrame(), vldpGetScale()))
fontPrint(10, 155, string.format("yuv %d %d %d rgb %d %d %d pad0 %s", y1, u1, v1, r, g, b, tostring(controllerIsValid(0))))
fontPrint(10, 180, string.format("frame size %dx%d crate %dx%d", spriteFrameWidth(strip), spriteFrameHeight(strip), spriteFrameWidth(crate), spriteFrameHeight(crate)))
if frames == 30 or frames == 70 or frames == 100 or frames == 120 then
singeScreenshot()
end
if frames == 130 then
singeQuit()
end
return OVERLAY_UPDATED
end

85
testScripts/scene45.singe Normal file
View file

@ -0,0 +1,85 @@
-- Hypseus parity, stage 1: the framework's setOverlaySize presets and setOverlayResolution
-- (every call is followed by a redraw and a screenshot, since the resize empties the overlay),
-- the engine's setOverlayLinearScale over a checkerboard the window scales up, the music
-- wrappers over a generated tone, and a status line carrying scoreBezelGetState (false until a
-- game asks for the score panel, which scene47 does), rewriteStatus, ratioGetX, ratioGetY and
-- musicIsPlaying.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 12)
local checker = spriteLoad("testScripts/checker.png")
local tone = musicLoad("testScripts/tone.ogg")
local frames = 0
local note = "start"
fontSelect(font)
discPlay()
scoreBezelEnable(false, 0) -- Accepted with a type Singe ignores; the panel stays off, as Hypseus ships it.
scoreBezelCredits(3)
scoreBezelScore(1, 12345)
scoreBezelLives(1, 3)
scoreBezelTwinScoreOn(true)
musicSetVolume(soundGetVolume() * 2) -- The round trip every Hypseus game makes.
musicPlay(tone, -1)
function onOverlayUpdate()
frames = frames + 1
overlayClear()
overlayBox(1, 1, overlayGetWidth() - 2, overlayGetHeight() - 2)
spriteDraw(checker, 10, 10)
spriteDraw(checker, overlayGetWidth() - 74, 10)
fontPrint(10, 90, string.format("frame %d overlay %dx%d disc %dx%d", frames, overlayGetWidth(), overlayGetHeight(), discGetWidth(), discGetHeight()))
fontPrint(10, 108, string.format("bezel %s rewrite %s ratio %d %d music %s", tostring(scoreBezelGetState()), tostring(rewriteStatus()), ratioGetX(), ratioGetY(), tostring(musicIsPlaying())))
fontPrint(10, 126, note)
-- Each overlay size in turn, every one a visible change from the one before it.
if frames == 10 then
setOverlaySize(1) -- The disc's own size.
note = "setOverlaySize(1)"
elseif frames == 30 then
setOverlaySize(2) -- Half the disc, both axes.
note = "setOverlaySize(2)"
elseif frames == 50 then
setOverlaySize(4, 640, 400) -- The size given.
note = "setOverlaySize(4, 640, 400)"
elseif frames == 70 then
setOverlaySize(3) -- 360x240, whatever the disc is.
note = "setOverlaySize(3)"
elseif frames == 90 then
setOverlaySize(4, 320, 240)
note = "setOverlaySize(4, 320, 240)"
elseif frames == 110 then
setOverlaySize(4) -- Ignored without a size, as in Hypseus: still 320x240.
note = "setOverlaySize(4) ignored"
elseif frames == 130 then
setOverlaySize(9) -- Unrecognised: half the disc, as preset 2 does.
note = "setOverlaySize(9) is half"
elseif frames == 150 then
setOverlayResolution(480, 320) -- The alias of overlaySetResolution.
note = "setOverlayResolution(480, 320)"
elseif frames == 170 then
setOverlayLinearScale(true) -- The checkerboard softens as the window scales it.
note = "setOverlayLinearScale(true)"
elseif frames == 190 then
setOverlayLinearScale(false) -- ... and blocks up again.
note = "setOverlayLinearScale(false)"
elseif frames == 210 then
musicStop(tone)
note = string.format("musicStop(tone): playing %s", tostring(musicIsPlaying(tone)))
elseif frames == 220 then
musicPlay(tone) -- Once through, no loop count.
musicSetVolume(128) -- The top of Hypseus's own 0 to 128 scale.
note = string.format("musicPlay(tone): playing %s", tostring(musicIsPlaying(tone)))
elseif frames == 230 then
musicStop() -- Every piece of music, with no fade.
note = string.format("musicStop(): playing %s", tostring(musicIsPlaying()))
elseif frames == 240 then
musicUnload(tone)
note = "musicUnload(tone)"
end
if frames == 20 or frames == 40 or frames == 60 or frames == 80 or frames == 100 or frames == 120 or frames == 140 or frames == 160 or frames == 180 or frames == 200 or frames == 245 then
singeScreenshot()
end
if frames == 250 then
singeQuit()
end
return OVERLAY_UPDATED
end

76
testScripts/scene46.singe Normal file
View file

@ -0,0 +1,76 @@
-- The video rectangle: the picture shifted, scaled and turned inside a Sinden border, with a
-- crosshair at a fixed overlay coordinate that must stay on the same point of the picture. The
-- shift has no runtime setter, so it comes from --shiftx and --shifty and the script is run once
-- for each of the three shifts; the scale and the rotation step here.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 14)
local width, height = overlayGetWidth(), overlayGetHeight()
local crossX = math.floor(width / 4)
local crossY = math.floor(height / 4)
local frames = 0
local shot = 0
local steps = {
{ "as given", nil },
{ "scale 50", function() return vldpSetScale(50) end },
{ "scale 25", function() return vldpSetScale(25) end },
{ "scale 100", function() return vldpSetScale(100) end },
{ "rotate 90", function() return vldpSetRotate(90) end },
{ "rotate 180", function() return vldpSetRotate(180) end },
{ "rotate 270", function() return vldpSetRotate(270) end },
{ "rotate 0", function() return vldpSetRotate(0) end },
}
local label = steps[1][1]
local applied = true
local step = 1
local STEP_FRAMES = 20
local SHOT_DELAY = 10
fontSelect(font)
discPlay()
function onOverlayUpdate()
frames = frames + 1
overlayClear()
-- A frame around the very edge of the picture, so the rectangle's position and size are visible.
colorForeground(255, 255, 0)
overlayBox(0, 0, width - 1, height - 1)
overlayBox(3, 3, width - 4, height - 4)
-- Corner ticks tell a turned picture from a shifted one: the long arm is the top left corner.
colorForeground(255, 0, 255)
overlayLine(0, 0, 60, 0)
overlayLine(0, 0, 0, 40)
-- The crosshair sits a quarter of the way in and must stay on the same point of the picture.
colorForeground(0, 255, 255)
overlayCircle(crossX, crossY, 14)
overlayLine(crossX - 24, crossY, crossX + 24, crossY)
overlayLine(crossX, crossY - 24, crossX, crossY + 24)
colorForeground(255, 255, 255)
fontPrint(10, height - 46, string.format("%s scale %d rotate %d applied %s", label, vldpGetScale(), vldpGetRotate(), tostring(applied)))
fontPrint(10, height - 26, string.format("overlay %dx%d crosshair %d,%d disc frame %d", width, height, crossX, crossY, discGetFrame()))
-- One step every STEP_FRAMES frames, shot SHOT_DELAY frames later so the change is on screen.
if (frames % STEP_FRAMES) == 0 then
step = (frames / STEP_FRAMES) + 1
if steps[step] ~= nil then
label = steps[step][1]
if steps[step][2] ~= nil then
applied = steps[step][2]()
end
end
end
if ((frames % STEP_FRAMES) == SHOT_DELAY) and (shot < #steps) then
shot = shot + 1
singeScreenshot()
end
if shot >= #steps and (frames % STEP_FRAMES) == SHOT_DELAY + 2 then
singeQuit()
end
return OVERLAY_UPDATED
end

24
testScripts/scene47.rml Normal file
View file

@ -0,0 +1,24 @@
<rml>
<head>
<title>scene47</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { width: 100%; height: 100%; font-size: 15dp; }
.panel { width: 100%; height: 100%; box-sizing: border-box; padding: 10dp; text-align: center; }
h2 { font-size: 17dp; margin: 0 0 8dp 0; }
.line { display: block; margin: 0 0 8dp 0; word-break: break-word; }
.hot { color: #7ce07c; }
.small { font-size: 11dp; }
</style>
</head>
<body id="scene47">
<div class="panel">
<h2>ON THE<br/>ARTWORK</h2>
<div class="line">guiDrawScreen<br/>draws me on the<br/>artwork.</div>
<div class="line">step</div>
<div class="line hot" id="step">1</div>
<div class="line">bezel</div>
<div class="line hot small" id="bezel">no</div>
</div>
</body>
</rml>

110
testScripts/scene47.singe Normal file
View file

@ -0,0 +1,110 @@
-- Bezels: the cabinet artwork in testScripts/bezels (cabinet.png and its cabinet.cfg cutout), the
-- picture landing in the cutout, a GUI drawn on the artwork outside the picture with guiDrawScreen,
-- and the score panel behind the seven scoreBezel calls. A green frame runs along the very edge of
-- the picture, so the yellow ring painted just outside the cutout in the artwork must sit right
-- against it with no gap; magenta corner marks show what artwork drawn in front of the picture
-- (--bezelflip) covers, and setOverlayOnTop puts them back on top of it.
-- Run five times: plain, --bezelflip, with a Sinden border, with a Sinden border and
-- --sindenedge=video, and with no --bezel at all.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 12)
local width, height = overlayGetWidth(), overlayGetHeight()
local loaded, bezel = mainBezelLoaded()
local gui = guiNew(128, 384)
local document = guiLoad(gui, "testScripts/scene47.rml")
local frames = 0
local shot = 0
local note = "scoreBezelGetState " .. tostring(scoreBezelGetState())
local STEP_FRAMES = 25
local SHOT_DELAY = 14
-- Every step leaves the panel showing something different, and the last two prove that a cleared
-- panel comes back as soon as a value arrives.
local steps = {
{ "panel off", nil },
{ "panel on", function()
scoreBezelEnable(true, 0) -- The type is Hypseus's backend choice; Singe has one and ignores it.
scoreBezelCredits(5)
scoreBezelScore(1, 1234)
scoreBezelLives(1, 3)
scoreBezelScore(2, 999999) -- Ignored: the second score is not switched on yet.
note = "scoreBezelGetState " .. tostring(scoreBezelGetState())
end },
{ "twin on", function()
scoreBezelTwinScoreOn(true)
scoreBezelScore(2, 777)
scoreBezelLives(2, 2)
note = "second player on"
end },
{ "values", function()
scoreBezelCredits(12)
scoreBezelScore(1, 987654)
scoreBezelLives(1, 9)
scoreBezelScore(2, 42)
scoreBezelLives(2, 1)
note = "credits 12 1UP 987654 2UP 42"
end },
{ "overlay on top", function()
local ok, id = setOverlayOnTop(true)
note = string.format("setOverlayOnTop(true) -> %s %s", tostring(ok), id)
end },
{ "cleared", function()
setOverlayOnTop(false)
scoreBezelClear()
note = "scoreBezelClear()"
end },
{ "restored", function()
scoreBezelCredits(1)
note = "one credit brings the panel back"
end },
}
local label = steps[1][1]
fontSelect(font)
discPlay()
guiSetValue(gui, document, "bezel", loaded and bezel or "no")
function onOverlayUpdate()
frames = frames + 1
overlayClear()
-- The very edge of the picture: the artwork's yellow ring must be right against this.
colorForeground(0, 255, 0)
overlayBox(0, 0, width - 1, height - 1)
-- Corner marks inside the picture, where the artwork's brackets reach when it draws in front.
colorForeground(255, 0, 255)
overlayBox(4, 4, 30, 30)
overlayBox(width - 31, 4, width - 5, 30)
overlayBox(4, height - 31, 30, height - 5)
overlayBox(width - 31, height - 31, width - 5, height - 5)
colorForeground(255, 255, 255)
fontPrint(40, 20, string.format("scene47 %s", label))
fontPrint(40, 38, string.format("bezel %s id %s", tostring(loaded), bezel))
fontPrint(40, 56, note)
guiDrawScreen(gui, 8, 44, 38, 114)
if (frames % STEP_FRAMES) == 0 then
local step = (frames / STEP_FRAMES) + 1
if steps[step] ~= nil then
label = steps[step][1]
if steps[step][2] ~= nil then
steps[step][2]()
end
guiSetValue(gui, document, "step", string.format("%d", step))
end
end
if ((frames % STEP_FRAMES) == SHOT_DELAY) and (shot < #steps) then
shot = shot + 1
singeScreenshot()
end
if (shot >= #steps) and ((frames % STEP_FRAMES) == (SHOT_DELAY + 2)) then
singeQuit()
end
return OVERLAY_UPDATED
end

71
testScripts/scene48.singe Normal file
View file

@ -0,0 +1,71 @@
-- User options and the settings file: everything a script can see of the new configuration.
-- getFValue, ratioGetX and ratioGetY come straight from --fvalue, --xratio and --yratio (or from
-- settings.cfg), soundGetVolume follows --volume_nonvldp, controllerIsValid(0) and mouseHowMany
-- follow --nogamepad and --nomouse, and controllerDoRumble is called once so the trace records the
-- step --haptic left of it. The disc fills most of the picture, so --monochrome shows in a
-- screenshot; --startsilent, --altaudio, --idleexit and --screen are checked from the trace.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 12)
local width, height = overlayGetWidth(), overlayGetHeight()
local frames = 0
local shot = 0
local STEP_FRAMES = 30
local SHOT_DELAY = 18
local SHOTS = 3
local SWATCHES = 6
local SWATCH_HEIGHT = 15
-- A colour ramp in the overlay beside the disc: the overlay keeps its colour under --monochrome,
-- which greys the disc alone, so the two together show exactly what the option did.
local swatch = {
{ 255, 0, 0 },
{ 0, 255, 0 },
{ 0, 0, 255 },
{ 255, 255, 0 },
{ 0, 255, 255 },
{ 255, 0, 255 },
}
fontSelect(font)
discPlay()
-- Once, so --haptic shows in the trace whether a pad is plugged in or not.
controllerDoRumble(0, 4, 1)
function onOverlayUpdate()
local x = 0
local y = 0
frames = frames + 1
overlayClear()
colorForeground(0, 255, 0)
overlayBox(0, 0, width - 1, height - 1)
for x = 1, SWATCHES do
colorForeground(swatch[x][1], swatch[x][2], swatch[x][3])
for y = 0, SWATCH_HEIGHT do
overlayLine(width - 30, 8 + (x - 1) * 18 + y, width - 8, 8 + (x - 1) * 18 + y)
end
end
colorForeground(255, 255, 255)
fontPrint(20, 20, "scene48 user options")
fontPrint(20, 38, string.format("getFValue %s", tostring(getFValue())))
fontPrint(20, 56, string.format("ratioGetX %s ratioGetY %s", tostring(ratioGetX()), tostring(ratioGetY())))
fontPrint(20, 74, string.format("soundGetVolume %d vldpGetScale %d", soundGetVolume(), vldpGetScale()))
fontPrint(20, 92, string.format("controllerIsValid(0) %s mouseHowMany %d", tostring(controllerIsValid(0)), mouseHowMany()))
fontPrint(20, 110, string.format("singeWantsCrosshairs %s", tostring(singeWantsCrosshairs())))
if ((frames % STEP_FRAMES) == SHOT_DELAY) and (shot < SHOTS) then
shot = shot + 1
singeScreenshot()
end
if (shot >= SHOTS) and ((frames % STEP_FRAMES) == (SHOT_DELAY + 2)) then
singeQuit()
end
return OVERLAY_UPDATED
end

164
testScripts/scene49.singe Normal file
View file

@ -0,0 +1,164 @@
-- Input: every device the engine can see, its kind, slot and identity, the live state of every
-- axis and button of each, the dead zone and trigger threshold in force, and whether the stick is
-- driving the mouse. Every device Singe opens is a gamepad, an unrecognised one through a mapping
-- Singe writes for it, so there is one family to report and not two.
--
-- The phases are timed on the wall clock so a test harness can attach a device, press something on
-- it and take it away again while the scene is running.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 11)
local width, height = 0, 0
local shot = 0
local started = os.clock()
local mouseX = -1
local mouseY = -1
local mouseMoves = 0
local disconnects = 0
local LINE = 14
local AXIS_FULL = 32767 -- What SDL reports at full deflection, and what DEAD_ZONE counts in
local PANEL_HEIGHT = 350
local SHOT_TIMES = { 2, 6, 10, 16 }
local QUIT_TIME = 18
local PADS = { GAMEPAD_0, GAMEPAD_1, GAMEPAD_2, GAMEPAD_3 }
local AXES = 6
local BUTTONS = {
"BUTTON_A", "BUTTON_B", "BUTTON_X", "BUTTON_Y", "BUTTON_BACK", "BUTTON_GUIDE", "BUTTON_START",
"BUTTON_LEFT_STICK", "BUTTON_RIGHT_STICK", "BUTTON_LEFT_BUMPER", "BUTTON_RIGHT_BUMPER",
"DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT"
}
-- Every mapped switch by name, so what a dead zone let through can be read off the screen.
local SWITCHES = {
[SWITCH_UP] = "UP", [SWITCH_LEFT] = "LEFT", [SWITCH_DOWN] = "DOWN", [SWITCH_RIGHT] = "RIGHT",
[SWITCH_START1] = "START1", [SWITCH_START2] = "START2", [SWITCH_BUTTON1] = "BUTTON1",
[SWITCH_BUTTON2] = "BUTTON2", [SWITCH_BUTTON3] = "BUTTON3", [SWITCH_BUTTON4] = "BUTTON4",
[SWITCH_COIN1] = "COIN1", [SWITCH_COIN2] = "COIN2", [SWITCH_SKILL1] = "SKILL1",
[SWITCH_SKILL2] = "SKILL2", [SWITCH_SKILL3] = "SKILL3", [SWITCH_SERVICE] = "SERVICE",
[SWITCH_TEST] = "TEST", [SWITCH_RESET] = "RESET", [SWITCH_SCREENSHOT] = "SCREENSHOT",
[SWITCH_QUIT] = "QUIT", [SWITCH_PAUSE] = "PAUSE", [SWITCH_CONSOLE] = "CONSOLE",
[SWITCH_TILT] = "TILT", [SWITCH_GRAB] = "GRAB", [SWITCH_MOUSE_DISCONNECT] = "MOUSE_DISCONNECT"
}
local held = {}
overlaySetResolution(720, 480)
width, height = overlayGetWidth(), overlayGetHeight()
fontSelect(font)
discPlay()
-- Every button of one pad as a string of 0s and 1s, in SDL's own button order.
function buttonBits(slot)
local bits = ""
local b = 0
for b = 1, #BUTTONS do
bits = bits .. (controllerGetButton(slot, PADS[slot + 1][BUTTONS[b]].value) and "1" or "0")
end
return bits
end
function onMouseMoved(x, y, xr, yr, mouse)
mouseX = x
mouseY = y
mouseMoves = mouseMoves + 1
end
function onInputPressed(switch)
held[switch] = true
if switch == SWITCH_MOUSE_DISCONNECT then
disconnects = disconnects + 1
end
end
function onInputReleased(switch)
held[switch] = nil
end
function onOverlayUpdate()
local elapsed = os.clock() - started
local line = 0
local slot = 0
local axis = 0
local text = ""
overlayClear()
-- A dark panel behind the text, so every line reads over whatever the disc is showing.
colorForeground(0, 0, 40)
for line = 0, PANEL_HEIGHT do
overlayLine(4, 4 + line, width - 5, 4 + line)
end
colorForeground(0, 255, 0)
overlayBox(4, 4, width - 5, 4 + PANEL_HEIGHT)
colorForeground(255, 255, 255)
line = 16
fontPrint(12, line, string.format("scene49 input t=%.1f", elapsed))
line = line + LINE * 2
fontPrint(12, line, string.format("DEAD_ZONE %d TRIGGER_THRESHOLD %d raw (%.1f%% of full travel) joyMouseIsEnabled %s",
SINGE_DEAD_ZONE, SINGE_TRIGGER_THRESHOLD, SINGE_TRIGGER_THRESHOLD * 100.0 / AXIS_FULL, tostring(joyMouseIsEnabled())))
line = line + LINE * 2
-- Gamepads. Every device Singe opens is one of these, whatever it started life as.
for slot = 0, SINGE_MAX_CONTROLLERS - 1 do
if controllerIsValid(slot) then
fontPrint(12, line, string.format("gamepad slot %d \"%s\"", slot, tostring(controllerGetName(slot))))
line = line + LINE
text = ""
for axis = 0, AXES - 1 do
text = text .. string.format(" a%d=%-7d", axis, controllerGetAxis(slot, axis))
end
fontPrint(28, line, "axes " .. text)
line = line + LINE
fontPrint(28, line, "buttons " .. buttonBits(slot) .. " (A B X Y Back Guide Start LS RS LB RB DpU DpD DpL DpR)")
line = line + LINE
else
fontPrint(12, line, string.format("gamepad slot %d empty", slot))
line = line + LINE
end
end
line = line + LINE
-- Mice, which are their own family: ManyMouse names them, and mouseHowMany counts them.
fontPrint(12, line, string.format("mice %d", mouseHowMany()))
line = line + LINE
for slot = 0, SINGE_MAX_MICE - 1 do
if slot < mouseHowMany() then
fontPrint(28, line, string.format("mouse %d \"%s\"", slot, tostring(mouseGetName(slot))))
line = line + LINE
end
end
fontPrint(28, line, string.format("last position %d,%d after %d movements", mouseX, mouseY, mouseMoves))
line = line + LINE
fontPrint(28, line, string.format("SWITCH_MOUSE_DISCONNECT seen %d times", disconnects))
line = line + LINE * 2
fontPrint(12, line, string.format("keyboard mode %d last down %d", keyboardGetMode(), keyboardGetLastDown()))
line = line + LINE
text = ""
for slot = 0, #SWITCHES do
if held[slot] then
text = text .. " " .. SWITCHES[slot]
end
end
fontPrint(12, line, "switches held " .. ((text == "") and "(none)" or text))
if (shot < #SHOT_TIMES) and (elapsed >= SHOT_TIMES[shot + 1]) then
shot = shot + 1
singeScreenshot()
end
if elapsed >= QUIT_TIME then
singeQuit()
end
return OVERLAY_UPDATED
end

77
testScripts/scene50.singe Normal file
View file

@ -0,0 +1,77 @@
-- Hypseus parity, stage 6: subtitles, the banner and the disc picture controls. The disc is
-- searched to a known frame before each subtitle shot, so a cue's timing is read off the disc's
-- own frame number and not off the wall clock; the .srt's timestamps become disc frames at the
-- disc's frame rate when it is loaded.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 14)
local frames = 0
local step = 0
local note = "start"
local broken = srtLoad("testScripts/broken.srt") -- No cues in it: false, and nothing loaded.
local good = srtLoad("testScripts/subtitles.srt") -- Four cues over the twelve second disc.
-- Each step: a name, what to do when it is reached, and the frame to leave the disc on.
local steps = {
{ "cue 1 (frames 0 to 45)", 15, function() srtEnable(true) end },
{ "cue 2, two lines", 90, function() end },
{ "cue 2 at srtPosition(15)", 90, function() srtPosition(15) end },
{ "cue 2 at srtPosition(80)", 90, function() srtPosition(80) end },
{ "cue 3, escaped < and &", 210, function() end },
{ "the gap: no cue", 150, function() end },
{ "cue 4, the last", 300, function() end },
{ "overlayBanner over the cue", 300, function() overlayBanner("A banner, sixty characters or fewer, over the picture.", 30) end },
{ "the banner has timed out", 300, function() end },
{ "srtEnable(false)", 300, function() srtEnable(false) end },
{ "srtEnable(true) again", 300, function() srtEnable(true) end },
{ "srtClear()", 300, function() srtClear() end },
{ "vldpSetLuma(true, 0): half", 300, function() srtEnable(false) vldpSetLuma(true, 0) end },
{ "vldpSetLuma(true, 8): brighter", 300, function() vldpSetLuma(true, 8) end },
{ "vldpSetLuma(true, 4): neutral", 300, function() vldpSetLuma(true, 4) end },
{ "vldpSetLuma(false)", 300, function() vldpSetLuma(false) end },
{ "vldpSetBlend(true)", 300, function() vldpSetBlend(true) end },
{ "vldpSetBlend(false)", 300, function() vldpSetBlend(false) end },
{ "vldpSetLuma(true, 1) and blend", 300, function() vldpSetBlend(true) vldpSetLuma(true, 1) end },
{ "back to the plain picture", 300, function() vldpSetBlend(false) vldpSetLuma(false) end },
-- The blend is a one row smoothing, so it is easiest to see magnified: the same sliver of the
-- picture through vldpFocusArea, without it and then with it.
{ "a magnified sliver, no blend", 300, function() vldpFocusArea(discGetWidth() * 0.42, discGetHeight() * 0.42, 96, 64) end },
{ "the same sliver, blended", 300, function() vldpSetBlend(true) end },
{ "the sliver, blend off again", 300, function() vldpSetBlend(false) end },
{ "vldpResetFocus()", 300, function() vldpResetFocus() end },
}
fontSelect(font)
discSearch(1)
function onOverlayUpdate()
frames = frames + 1
if frames == ((#steps + 1) * 20) + 1 then
note = "vldpFlash()"
vldpFlash()
singeScreenshot()
end
overlayClear()
overlayBox(1, 1, overlayGetWidth() - 2, overlayGetHeight() - 2)
fontPrint(8, 6, string.format("srtLoad broken=%s good=%s disc frame %d", tostring(broken), tostring(good), discGetFrame()))
fontPrint(8, 24, string.format("step %d/%d %s", step, #steps, note))
-- One step every twenty script frames: act on the first, shoot on the tenth, so a banner
-- (thirty drawn frames) is up for one shot and gone by the next.
if (frames % 20) == 1 and step < #steps then
step = step + 1
note = steps[step][1]
discSearch(steps[step][2])
steps[step][3]()
elseif (frames % 20) == 11 then
singeScreenshot()
end
-- The flash lasts one drawn frame, so it is asked for and shot in the same tick. The note is
-- set before the text is drawn, so the shot names what it is showing.
if frames == ((#steps + 1) * 20) + 3 then
note = "the frame after vldpFlash()"
singeScreenshot()
end
if frames == ((#steps + 1) * 20) + 20 then
singeQuit()
end
return OVERLAY_UPDATED
end

71
testScripts/scene51.singe Normal file
View file

@ -0,0 +1,71 @@
-- Hypseus parity, stage 6: the sprite sheet rotation pair, the source rectangle blit, the frame
-- count getter, the two in-memory loaders, the overlay opacity, controllerHowMany and the music
-- family with its own gain, over an mp3 so the built-in decoder is exercised.
dofile("Singe/Framework.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 14)
local arrows = spriteLoadFrames(4, "testScripts/arrows.png")
local sheet = spriteLoad("testScripts/arrows.png") -- The same file as one wide still.
local bytes = io.open("testScripts/arrows.png", "rb"):read("a")
local copied = spriteLoadData(bytes) -- The same image, from a Lua string.
local wave = io.open("testScripts/tone.ogg", "rb"):read("a")
local effect = soundLoadData(wave) -- A sound from a Lua string.
local tune = musicLoad("testScripts/tune.mp3") -- 6 seconds of 330 Hz, mp3.
local frames = 0
local step = 0
local note = "start"
local said = ""
local steps = {
{ "spriteRotateFrame(arrows, 0, 1)", function() spriteRotateFrame(arrows, 0, 1) end },
{ "spriteRotateFrame(arrows, 45, 2)", function() spriteRotateFrame(arrows, 45, 2) end },
{ "spriteRotateFrame(arrows, 135, 3)", function() spriteRotateFrame(arrows, 135, 3) end },
{ "spriteScale(arrows, 2) then 225, 4", function() spriteScale(arrows, 2) spriteRotateFrame(arrows, 225, 4) end },
{ "out of range frame draws frame 1", function() spriteScale(arrows, 1) spriteRotateFrame(arrows, 90, 99) end },
{ "setOverlayOpacity(96)", function() setOverlayOpacity(96) end },
{ "setOverlayOpacity(255)", function() setOverlayOpacity(255) end },
{ "musicPlay(tune, -1) at volume 128", function() said = "musicSetVolume returned " .. musicSetVolume(128) musicPlay(tune, -1) end },
{ "musicSetVolume(16), sound unmoved", function() said = "musicSetVolume returned " .. musicSetVolume(16) end },
{ "soundSetVolume(8), music unmoved", function() soundSetVolume(8) soundPlay(effect) end },
{ "musicPause()", function() musicPause() end },
{ "musicResume()", function() musicResume() end },
{ "musicStop(tune, 250) with a fade", function() musicStop(tune, 250) end },
{ "musicPlay(tune) once through", function() musicPlay(tune) end },
{ "musicStop() and musicUnload(tune)", function() musicStop() musicUnload(tune) end },
}
fontSelect(font)
overlaySetResolution(640, 480) -- Room for the rotated frames at twice their size.
discPlay()
soundSetVolume(40)
spriteRotateFrame(arrows, 0, 1) -- There is a frame to draw before the first step runs.
function onOverlayUpdate()
frames = frames + 1
overlayClear()
overlayBox(1, 1, overlayGetWidth() - 2, overlayGetHeight() - 2)
-- The rotated frame, centred on its point, beside the same frame drawn flat by spriteDrawFrame.
spriteDrawRotatedFrame(arrows, 90, 200)
spriteDrawRotatedFrame(arrows, 240, 200, 1.5)
spriteDrawRotatedFrame(arrows, 400, 200, 2, 0.75)
spriteDrawFrame(arrows, 520, 176, 1)
-- spriteDrawGrid takes a rectangle out of the wide sheet: frames 3 and 2, and a quarter of one.
spriteDrawGrid(sheet, 40, 330, 96, 0, 48, 48)
spriteDrawGrid(sheet, 120, 330, 48, 0, 48, 48)
spriteDrawGrid(sheet, 200, 330, 12, 12, 24, 24)
-- The sprite built from bytes draws exactly as the one built from the file.
spriteDrawGrid(copied, 280, 330, 0, 0, 48, 48)
fontPrint(8, 6, string.format("frames sheet=%d still=%d data=%d controllers %d", spriteGetFrames(arrows), spriteGetFrames(sheet), spriteGetFrames(copied), controllerHowMany()))
fontPrint(8, 24, string.format("sound %d music playing %s %s", soundGetVolume(), tostring(musicIsPlaying()), said))
fontPrint(8, 42, string.format("step %d/%d %s", step, #steps, note))
if (frames % 20) == 1 and step < #steps then
step = step + 1
note = steps[step][1]
steps[step][2]()
elseif (frames % 20) == 11 then
singeScreenshot()
end
if frames == ((#steps + 1) * 20) then
singeQuit()
end
return OVERLAY_UPDATED
end

BIN
testScripts/strip.png (Stored with Git LFS) Normal file

Binary file not shown.

17
testScripts/subtitles.srt Normal file
View file

@ -0,0 +1,17 @@
1
00:00:00,000 --> 00:00:01,500
The first cue, at the start of the disc.
2
00:00:02,000 --> 00:00:04,000
A second cue with two lines,
so the parser has to join them.
3
00:00:06,000 --> 00:00:08,000
A third cue after a gap,
with a <bracket> & an ampersand.
4
00:00:09,500 --> 00:00:11,500
The fourth and last cue.

BIN
testScripts/tone.ogg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
testScripts/tune.mp3 (Stored with Git LFS) Normal file

Binary file not shown.