From d024a50c8df22a3ba043e81e26b0cb3670772dd4 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Wed, 9 Sep 2026 15:55:26 -0500 Subject: [PATCH] Added Hypseus extensions. --- CHANGELOG | 187 ++ CMakeLists.txt | 7 + assets/Framework.singe | 119 + assets/controls.cfg | 20 + assets/scoreBezel.rcss | 96 + assets/scoreBezel.rml | 34 + assets/settings.cfg | 124 + assets/subtitle.rcss | 44 + assets/subtitle.rml | 19 + docs/Manual.adoc | 2447 +++++++++++++++++++- src/embedded.h | 5 + src/frameFile.c | 74 +- src/frameFile.h | 2 + src/main.c | 1433 +++++++++--- src/main.h | 16 +- src/scene.c | 6 +- src/singe.c | 3908 +++++++++++++++++++++++++++++--- src/singe.h | 69 +- src/videoPlayer.c | 537 ++++- src/videoPlayer.h | 11 + testScripts/README.md | 8 + testScripts/arrows.png | 3 + testScripts/bezels/cabinet.cfg | 2 + testScripts/bezels/cabinet.png | 3 + testScripts/broken.srt | 3 + testScripts/checker.png | 3 + testScripts/games.dat | 88 + testScripts/scene44.singe | 61 + testScripts/scene45.singe | 85 + testScripts/scene46.singe | 76 + testScripts/scene47.rml | 24 + testScripts/scene47.singe | 110 + testScripts/scene48.singe | 71 + testScripts/scene49.singe | 164 ++ testScripts/scene50.singe | 77 + testScripts/scene51.singe | 71 + testScripts/strip.png | 3 + testScripts/subtitles.srt | 17 + testScripts/tone.ogg | 3 + testScripts/tune.mp3 | 3 + 40 files changed, 9274 insertions(+), 759 deletions(-) create mode 100644 assets/scoreBezel.rcss create mode 100644 assets/scoreBezel.rml create mode 100644 assets/settings.cfg create mode 100644 assets/subtitle.rcss create mode 100644 assets/subtitle.rml create mode 100644 testScripts/arrows.png create mode 100644 testScripts/bezels/cabinet.cfg create mode 100644 testScripts/bezels/cabinet.png create mode 100644 testScripts/broken.srt create mode 100644 testScripts/checker.png create mode 100644 testScripts/scene44.singe create mode 100644 testScripts/scene45.singe create mode 100644 testScripts/scene46.singe create mode 100644 testScripts/scene47.rml create mode 100644 testScripts/scene47.singe create mode 100644 testScripts/scene48.singe create mode 100644 testScripts/scene49.singe create mode 100644 testScripts/scene50.singe create mode 100644 testScripts/scene51.singe create mode 100644 testScripts/strip.png create mode 100644 testScripts/subtitles.srt create mode 100644 testScripts/tone.ogg create mode 100644 testScripts/tune.mp3 diff --git a/CHANGELOG b/CHANGELOG index e5b23a110..6ea543d95 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,179 @@ API Changes - Framework.singe applies the legacy sprite argument order once even when a game loads it more than once. +- Hypseus Singe extensions, so games written for it run unchanged: + spriteLoadFrames, spriteDrawFrame, spriteFrameWidth, spriteFrameHeight, + spriteResetColorKey, discAudioSuffix, singeSetQuitKeyEnabled (with the + Framework alias keyboardCatchQuit), vldpGetScale, vldpFocusArea, + vldpResetFocus, vldpSetMonochrome, overlaySetMonochrome, + vldpGetYUVPixel, controllerIsValid and controllerDoRumble. Sprite calls + take the handle first like the rest of 3.00; the LEGACY_SPRITE_ARGS + shim covers spriteDrawFrame and spriteResetColorKey. + +- More Hypseus Singe names, almost all of them supplied by + Framework.singe over calls Singe already had: setOverlaySize and + setOverlayResolution over overlaySetResolution, rewriteStatus as a stub, + and the six music calls over the sound family. setOverlayLinearScale is + a real engine call: it smooths the overlay as the window scales it, + which is the default and what Singe has always done, or takes the + nearest pixel for crisp pixel art. + +- The picture's place on screen is now one rectangle built from four + things in order: the window, --scalefactor (range widened from 50-100 + to 25-100), --shiftx and --shifty (new; they move the picture inside the + room the scale left, -100 hard against one side and 100 against the + other, which is what Hypseus's own mean -- so at full scale there is no + room and a shift does nothing), and last the Sinden border, which is + therefore still a border whatever the shift and the scale did -- + --scalefactor and --sindengun may now be used together. --rotate (new; + 0, 90, 180 or 270 degrees) + turns the whole presentation, video, overlay, GUIs, 3D scene and + particles together, filling a portrait window on a quarter turn. Mouse + and light gun positions follow all of it, so a click lands where it is + drawn. Scripts move the scale and the rotation while the game runs with + the Hypseus extensions vldpSetScale, vldpSetRotate and vldpGetRotate. + +- Bezels. --bezel names cabinet artwork in a bezels folder (--bezeldir + names another folder, --bezelflip draws the artwork in front of the + picture instead of behind it), and a Lua sidecar named after the + artwork declares the cutout the picture is drawn in, in the artwork's + own pixels: CUTOUT = { x = 200, y = 120, width = 800, height = 500 }. + With no cutout the picture keeps the window and --scalefactor, + --shiftx and --shifty place it, as before. A Sinden border goes to the + edge of the window when artwork is loaded, because the gun's camera + sees the whole screen; --sindenedge video|window overrides that. + Scripts ask mainBezelLoaded() which artwork they got and choose with + setOverlayOnTop whether the overlay sits above it. + +- The arcade score panel behind the seven scoreBezel calls: credits, and + a score and a life count for two players, in a shipped RmlUi document + (Singe/scoreBezel.rml and Singe/scoreBezel.rcss) with a seven segment + look that a theme or a game may restyle. It is off until a game asks + for it with scoreBezelEnable, exactly as in Hypseus, and it sits in the + room a bezel's cutout leaves above or below the picture, or along the + bottom of the picture when there is none. Hypseus's second argument, + which chooses among its five scoreboards, is accepted and ignored. + guiDrawScreen puts a game's own GUIs out there beside it: it is + guiDraw with the rectangle measured across the whole window. + +- A settings file. settings.cfg holds the options you would otherwise + type on every command line, in Lua like controls.cfg and found the same + four ways: the directory Singe was started in, above and inside the data + directory, and beside the game's script, with a later file winning key by + key. Its keys are the long option names without the dashes, so there is + no second vocabulary, and a switch takes true or false. A settings file + is a set of defaults: a games.dat entry for the game still overrides it + and the command line always does. Anything naming the game, naming a + directory, or acting before the file could be found (--framefile, + --gamedir, --datadir, --entry, --help, --apiversion, --noconsole, the + packing tools) belongs on the command line, and a key that is not an + option, is one of those, or carries a value the option will not take is + an error naming the file and the key. Singe/settings.cfg.example lists + every key with its default. + +- Options a cabinet owner has been missing, none of them with a short + letter since the alphabet ran out: --screen picks the display (counting + from 1; a number too large lists the displays that are there), + --idleexit quits after so many seconds with nothing touched, + --startsilent starts muted until the first input, --monochrome starts + the disc picture in grey, --linearscale chooses the overlay's filter, + --nogamepad ignores the pads as --nomouse ignores the mice, --haptic + caps the rumble a game may ask for (0 turns it off), --keymapfile names + one controls.cfg instead of searching four places, --altaudio (and the + AUDIO_SUFFIX games.dat key) plays .ogg beside the disc's + video for another language, --xratio and --yratio carry the gun + coordinate scales a Hypseus light gun game reads with ratioGetX and + ratioGetY, --fvalue carries one number to the game through getFValue, + and --apiversion prints one machine readable line for a front end and + exits. --nosound, which had been accepted and quietly ignored, now + actually mutes. + +- Every controller Singe opens is now a gamepad, whatever it started life + as. SDL calls a device a gamepad only when it has a mapping for it, and + an arcade encoder board, a spinner, a wheel or a flight stick usually has + none, so such a device used to be invisible. Singe now writes a + straight-through mapping for it -- axes in order to the two sticks and + then the triggers, buttons in order to the standard names, the first hat + to the D-pad -- and opens it as an ordinary pad, so it reaches + controls.cfg, GAMEPAD_0 to GAMEPAD_3 and every controller call like any + other. A device with more controls than the gamepad model can name loses + the extras, and one whose buttons are not wired in that order will have + them in the wrong places: --program traces the mapping in SDL's own + format so it can be pasted into a gamecontrollerdb.txt and rearranged. + Singe now reads that file, from the four places it reads controls.cfg, + and it and SDL's own database both win over the mapping Singe would + write. --mapjoysticks=false turns the writing off. + +- Input options for a cabinet: --gamepad_reorder says which physical pad + fills which slot (enumeration positions from 0, as Hypseus writes them), + --trigger_threshold says how far an analogue trigger must travel before + it counts as a button (a per cent of full travel, Hypseus's unit), + --joymouse lets the first pad's left stick drive the mouse cursor at + --js_range speed so a light gun game is playable on a stick-only + cabinet, --manymouse turns the many mouse mode on whatever the game asks + for so a two gun cabinet can be fixed from outside, and + --absolutes_only keeps only the devices that report an absolute + position, which is what a real gun does. controls.cfg gains DEAD_ZONES, + a dead zone for one axis at a time, and TRIGGER_THRESHOLD; both leave + every axis on DEAD_ZONE unless set, so nothing changes for anyone who + does not ask. + +- SWITCH_MOUSE_DISCONNECT reaches onInputPressed when a mouse or light gun + is unplugged, so a gun game can say so instead of appearing to have + died, and joyMouseEnable, joyMouseIsEnabled, controllerGetName and + mouseGetName let a script drive and describe all of it. + +- Alt-Enter switches between full screen and a window while the game runs, + as it does in Hypseus. Singe could not change the mode at all once it + had started. + +- Subtitles. srtLoad reads a SubRip .srt and turns its timestamps into + disc frames at the disc's own frame rate, so a line is found again after + any search or skip; srtEnable shows them, srtPosition puts them where a + game wants (1 to 95 per cent down the picture) and srtClear takes one + off early. They are drawn through a shipped RmlUi document, + Singe/subtitle.rml with Singe/subtitle.rcss, both of which a game or a + theme may replace, over the picture and following it wherever the scale, + a shift, a bezel cutout or a rotation put it. A malformed file answers + false rather than dying. overlayBanner puts a message of its own up the + same way for about thirty frames. + +- The disc picture gains three controls beside vldpSetMonochrome: + vldpFlash puts one white picture up the way a real player flashes when + it is searched, vldpSetBlend smooths the picture down its rows to take + the edges off an interlaced transfer, and vldpSetLuma brightens or + darkens it in eighths from half to one and a half without washing the + colour out. All three work on the decoded frame, cost nothing while + they are off, and follow a frame file from one segment to the next -- + which vldpSetMonochrome now does too, and did not before. + +- Sprites. spriteRotateFrame turns one frame of a sheet and + spriteDrawRotatedFrame draws it centred on a point, which is what + spriteDrawFrame will not do since it ignores rotation and scale by + design; spriteDrawGrid draws any rectangle of a sprite at its own size, + which Singe had no way to do at all; spriteGetFrames counts a sheet's + frames; and spriteLoadData and soundLoadData build a sprite or a sound + from bytes held in a Lua string, for a game that unpacks its own assets. + setOverlayOpacity fades the whole overlay and controllerHowMany counts + the open gamepads. + +- Music has its own volume. The music family (musicLoad, musicPlay, + musicStop with a real fade, musicPause, musicResume, musicIsPlaying, + musicSetVolume, musicUnload) is now in the engine rather than shimmed + over the sound family, and carries a gain of its own on Hypseus's 0 to + 128 scale: musicSetVolume moves no sound effect and soundSetVolume + moves no music. Music starts as loud as the sound effects, at whatever + --volume_nonvldp set. Ten more Hypseus names -- hypseusGetWidth, + hypseusGetHeight, takeScreenshot, mouseHowManyReal and the six + spriteAnim ones -- are aliases in Framework.singe. + +- trace.txt now opens with a header a bug report can be built on: the + version, the whole command line, the operating system, the processor, + the renderer and GPU backend that were actually chosen, how video will + be decoded, which audio decoders the build carries, the SDL versions + built and linked against, the settings that were applied and the game. It sits between two rules so it can be + pasted whole. + - lfs.dir, lfs.attributes and lfs.symlinkattributes see inside a packed game (the union of its loose directory, its data overlay and the database), and lfs.mkdir and lfs.rmdir act on its data overlay, so @@ -152,6 +325,20 @@ API Changes layers, filter passes, mask writes, layer bytes, passes). Test scenes scene40 to scene43 cover clip masks, layers, filters and gradients. +- The Pi decodes video through its V4L2 memory-to-memory device (the + h264_v4l2m2m family of decoders) when the codec has one, falling back + to software when the device is missing, as on a Pi 5 for H.264. The + trace reports which decoder is in use and the format the device hands + back; --softwarevideo turns it off like the other hardware decoders. + +- The Menu.sh and Menu.bat the engine writes now look beside themselves + for a file whose name starts with Singe and run the first one, the way + the launchers BatMaker writes already did, instead of naming the + executable. The executable's name carries its version, so a launcher + that named it stopped working at the next release, and the thing that + would have rewritten it was the launcher itself. With --gamedir the + executable is elsewhere and its full path is still written. + - Third-party libraries brought up to their current releases: ffmpeg 9.0.1 (from a 2023 development snapshot; libpostproc is gone with it), OpenSSL 3.5.8 LTS (from a 2023 development snapshot), Lua 5.4.9, zlib diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e4346dec..520fe1c15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -187,11 +187,16 @@ add_custom_command( # Support files extracted at first run. singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuClassic.singe ${GENERATED_DIR}/MenuClassic_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/gui.rcss ${GENERATED_DIR}/gui_rcss.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rml ${GENERATED_DIR}/scoreBezel_rml.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/scoreBezel.rcss ${GENERATED_DIR}/scoreBezel_rcss.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rml ${GENERATED_DIR}/subtitle_rml.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/subtitle.rcss ${GENERATED_DIR}/subtitle_rcss.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/FreeSansBold.ttf ${GENERATED_DIR}/FreeSansBold_ttf.h "") # Menu background video: two clips cropped to 4:3, scaled to 720x480, keyframed every second so the @@ -528,6 +533,8 @@ if(KANGAROO_OS STREQUAL "linux") endif() elseif(KANGAROO_OS STREQUAL "pi") set(SYSTEM_LIBS -ldl) + # The Pi decodes video through its V4L2 memory-to-memory device (videoPlayer.c). + target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE SINGE_V4L2_DECODE) if(CMAKE_BUILD_TYPE STREQUAL "Release") list(APPEND SYSTEM_LIBS -s) endif() diff --git a/assets/Framework.singe b/assets/Framework.singe index aef62cdea..a9385c9c2 100644 --- a/assets/Framework.singe +++ b/assets/Framework.singe @@ -418,6 +418,61 @@ if singeSetPauseKeyEnabled ~= nil then singeEnablePauseKey = function() singeSetPauseKeyEnabled(true) end singeDisablePauseKey = function() singeSetPauseKeyEnabled(false) end end +-- Hypseus Singe's name for the quit key setting, with the opposite sense: catching the +-- key means the engine stops quitting on it. +if singeSetQuitKeyEnabled ~= nil then + keyboardCatchQuit = function(catch) singeSetQuitKeyEnabled(not catch) end +end + + +-- Hypseus Singe Compatibility ------------------------------------------------ + + +-- Names Hypseus Singe registers in its own engine and Singe answers from here. +-- Every one is guarded, so an engine that grows a real implementation wins. + + +-- Hypseus extension. Chooses an overlay resolution and hands it to +-- overlaySetResolution, which is all Hypseus's own call does: 1 is the disc's +-- video size, 3 is 360x240, 4 is the size given (ignored without one, as in +-- Hypseus) and anything else, 2 included, is half the disc on both axes. Singe's +-- discGetWidth and discGetHeight report the canvas size when there is no disc, +-- which is the space a disc-less game draws into, so no special case is needed. +local OVERLAY_PRESET_FULL = 1 +local OVERLAY_PRESET_NONSQUARE = 3 +local OVERLAY_PRESET_CUSTOM = 4 +local OVERLAY_NONSQUARE_WIDTH = 360 +local OVERLAY_NONSQUARE_HEIGHT = 240 +local OVERLAY_HALF = 2 +if setOverlaySize == nil and overlaySetResolution ~= nil then + function setOverlaySize(preset, width, height) + if preset == OVERLAY_PRESET_FULL then + overlaySetResolution(discGetWidth(), discGetHeight()) + elseif preset == OVERLAY_PRESET_NONSQUARE then + overlaySetResolution(OVERLAY_NONSQUARE_WIDTH, OVERLAY_NONSQUARE_HEIGHT) + elseif preset == OVERLAY_PRESET_CUSTOM then + if width ~= nil and height ~= nil then + overlaySetResolution(width, height) + end + else + overlaySetResolution(discGetWidth() // OVERLAY_HALF, discGetHeight() // OVERLAY_HALF) + end + end +end + +-- Hypseus extension. Hypseus registers one C function under two names and Singe +-- already has the other one, so this is a pure alias of overlaySetResolution. +if setOverlayResolution == nil and overlaySetResolution ~= nil then + setOverlayResolution = overlaySetResolution +end + +-- Hypseus extension. True in Hypseus while it is rewriting a game's asset paths +-- for an -espath install. Singe's virtual file system reaches a game's files +-- wherever they were installed, so there is nothing to rewrite and false is the +-- right answer: the games that ask keep the MYDIR they already have. +if rewriteStatus == nil then + function rewriteStatus() return false end +end -- Singe 3.00 moved the sprite handle to the first argument. Games written for -- 2.10 can set SINGE_LEGACY_SPRITE_ARGS = true (or LEGACY_SPRITE_ARGS = true in @@ -451,6 +506,70 @@ if SINGE_LEGACY_SPRITE_ARGS and spriteDraw ~= nil and not SINGE_LEGACY_SPRITE_WR end local newSpriteSetFrame = spriteSetFrame spriteSetFrame = function(frame, id) return newSpriteSetFrame(id, frame) end + -- Hypseus extensions ship in the old order too: (x, y, frame, spr), + -- (x, y, scale, frame, spr) or (x, y, scaleX, scaleY, frame, spr). + local newSpriteDrawFrame = spriteDrawFrame + spriteDrawFrame = function(...) + local args = { ... } + local id = table.remove(args) + local frame = table.remove(args) + return newSpriteDrawFrame(id, args[1], args[2], frame, table.unpack(args, 3, #args)) + end + local newSpriteResetColorKey = spriteResetColorKey + spriteResetColorKey = function(enabled, id) return newSpriteResetColorKey(id, enabled) end + local newSpriteRotateFrame = spriteRotateFrame + spriteRotateFrame = function(degrees, frame, id) return newSpriteRotateFrame(id, degrees, frame) end + local newSpriteDrawRotatedFrame = spriteDrawRotatedFrame + spriteDrawRotatedFrame = function(...) + local args = { ... } + local id = table.remove(args) + return newSpriteDrawRotatedFrame(id, table.unpack(args)) + end + -- spriteDrawGrid is the one Hypseus writes with the handle in the middle, + -- (x, y, spr, rectX, rectY, width, height), not on the end. + local newSpriteDrawGrid = spriteDrawGrid + spriteDrawGrid = function(x, y, id, sourceX, sourceY, width, height) + return newSpriteDrawGrid(id, x, y, sourceX, sourceY, width, height) + end +end + +-- Hypseus names for calls Singe already has, and nothing more: each is a plain +-- alias, so it takes exactly the arguments the Singe call takes. They are set +-- after the legacy wrapping above, which means the six spriteAnim ones follow +-- SINGE_LEGACY_SPRITE_ARGS with the rest of the sprite family -- old order when +-- a game asked for it, handle first when it did not -- and a game never has two +-- argument orders in play at once. +if hypseusGetHeight == nil and singeGetHeight ~= nil then + hypseusGetHeight = singeGetHeight +end +if hypseusGetWidth == nil and singeGetWidth ~= nil then + hypseusGetWidth = singeGetWidth +end +if takeScreenshot == nil and singeScreenshot ~= nil then + takeScreenshot = singeScreenshot +end +-- Hypseus counts real mice apart from the ones it derives from a gamepad; Singe +-- has no derived mice, so mouseHowMany is already the real count. +if mouseHowManyReal == nil and mouseHowMany ~= nil then + mouseHowManyReal = mouseHowMany +end +if spriteAnimGetFrame == nil and spriteGetFrame ~= nil then + spriteAnimGetFrame = spriteGetFrame +end +if spriteAnimIsPlaying == nil and spriteIsPlaying ~= nil then + spriteAnimIsPlaying = spriteIsPlaying +end +if spriteAnimLoop == nil and spriteLoop ~= nil then + spriteAnimLoop = spriteLoop +end +if spriteAnimPause == nil and spritePause ~= nil then + spriteAnimPause = spritePause +end +if spriteAnimPlay == nil and spritePlay ~= nil then + spriteAnimPlay = spritePlay +end +if spriteAnimSetFrame == nil and spriteSetFrame ~= nil then + spriteAnimSetFrame = spriteSetFrame end if videoGetLanguageDescription ~= nil then diff --git a/assets/controls.cfg b/assets/controls.cfg index 76b0ac407..330ef42b4 100644 --- a/assets/controls.cfg +++ b/assets/controls.cfg @@ -24,8 +24,25 @@ -- Default Mappings +-- How far an axis must move before it counts as pressed, in raw axis units: SDL reports 32767 at +-- full deflection, so 15000 is a little under half way. It covers every axis of every gamepad, +-- the two analogue triggers included. DEAD_ZONE = 15000 +-- One axis at a time, for a stiff stick or a worn trigger, in the same raw units. Each entry is +-- { axis, dead zone }, where the axis is any GAMEPAD_N axis entry -- the axis itself or either of +-- its two directions, which all name the same axis. Anything not listed keeps DEAD_ZONE. +DEAD_ZONES = { +-- { GAMEPAD_0.AXIS_LEFT_X, 8000 }, +-- { GAMEPAD_0.AXIS_LEFT_Y, 8000 }, +} + +-- How far an analogue trigger must travel before it counts as a button, as a per cent of full +-- travel: Hypseus's unit, where its own default is 99.5. Zero leaves the triggers on DEAD_ZONE, +-- which is what Singe has always given them, and --trigger_threshold on the command line wins over +-- whatever is set here. 15000 raw units is 45.8 per cent, for comparison. +TRIGGER_THRESHOLD = 0 + -- One table per switch, in the order the engine numbers them (SWITCH_UP = 0 ...). -- Each entry is a { name, value } pair from the SCANCODE, GAMEPAD_N, or MOUSE_N tables. INPUT_UP = { SCANCODE.UP, SCANCODE.KP_8, GAMEPAD_0.AXIS_LEFT_Y_U, GAMEPAD_0.AXIS_RIGHT_Y_U, GAMEPAD_0.DPAD_UP } @@ -52,3 +69,6 @@ INPUT_CONSOLE = { SCANCODE.GRAVE } INPUT_ACTION_4 = { SCANCODE.RSHIFT, GAMEPAD_0.BUTTON_Y, MOUSE_0.BUTTON_X1 } INPUT_TILT = { SCANCODE.T } INPUT_GRAB = { SCANCODE.G } +-- Not a control: the engine raises this one itself when a mouse or a light gun is unplugged, so a +-- gun game can say so on screen. Bind a key to it as well if you want to test what the game does. +INPUT_MOUSE_DISCONNECT = { } diff --git a/assets/scoreBezel.rcss b/assets/scoreBezel.rcss new file mode 100644 index 000000000..ab432f13a --- /dev/null +++ b/assets/scoreBezel.rcss @@ -0,0 +1,96 @@ +/* + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * The score panel's look: an arcade scoreboard's seven segment readouts, each a row of dim + * "all segments lit" digits with the live value glowing over them, so an unused digit still + * shows its ghost the way a real display does. A game or a theme may replace this file. + * Licensed under the GNU General Public License, version 3 or later. + */ + +body { + width: 100%; + height: 100%; + font-family: FreeSans; + font-weight: bold; + color: #ff3018; +} + +/* The cabinet the readouts are set into. */ +#board { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 12dp 24dp; + background-color: #060606; + decorator: linear-gradient(180deg, #1a1a1c, #050505); + border: 3dp #3a3a40; + border-radius: 10dp; +} + +.cell { + display: block; + text-align: center; + padding: 0 10dp; +} + +/* An empty second player cell leaves the row to the other two. */ +.cell:empty { + padding: 0; +} + +.label { + display: block; + font-size: 20dp; + letter-spacing: 3dp; + color: #c8b070; + margin: 0 0 4dp 0; +} + +/* A readout stacks the live digits exactly over the dim ones, so both are the same size and + the same shape whatever the value is. The slant is the LED look. */ +.readout { + display: block; + position: relative; + font-size: 54dp; + letter-spacing: 6dp; + transform: skewX(-7deg); +} + +.readout.small { + font-size: 34dp; + letter-spacing: 3dp; +} + +.ghost { + display: block; + color: #4d1108; +} + +.value { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + color: #ff3018; + font-effect: glow(2dp 5dp #ff6a3cc0); +} + +/* The life count sits under the score, smaller, with its own label beside it. */ +.lives { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin: 6dp 0 0 0; +} + +.lives .label { + font-size: 16dp; + margin: 0 8dp 0 0; +} diff --git a/assets/scoreBezel.rml b/assets/scoreBezel.rml new file mode 100644 index 000000000..c75c78780 --- /dev/null +++ b/assets/scoreBezel.rml @@ -0,0 +1,34 @@ + + + + Score + + + +
+
+
1UP
+
888888
000000
+
LIVES
8
0
+
+
+
CREDITS
+
88
00
+
+
+
2UP
+
888888
000000
+
LIVES
8
0
+
+
+ +
diff --git a/assets/settings.cfg b/assets/settings.cfg new file mode 100644 index 000000000..02469e8d5 --- /dev/null +++ b/assets/settings.cfg @@ -0,0 +1,124 @@ +--[[ + * + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + * + * +--]] + + +-- settings.cfg: the options you would otherwise type on every command line. +-- +-- Copy this file to settings.cfg and uncomment what you want. Singe reads it +-- from the same four places it reads controls.cfg, in this order, and a later +-- one wins key by key: +-- +-- 1. the directory Singe was started in +-- 2. the directory above the game's data directory +-- 3. the game's data directory +-- 4. the game's own script directory +-- +-- Every key is the long name of a command line option without the leading +-- dashes, so there is no second vocabulary to learn, and the command line +-- always wins over anything set here. A games.dat entry for the game does +-- too, because what is here is a default and what is there describes the game. +-- A key that is not an option, or one that names the game or a path, is an +-- error naming this file and the key. +-- +-- Options that take no value on the command line take true or false here. +-- Setting one to false is the same as leaving it out. +-- +-- The value shown against each key is its default, or an example where the +-- default is worked out from your display or from the game. + + +-- The window ------------------------------------------------------------ + +-- screen = 1 -- which display, counting from 1; without it, the primary one +-- xresolution = 1280 -- window width in pixels; without it, one that fits the display +-- yresolution = 960 -- window height in pixels; without it, one that fits the width +-- aspect = "16:9" -- which window size table to pick from: 4:3, 16:9 or 16:10 +-- fullscreen = false -- exclusive full screen at the desktop resolution +-- fullscreen_window = false -- borderless window covering the desktop +-- nologos = false -- skip the splash screens +-- canvas = "720x480" -- world size for a game with no disc + + +-- Where the picture sits ------------------------------------------------- + +-- stretch = false -- fill the window instead of keeping the video's shape +-- scalefactor = 100 -- per cent of the window the picture fills, 25 to 100 +-- shiftx = 0 -- move it inside the room scalefactor left, -100 to 100 +-- shifty = 0 +-- rotate = 0 -- turn the whole presentation clockwise: 0, 90, 180 or 270 +-- linearscale = true -- smooth the overlay as the window scales it, or take the nearest pixel +-- monochrome = false -- start with the disc picture in grey + + +-- Cabinet artwork -------------------------------------------------------- + +-- bezel = "cabinet.png" -- artwork in the bezels folder, drawn around the picture +-- bezeldir = "bezels" -- the folder holding it +-- bezelflip = false -- draw the artwork in front of the picture instead of behind it +-- sindenedge = "video" -- where a Sinden border sits: video, or window with artwork loaded + + +-- Sound ------------------------------------------------------------------ + +-- nosound = false -- mute everything for the whole run +-- startsilent = false -- start muted until the first input of any kind +-- volume_vldp = 100 -- laserdisc volume in per cent +-- volume_nonvldp = 100 -- sound effect and extra video volume in per cent +-- audio = 0 -- which audio track of the disc video to play +-- altaudio = "" -- play .ogg beside the disc video instead +-- audiodelay = 0 -- milliseconds the audio is heard late (negative if early) + + +-- Input ------------------------------------------------------------------ + +-- nomouse = false -- ignore mice and light guns +-- nogamepad = false -- ignore gamepads +-- nocrosshair = false -- ask the game not to draw its own crosshair +-- haptic = 4 -- strongest rumble step a game may use; 0 turns rumble off +-- keymapfile = "controls.cfg" -- read the mappings from this file and make no search +-- sindengun = "10 5" -- the Sinden light gun border +-- xratio = 0 -- gun coordinate scale a game reads with ratioGetX() +-- yratio = 0 -- ... and ratioGetY() + +-- mapjoysticks = true -- write a gamepad mapping for a device SDL does not recognise +-- gamepad_reorder = "3210" -- which pad fills which slot, as enumeration positions from 0 +-- trigger_threshold = 0 -- per cent of full travel a trigger counts as pressed at; 0 uses DEAD_ZONE +-- joymouse = false -- let the first gamepad's left stick drive the mouse cursor +-- js_range = 5 -- how fast it drives it, 1 to 20 +-- manymouse = false -- tell the mice apart whatever the game asks for +-- absolutes_only = false -- keep only the mice that report absolute positions, which light guns do + + +-- Cabinets and front ends ------------------------------------------------ + +-- idleexit = 0 -- quit after this many seconds with no input; 0 never does +-- fvalue = 0 -- one number handed to the game, which reads it with getFValue() + + +-- Working on a game ------------------------------------------------------ + +-- reload = false -- rerun the game when one of its loose script files changes +-- program = false -- trace engine activity to trace.txt +-- trace = false -- trace every Lua call to trace.txt +-- showcalculated = false -- print the frame ranges of every framefile segment +-- softwarevideo = false -- decode video in software even when the machine can do it in hardware diff --git a/assets/subtitle.rcss b/assets/subtitle.rcss new file mode 100644 index 000000000..413a143f4 --- /dev/null +++ b/assets/subtitle.rcss @@ -0,0 +1,44 @@ +/* + * Singe 3 + * Copyright (C) 2006-2026 Scott Duensing + * + * What a subtitle looks like: centred lines of light text on a dark bar just wide enough to hold + * them, at whatever height the game asked for, over the picture. A game or a theme may replace + * this file; the engine only ever sets #slot's contents and the .cue's top. + * Licensed under the GNU General Public License, version 3 or later. + */ + +body { + width: 100%; + height: 100%; + font-family: FreeSans; + font-weight: bold; +} + +/* The document covers the picture, so a cue is placed down it as a per cent. */ +#slot { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +.cue { + display: block; + position: absolute; + left: 0; + width: 100%; + text-align: center; +} + +/* The bar hugs the text rather than spanning the picture, as a subtitle's does. */ +.cue .text { + display: inline-block; + padding: 4dp 18dp; + font-size: 30dp; + line-height: 36dp; + color: #ffffff; + background-color: #141414e0; + border-radius: 4dp; + font-effect: shadow(1dp 1dp #000000ff); +} diff --git a/assets/subtitle.rml b/assets/subtitle.rml new file mode 100644 index 000000000..283d97042 --- /dev/null +++ b/assets/subtitle.rml @@ -0,0 +1,19 @@ + + + + Subtitles + + + +
+ +
diff --git a/docs/Manual.adoc b/docs/Manual.adoc index f6f60eea5..7c9843a05 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -102,6 +102,7 @@ previous menu, drawn with sprites and `fontPrint` straight into the overlay, kept for one release for exactly that machine: edit `Menu.sh` (or `Menu.bat`) to name it in place of `Singe/Menu.singe`. +[[controls]] === Customizing the Controls By default, Singe maps controllers as if they were Xbox gamepads. If your @@ -109,6 +110,61 @@ controller is not recognized or you wish to change the default mappings as they appear to Singe, you can use the free SDL2 Gamepad Tool from http://generalarcade.com/gamepadtool/. +[[devices]] +==== Every Device Is a Gamepad + +Singe has one family of controller, `GAMEPAD_0` through `GAMEPAD_3`, and every +device it opens is in it. SDL calls a device a gamepad only when it has a +mapping for it, and an arcade encoder board, a spinner, a wheel or a flight +stick often has none. Rather than carry a second kind of device with its own +names, Singe writes a mapping for such a device itself and then opens it as an +ordinary gamepad, so it reaches `controls.cfg`, `controllerGetAxis`, +`controllerGetButton` and every `INPUT_*` switch exactly as a recognised pad +does. + +The mapping Singe writes is a straight-through one: the device's axes in order +become the left stick, the right stick and then the two triggers; its buttons +in order become `BUTTON_A`, `BUTTON_B`, `BUTTON_X`, `BUTTON_Y`, `BUTTON_BACK`, +`BUTTON_GUIDE`, `BUTTON_START`, `BUTTON_LEFT_STICK`, `BUTTON_RIGHT_STICK`, +`BUTTON_LEFT_BUMPER` and `BUTTON_RIGHT_BUMPER`; and its first hat becomes the +D-pad. A device with no hat spends its next four buttons on the D-pad instead. +**A device with more controls than the gamepad model can name loses the +extras** -- more than six axes, more than fifteen buttons, or a second hat -- +and a device whose buttons are not wired in that order will have them in the +wrong places. That is the price of one family instead of two, and it is +straightforward to correct: `--program` traces the mapping it wrote, in SDL's +own format, so you can paste it into a `gamecontrollerdb.txt`, rearrange it and +have it used instead. + +`--mapjoysticks=false` turns the writing off, in which case a device SDL does +not recognise is not opened at all, exactly as in earlier releases. + +[[gamecontrollerdb]] +==== gamecontrollerdb.txt + +SDL ships with a large database of controller mappings, and the community keeps +a larger one -- `gamecontrollerdb.txt`, from +https://github.com/mdqinc/SDL_GameControllerDB. Singe looks for that file in +the same four places it looks for `controls.cfg`: + +. the directory Singe was started in; +. the directory above the game's data directory; +. the game's data directory; +. the game's main script directory. + +Every file found is loaded, in that order, so one beside a game wins over a +machine-wide one, and both win over SDL's own database. Only a device that +none of them names gets the mapping Singe writes for it, so the order of +precedence is: SDL's built-in database, then your `gamecontrollerdb.txt`, then +the synthetic mapping. `--program` reports how many mappings each file added. + +A line of that file looks like this, and is exactly what the trace prints for a +device Singe mapped itself: + +---- +ff00d87a475300004900000000007603,Arcade Encoder,platform:Linux,a:b0,b:b1,x:b2,y:b3,back:b4,guide:b5,start:b6,leftx:a0,lefty:a1,dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8, +---- + In addition to configuring your controller, you can remap all the input options for Singe as a whole or for any individual game by using `controls.cfg` files. When determining how inputs are mapped, Singe reads configuration settings in @@ -116,7 +172,7 @@ the following order: . The defaults from inside Singe. . `controls.cfg` from the directory Singe was started in (the `Singe` directory holds a `controls.cfg.example` template you can copy). -. `controls.cfg` from the root of the data directory. +. `controls.cfg` from the directory above the game's data directory. . `controls.cfg` from the game's data directory. . `controls.cfg` from the game's main script directory. @@ -128,6 +184,177 @@ After installing Singe, you can find a sample input configuration file at options available to be used in this file, read through `/Singe/Framework.singe`. +`--keymapfile` names one file to read instead of making that search, which is +useful to a front end that wants no doubt about which mappings a game got. + +[[deadzones]] +==== Dead Zones and Triggers + +`DEAD_ZONE` in `controls.cfg` is how far an axis must move before it counts as +a pressed switch, in the raw units SDL reports: `32767` at full deflection, so +the default of `15000` is a little under half way. It covers every axis of +every pad, the two analogue triggers included, and a script reads it as +`SINGE_DEAD_ZONE`. + +Two settings narrow that down. + +`DEAD_ZONES` is a list of `{ axis, dead zone }` pairs for one axis at a time, +in those same raw units, for a stiff stick or a worn trigger that should not +move everything else: + +[source,lua] +---- +DEAD_ZONES = { + { GAMEPAD_0.AXIS_LEFT_X, 8000 }, + { GAMEPAD_1.AXIS_LEFT_TRIGGER, 30000 }, +} +---- + +The axis may be written as the axis itself or as either of its two directions +(`GAMEPAD_0.AXIS_LEFT_X`, `AXIS_LEFT_X_L` and `AXIS_LEFT_X_R` all name the same +axis). Anything not listed keeps `DEAD_ZONE`. + +`TRIGGER_THRESHOLD` is how far an analogue trigger must travel before it counts +as a button, as a *per cent of full travel*, which is the unit Hypseus's +`-trigger-threshold` uses and whose default there is `99.5`. It applies to both +triggers of all four pads. Singe's default is `0`, meaning the triggers stay on +`DEAD_ZONE`, so nothing changes for anyone who does not ask; a script reads the +result in raw units as `SINGE_TRIGGER_THRESHOLD`. The two units convert +directly: a per cent is `32767 / 100` raw units, so Singe's `DEAD_ZONE` of +`15000` is `45.8` per cent and Hypseus's `99.5` per cent is `32603` raw units. +`--trigger_threshold` sets the same number from the command line and wins over +`controls.cfg`; `DEAD_ZONES` wins over both, being the most specific. + +[[joymouse]] +==== Driving the Mouse With a Stick + +A light gun game on a cabinet with a stick and no mouse is unplayable unless +the stick can move the cursor. `--joymouse` makes it do so: the **left stick of +gamepad slot 0** moves the pointer the game sees, through the same +`onMouseMoved` a real mouse reaches, and the cursor is held inside the picture. +`--js_range` is the speed, `1` to `20` with `5` the default, measured in video +pixels a frame at full deflection, so `5` crosses a 720 pixel picture in about +two seconds. The axis obeys its own dead zone, so a drifting stick does not +creep. + +It is off by default, which is where Singe differs from Hypseus: the shipped +`controls.cfg` already binds that stick to `INPUT_UP`, `INPUT_DOWN`, +`INPUT_LEFT` and `INPUT_RIGHT`, and having it drive the cursor as well without +being asked would surprise every existing game. `--joymouse=false` is +Hypseus's `-nojoymouse`, and a script turns it on and off with `joyMouseEnable` +and reads it with `joyMouseIsEnabled`. A reload puts it back where the command +line left it. + +[[mousedevices]] +==== Mice, Guns, and Who Chooses + +`mouseSetMode(MOUSE_MANY)` is a game's decision to tell up to four mice apart, +which a two gun cabinet needs and which a game that never makes the call cannot +be given. `--manymouse` is the user's decision and outranks it: the mode is on +from the start and `mouseSetMode(MOUSE_SINGLE)` no longer switches it off. + +`--absolutes_only` keeps only the devices that report an absolute position, +which is what a real light gun does and an ordinary mouse does not, so a +cabinet with two guns and a desk mouse plugged in is not confused by the mouse. +Be aware of how it decides: **ManyMouse, the backend Singe uses to tell mice +apart, offers no way to ask a device what kind it is**, so a device counts as +absolute once it has reported an absolute position and not before. A gun does +that with its first movement; until then it is silent, and a relative-only +device stays silent for good. The option is therefore a filter by observed +behaviour rather than by device class, and on a machine where no device ever +reports an absolute position it silences every mouse. + +`SWITCH_MOUSE_DISCONNECT` is a switch like any other, except that no key or +button produces it: the engine raises it when a mouse or a light gun is +unplugged, once as a press and once as a release, so a gun game can say so on +screen instead of appearing to have died. It is Hypseus's switch of the same +name. `controls.cfg` leaves it bound to nothing; bind a key to it as well if +you want to see what a game does without pulling a cable. + +[[hotkeys]] +==== Hotkeys + +`Alt-Enter` switches between full screen and a window while the game runs, as +it does in Hypseus. The game never sees the key. A window that started in +exclusive full screen (`--fullscreen`) returns to it in the display mode it was +given; one that started windowed gets a borderless window covering the desktop. +Nothing else has to change with it: the renderer presents a logical size in +video coordinates and scales that to whatever the window now is, the video +rectangle is built in those same coordinates, and the rotation target is the +size of the picture rather than of the window, so the picture, the overlay, the +GUIs and the mouse mapping all follow by themselves. + +The other switches a user can press -- quit, pause, screenshot, the console and +the mouse capture toggle -- are ordinary `controls.cfg` mappings and are +described with the `INPUT_*` names in the sample file. + +[[settings]] +=== The Settings File + +Everything on the command line can also be written down once, in a +`settings.cfg` file, so a cabinet does not need a launcher script carrying the +same dozen options for every game. It is Lua, like `controls.cfg`, and Singe +looks for it in exactly the same four places, in this order: + +. `settings.cfg` from the directory Singe was started in (the `Singe` directory holds a `settings.cfg.example` template you can copy). +. `settings.cfg` from the directory above the game's data directory. +. `settings.cfg` from the game's data directory. +. `settings.cfg` from the game's main script directory. + +Each place is read once, and a later file wins key by key, so a machine-wide +file can set the display and the volumes while a file beside one game turns off +its crosshair. You do not have to write every key in a file; write the ones you +want changed. + +Every key is the long name of a command line option with the leading dashes +removed, so there is no second vocabulary to learn: + +[source,lua] +---- +-- The cabinet's own settings.cfg +screen = 2 -- the second monitor +fullscreen = true +volume_vldp = 80 +haptic = 0 -- this cabinet has no rumble +idleexit = 900 -- hand the screen back after fifteen quiet minutes +startsilent = true +---- + +An option that takes no value on the command line takes `true` or `false` here +(`yes`, `no`, `on`, `off`, `1` and `0` are accepted too). Setting one to `false` +is the same as leaving it out. An option that takes a value takes the same value +it would on the command line, as a number or a string. + +==== What wins + +. The built in default. +. The settings file. +. The game's `games.dat` entry, for the handful of settings it carries (see <>). +. The command line, which always wins. + +A settings file is a set of defaults, in other words, not something you typed: +if a `games.dat` entry gives a game a resolution or a Sinden border, that entry +still describes the game better than a machine-wide file does. Anything actually +typed on the command line beats both. + +Options that name the game (`--framefile`, `--disc`, `--entry`), name a +directory (`--gamedir`, `--datadir`), or have to act before the file could be +found (`--help`, `--apiversion`, `--noconsole`, `--pack`, `--patch`, +`--unpack`) cannot be set in a settings file. A key that is not an option, one +of those, or a value the option will not take is an error naming the file and +the key, so a typo is never silently ignored: + +---- +settings.cfg: volume_vldpp is not an option. +settings.cfg: datadir cannot be set here; it belongs on the command line. +settings.cfg: Display scale must be between 25 and 100 percent. +---- + +With `--program` or `--trace`, the header at the top of `trace.txt` lists every +setting that was applied, so there is never a question about which file a +running game was given. + +[[commandlineoptions]] === Command Line Options ---- @@ -149,40 +376,257 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile. | Option | Purpose | `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See <>. | `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`). -| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`50` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. Cannot be combined with `--sindengun`. +| `-B`, `--bezel=FILENAME` | Draw cabinet artwork around the picture. The file is looked for in the `bezels` folder of the game directory (a packed game may carry its own) and then in the `bezels` folder of the data directory; `--bezeldir` names a different folder. Any format SDL_image reads will do, and transparency is kept. A sidecar beside it says where the picture goes inside it; with none, the picture keeps the whole window and `--shiftx`, `--shifty` and `--scalefactor` place it. Artwork that cannot be found ends the program. See <>. +| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`25` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. A script may change it while the game runs with `vldpSetScale`. Combines with `--shiftx`, `--shifty`, `--rotate` and `--sindengun`; see <>. | `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging. | `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc. | `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored. | `-d`, `--datadir=PATHNAME` | Directory for everything Singe writes: video indexes, `trace.txt`, screenshots, saves, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it (for a packed game, one named for the database and then for the script's directory inside it). Defaults to `data` in the game directory. Singe 2.x wrote beside the game instead; game directories may be read only, so nothing is written there any more. | `-E`, `--entry=N` | Run the Nth entry of the `games.dat` inside a `.game` file (default 1). See <>. | `-e`, `--volume_nonvldp=PERCENT` | Sound effect and extra video volume, `0` to `100`. +| `-F`, `--bezelflip` | Draw the bezel artwork in front of the picture instead of behind it, which is what Hypseus does with its own bezels; the artwork's transparency is then the hole the game shows through. Without it the artwork is behind everything and the picture covers its cutout. A script chooses whether the overlay and the GUIs stay above the artwork with `setOverlayOnTop`. See <>. | `-f`, `--fullscreen` | Exclusive full screen at the desktop resolution. | `-G`, `--gamedir=PATHNAME` | The directory holding the games, the packed `.game` files and the `Singe` support folder, when the executable lives somewhere else. It becomes the working directory, so the game name, a relative `--datadir` and the packer's names all count from it; absolute paths work as they are. Defaults to the current directory. -| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. Cannot be combined with `--scalefactor`. -| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use. +| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. The rings are drawn around whatever `--shiftx`, `--shifty` and `--scalefactor` left, so a border stays a border however the picture is placed; see <>. +| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, the V4L2 decoder on the Pi, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use. | `-h`, `--help` | Show the option summary and exit. +| `-I`, `--bezeldir=PATHNAME` | The folder holding `--bezel`'s artwork, in place of `bezels`. Relative to the game directory, or absolute. Only that folder is searched. See <>. | `-k`, `--nologos` | Skip the splash screens. | `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`. | `-m`, `--nomouse` | Disable mouse and lightgun input entirely. | `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`. | `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases. | `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See <>. -| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. +| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see <>. +| `-S`, `--sindenedge=WHERE` | Where the Sinden border sits: `video` draws it around the picture, `window` at the edge of the screen. Without the option a bezel decides: artwork around the picture puts the border at the window's edge, because the gun's camera sees the whole screen, and with no artwork it goes around the picture as it always has. At the window's edge the two rings are drawn as rings, last of all, so the artwork between them and the picture stays visible. See <>. | `-s`, `--nosound` | Mute all audio. | `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit. -| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. +| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see <>. +| `-r`, `--rotate=DEGREES` | Turn the whole presentation clockwise by `0`, `90`, `180` or `270` degrees, for a cabinet whose panel is mounted on its side. The video, the overlay, the GUIs, the 3D scene and the particles all turn together, a quarter turn swaps the shape of the picture so a portrait window is filled, and mouse and light gun positions are turned back so a click still lands where it is drawn. Only the four right angles are accepted. A script may change it with `vldpSetRotate`. | `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work. | `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused. | `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio. | `-v`, `--framefile=FILENAME` | Video file or framefile to use instead of the one found next to the script. | `-w`, `--fullscreen_window` | Borderless window covering the desktop. +| `-X`, `--shiftx=PERCENT` | Move the picture horizontally inside the room `--scalefactor` left it, `-100` (hard against the left) to `100` (hard against the right), `0` centred. As in Hypseus, the shift is a percentage of that room and nothing more, so at `--scalefactor=100` there is no room and the shift does nothing: reduce the scale first. See <>. | `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. +| `-Y`, `--shifty=PERCENT` | Move the picture vertically inside the room `--scalefactor` left it, `-100` (hard against the top) to `100` (hard against the bottom), `0` centred. It does nothing at full scale, for the same reason. See <>. | `-y`, `--yresolution=VALUE` | Window height. | `-z`, `--noconsole` | Print nothing (and open no console window on Windows). +| `--absolutes_only` | Keep only the mice that report an absolute position, which is what a real light gun does and an ordinary mouse does not. ManyMouse cannot be asked what a device is, so a device counts as absolute once it has reported an absolute position and not before; see <>. Hypseus writes it `-absolutes-only`; the name here uses an underscore because a settings file key has to be a Lua name. Default: off. +| `--altaudio=SUFFIX` | Play `.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none. +| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <>. Nothing else is printed. +| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`. +| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order. +| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for. +| `--idleexit=SECONDS` | Quit after `SECONDS` with no input of any kind -- no key, no button, no pad axis, no mouse. For an attract cabinet that should hand the screen back. `0` never quits. `0` to `86400`. Default: `0`. +| `--joymouse=BOOLEAN` | Let the left stick of gamepad slot `0` drive the mouse cursor, for a light gun game on a cabinet with no mouse. `--joymouse=false` is Hypseus's `-nojoymouse`, and a script sets it with `joyMouseEnable()`. See <>. Default: `false`, where Hypseus's default is on -- the shipped `controls.cfg` already binds that stick to the four directions. +| `--js_range=SPEED` | How fast that stick drives it, `1` to `20`, in video pixels a frame at full deflection. Default: `5`, as in Hypseus. +| `--keymapfile=FILENAME` | Read the control mappings from this file instead of searching the four places for `controls.cfg`, so a front end can be certain which mappings a game got. The built in defaults still apply underneath it, so a partial file still works, and a file that is not there ends the program. Default: the four place search, described in <>. +| `--linearscale=BOOLEAN` | Smooth the overlay as the window scales it (`true`) or take the nearest pixel (`false`, which keeps pixel art crisp). Takes `true`, `false`, `yes`, `no`, `on`, `off`, `1` or `0`. A script changes it while running with `setOverlayLinearScale`. Default: `true`, which is what every Singe release has drawn; Hypseus's own default is the opposite. +| `--manymouse` | Tell up to four mice apart whatever the game asks for, which a two gun cabinet needs from a game that never calls `mouseSetMode(MOUSE_MANY)` itself. The user's choice outranks the game's: `mouseSetMode(MOUSE_SINGLE)` no longer switches it off. Default: off, and the game decides. +| `--mapjoysticks=BOOLEAN` | Write a gamepad mapping for a device SDL does not recognise, so an arcade encoder, a spinner, a wheel or a flight stick is opened as `GAMEPAD_0` to `GAMEPAD_3` like anything else. `false` leaves such a device unopened, as earlier releases did. See <>. Default: `true`. +| `--monochrome` | Start with the disc picture in grey. The overlay, the GUIs and the 3D scene keep their colour; a script does the same with `vldpSetMonochrome`, and undoes it. Default: off. +| `--nogamepad` | Ignore every gamepad, the counterpart of `--nomouse`. None is opened and no pad event reaches the game, so a stuck arcade encoder cannot press anything. Default: off. +| `--screen=N` | Open the window on display `N`, counting from `1` as Hypseus counts them. A number larger than the number of displays lists the displays that are there and exits. Default: the primary display. +| `--startsilent` | Start muted and stay muted until the first input of any kind, then play at the configured volumes. For an attract cabinet in a quiet room. `--nosound` outranks it. Default: off. +| `--trigger_threshold=PERCENT` | How far an analogue trigger must travel before it counts as a button, as a per cent of full travel -- Hypseus's unit, whose own default is `99.5`. `0` leaves the triggers on `DEAD_ZONE`, which is what Singe has always given them. Wins over `TRIGGER_THRESHOLD` in `controls.cfg`; `DEAD_ZONES` wins over both. `0` to `100`; see <>. Default: `0`. +| `--xratio=FACTOR` | The horizontal scale a light gun game reads with `ratioGetX()` to stretch its own gun coordinates for a display whose shape does not match the video. Singe does nothing with it; the game does the arithmetic, exactly as in Hypseus. `0` to `100`, kept to two decimals as Hypseus keeps it. Default: `0`, which every game that reads it treats as `1`. +| `--yratio=FACTOR` | The same for `ratioGetY()`. Default: `0`. |=== Options may be given as `-x 640`, `-x640`, or `--xresolution=640`. Numeric -values are validated; a bad value prints the usage text and exits. +values are validated; a bad value prints the usage text and exits. The options +at the end of the table have no short letter; the alphabet ran out, and a letter +picked at random would help nobody. + +Anything here except the options that name the game, name a directory, or must +act before a file can be found may also be set in a settings file, so a cabinet +does not need a launcher script for every game. See <>. + +[[apiversion]] +==== The Version Line + +`--apiversion` prints one line and exits with status `0`: + +---- +singe version=3.00 protocol=1 +---- + +This is a contract for front ends and will not change shape: + +* The line ends with a newline and is the only thing written to standard + output. Nothing else is printed, whatever else is on the command line. +* The first field is always `singe`. +* Every field after it is `key=value`, separated by one space, with no spaces + inside a field. +* `version` is the release, the same number the banner shows. +* `protocol` is the version of this line's format. It becomes `2` only if the + rules above ever have to change. +* Later releases may add fields. A reader must ignore keys it does not know + rather than treating them as an error. + +[[videorect]] +=== The Video Rectangle + +Everything a game shows -- the disc, the overlay, the GUIs, the 3D scene and +the particles -- is drawn into one rectangle, and a handful of options decide +where that rectangle is. They apply in this order: + +. *The window, or the bezel's cutout.* The rectangle starts as the whole + window. Singe works in the video's own coordinates and lets the window + letterbox them (`--stretch` fills instead), so nothing here depends on the + window's size. When `--bezel` loaded artwork that declares a cutout, the + rectangle starts as that hole instead and everything below places the picture + inside it; see <>. +. *`--scalefactor`.* The rectangle shrinks to that percentage of what it + started as, about its own centre. +. *`--shiftx` and `--shifty`.* The rectangle then moves inside the room the + scale left, `-100` hard against one side and `100` hard against the other, + which is what Hypseus's own `-shiftx` and `-shifty` mean. Two consequences + follow from that and are worth knowing: the picture can never be pushed off + the screen, and *at `--scalefactor=100` there is no room and a shift does + nothing at all*. To move the picture on a badly mounted panel, reduce the + scale first: `--scalefactor=90 --shiftx=-100` puts the shrunken picture hard + against the left. +. *`--sindengun`.* The border is applied last, to whatever the shift and the + scale left. The black ring takes the rectangle's edge, the white ring sits + inside it, and the game goes inside both, so a border is still a border + however the picture was placed. A scale small enough to leave no room for the + game squeezes the two rings rather than swallowing it. With bezel artwork + loaded the border goes to the window's edge instead and leaves the picture the + size it already was; `--sindenedge` decides it either way. + +`--rotate` is not part of that order. The frame is drawn unrotated and the +finished picture is turned clockwise as a whole, so a shift moves the picture +along the video's own axes and turns with it. A quarter turn also swaps the +shape of the presented area, so a portrait window is filled rather than +letterboxed into a strip. + +Mouse and light gun positions travel the same path backwards: the rotation +comes off first, and the position then maps into the rectangle, so a click +lands where the player sees it under any combination of these options. +`vldpGetScale`, `vldpSetScale`, `vldpGetRotate` and `vldpSetRotate` let a +script move the scale and the rotation while the game runs; the shift is a +display setting and has no script call. None of them is a `games.dat` key: +like `--scalefactor`, they describe the player's screen, not the game. + +[[bezels]] +=== Bezels + +A bezel is the artwork around an arcade cabinet's screen. Singe draws one from a +single image file, with the game's picture in a hole cut out of it, and can show +an arcade scoreboard in the space that leaves. Three options control it, and +they follow Hypseus's own convention so a cabinet's command line moves across +unchanged. + +`--bezel cabinet.png` names the artwork. It is looked for in this order: + +. `bezels/cabinet.png` in the game directory -- inside a packed `.game`, its own + copy, so a game can carry its bezel with it; +. `bezels/cabinet.png` in the data directory, where a user's own artwork can + live without touching a read-only game directory. + +`--bezeldir art/cabinets` replaces the `bezels` folder with the one it names, +relative to the game directory or absolute, and then only that folder is +searched. Any format SDL_image reads will do (`.png` is the sensible choice, +because transparency matters). Artwork the option names and Singe cannot find +ends the program rather than running without it. + +The artwork is drawn over the whole window, whatever shape either of them is, so +it stretches with the window exactly as the picture does. Give it the aspect +ratio you expect to play at. + +==== The cutout + +Beside the image, and named after it, a sidecar file says where the picture +goes: `bezels/cabinet.png` is described by `bezels/cabinet.cfg`. It is a Lua +file, as `controls.cfg` is, and sets one table in the artwork's *own pixels*: + +[source,lua] +---- +-- The hole in cabinet.png the picture is drawn in, in the artwork's own pixels. +CUTOUT = { x = 200, y = 120, width = 800, height = 500 } +---- + +All four fields are required and the rectangle must lie inside the artwork; a +sidecar that will not run, or that names a rectangle outside the image, ends the +program with a message. Because the numbers are the artwork's own, they stay +right at any window size: the image and the hole are scaled together. + +With a cutout, the picture is drawn in that hole -- stretched to it, so the hole +decides the shape of the picture -- and `--scalefactor`, `--shiftx` and +`--shifty` then work *inside* it, shrinking and nudging the picture in the hole. +With no sidecar the picture keeps the whole window, exactly as it does with no +bezel, and those three options place it as they always have. See +<>. + +The hole itself should be transparent in the image. Singe draws the artwork +behind the picture by default, so an opaque middle would simply be covered, but +`--bezelflip` puts the artwork in front and then only its transparency lets the +game through. + +==== Draw order + +Without `--bezelflip` the artwork is the first thing in the frame: the picture, +the overlay, the GUIs, the 3D scene and the particles all draw over it. With +`--bezelflip` the artwork draws in front of the picture instead, which is the +order Hypseus uses for its own bezels, so a frame with soft or shaped edges +overlaps the picture properly. + +A script decides what happens to the overlay in that case: +<> draws the overlay and the GUIs above +the artwork, and `false` (the default, and Hypseus's) leaves the artwork on top +of them. With the artwork behind the picture the setting changes nothing, +because everything is already above it. +<> tells a script whether artwork is loaded +and gives it the artwork's identifier, the same number Hypseus reports, so a +game written for a particular bezel can recognise it. + +Screen space GUIs and the score panel always draw above the artwork, since the +point of them is to sit on it. + +==== The Sinden border + +A Sinden light gun watches the screen for a bright border. With bezel artwork +loaded that border belongs at the *edge of the window*, not around the picture, +because the gun's camera sees the whole screen, so that is where `--sindengun` +puts it as soon as a bezel is loaded. There it is drawn as two rings, after +everything else, so the artwork between the border and the picture stays visible +and nothing paints over the white ring. With no bezel the border goes around the +picture, as it always has, and shrinks it. + +`--sindenedge video` or `--sindenedge window` overrides that choice in either +direction: a gun cabinet whose artwork is only a thin frame may still want the +border around the picture, and a cabinet with no artwork at all may want it at +the screen's edge. + +==== The score panel + +Sixteen of the Hypseus Singe games can drive an arcade scoreboard: credits, and +a score and a life count for each of two players. In Singe that scoreboard is +the RmlUi document `Singe/scoreBezel.rml`, styled by `Singe/scoreBezel.rcss`, +both written beside the other support files and both replaceable -- a theme or a +game may restyle the panel by editing them, exactly as it may restyle +`Singe/gui.rcss`. The shipped style is a seven segment look: every readout shows +its digits dimly lit behind the live value, the way a real display does. + +The panel is *off* until a game asks for it with +<>, which is how Hypseus ships as +well, so a game that never asks behaves exactly as before and draws its own +scoreboard with sprites. <> is the +question every such game asks first. + +Where it goes is decided for you: the panel takes the deeper of the bands the +picture leaves above and below it -- the room a bezel's cutout usually leaves -- +centred on the picture, and lies along the bottom of the picture when neither +band is deep enough to hold it. It is drawn as a screen space GUI, so a game may +put its own GUIs on the artwork beside it with +<>. + +Hypseus's `scoreBezelEnable` takes a second argument choosing among its five +scoreboard backends. Singe has one, so the argument is accepted and ignored. == Frequently Asked Questions @@ -334,7 +778,11 @@ packed `.game` files, and a `data` directory for everything the games and the engine write. The executable usually sits there too, but need not: `--gamedir` names the game directory from anywhere, and the `Menu.sh` (or `Menu.bat`) the engine writes there then runs the executable by its full -path. +path. Otherwise the launcher searches beside itself for a file whose name +begins with `Singe` and runs the first one it finds, the same way the +launchers `BatMaker` writes do, so an upgrade that changes the executable's +version in its name does not leave a launcher pointing at a file that is no +longer there. ---- Singe/ Support files extracted by the engine @@ -750,12 +1198,15 @@ GAMES = { The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, -`AUDIO_DELAY`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own +`AUDIO_DELAY`, `AUDIO_SUFFIX`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own script, through `scriptExecute` / `scriptPush`) launches the entry; they override the command line. The `VIDEO` line is the disc: an entry that names one is a laserdisc game, and an entry that leaves it out or blank runs without a disc and may set `CANVAS_X` / `CANVAS_Y` (default 720x480); see -<>. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10 +<>. `AUDIO_SUFFIX = "-es"` plays +`-es.ogg` beside the disc's video instead of the audio inside it, for a +release whose other languages ship as separate files, the same thing +`--altaudio` does. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10 with the old sprite argument order (see <>). The remaining keys are read by the menu for display. @@ -767,8 +1218,10 @@ script (entry paths count from the directory above the `games.dat`, so keys apply. The one difference from a menu launch is precedence: an option typed on the command line (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, -`--canvas`) keeps its value, since the person typing it meant it. A -`games.dat` that fails to run is reported and ignored for a direct launch. +`--altaudio`, `--canvas`) keeps its value, since the person typing it meant it. +A value from a settings file does not: it is a default, and the entry describes +the game better (see <>). A `games.dat` that fails +to run is reported and ignored for a direct launch. [#withoutadisc] === Games Without a Disc @@ -1229,6 +1682,65 @@ The same page on the screen of a modeled cabinet is the two lines from <> in place of `guiDraw`, and a click on the monitor presses the button under it. +[#subtitles] +=== Subtitles + +A game can ship a SubRip subtitle file and have Singe show it over the disc as it plays. Three calls do the whole job: <> reads the file, <> switches it on, and <> says where the lines sit. <> takes whatever is up off again. The feature came from Hypseus Singe and the four calls are its own. + +[source,lua] +---- +if srtLoad("subs/english.srt") then + srtPosition(80) + srtEnable(true) +end +---- + +==== The file format + +SubRip (`.srt`) is a plain text file of numbered cues: + +---- +1 +00:00:04,120 --> 00:00:06,880 +Stay away from the caves. + +2 +00:00:07,000 --> 00:00:09,500 +I mean it -- the caves +are where they nest. +---- + +Each cue is a number on a line of its own, then a line holding two timestamps separated by `-->`, then one or more lines of text, then a blank line. A timestamp is `HH:MM:SS,mmm`: hours, minutes and seconds separated by colons, and milliseconds after a comma. The cue number is read and thrown away, so the numbers need not be in order or even correct. + +The parser is deliberately forgiving in the places a hand-edited file goes wrong. Carriage returns, leading spaces and trailing spaces are stripped from every line, so a file written on Windows loads unchanged. A cue whose time line has no `-->`, or whose timestamps do not parse, is skipped and the file goes on loading. A cue with no text is skipped. A cue's lines are joined with line breaks and shown as written, and `<`, `>` and `&` reach the screen as themselves rather than being taken for markup. + +What is *not* supported: the positioning coordinates some tools append to the time line are ignored (`srtPosition` decides the height), and SubRip's occasional `` and `` tags are shown as text rather than obeyed. Cues may overlap in time; the last one that has started wins. + +`srtLoad` answers `false` and loads nothing when the file cannot be read, when nothing in it parses, or when there is no disc -- so a broken file is a `false` a game can act on and not a crash. It prints a warning naming the file in each case. + +==== The timing + +Timestamps are turned into *disc frame numbers* as the file is loaded, using the frame rate of the disc that is loaded at the time, and it is the frame number the engine matches every drawn frame. Two things follow from that. + +First, **a subtitle survives a seek.** Sending the disc anywhere -- `discSearch`, `discSkipForward`, a step, a frame file crossing into another segment -- puts up the cue covering wherever it landed, on the next drawn frame, forwards or backwards. Nothing has to be reloaded and no clock has to be reset. + +Second, **the file must be timed against the disc, not against the wall clock.** A `.srt` cut for a video at a different frame rate lands in the wrong place, and one loaded before the disc has been opened does not load at all. Load subtitles after the disc, which for an ordinary game means from the script's body or from `onStart`. + +A cue is shown while the disc's frame is between its two frames inclusive. Only the cue is redrawn when it changes, so subtitles cost nothing while a line is up. + +==== Styling + +The lines are an RmlUi document, not a blitted text line, so they are styled the way a GUI is. Two files ship in the `Singe` folder and are extracted beside the others at first run: + +* `Singe/subtitle.rml` -- the document. It has one element, `#slot`, that the engine writes the current cue or banner into. +* `Singe/subtitle.rcss` -- the look. `.cue` is the block that carries the height `srtPosition` asked for, as an inline `top`; `.cue .text` is the bar of text inside it, which by default is white on a dark rounded panel that hugs the text rather than spanning the picture. + +Replace either file to restyle. The engine only ever sets `#slot`'s contents and the `.cue`'s `top`, so anything else -- the face, the size, the colours, the panel, a border, a shadow -- belongs in the RCSS and survives an engine upgrade. The document is drawn over the picture, above the overlay, below the screen space GUIs, follows the video rectangle wherever `--scalefactor`, `--shiftx`, a bezel cutout or `--rotate` put it, and takes no mouse input, so a click passes straight through it. + +<> uses the same document, so a banner and a subtitle look alike and a game that restyles one restyles both. + +On a machine with no GPU there are no GUIs at all; `srtLoad` still parses the file and answers truthfully, and nothing is drawn. A warning says so once. + [#scenes3d] === 3D Scenes @@ -2477,7 +2989,7 @@ available to `controls.cfg` and to `Framework.singe` alike: [cols="2,3",options="header"] |=== | Constant | Meaning -| `SWITCH_UP` ... `SWITCH_GRAB` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. +| `SWITCH_UP` ... `SWITCH_MOUSE_DISCONNECT` | Values passed to `onInputPressed` / `onInputReleased` in `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` is raised by the engine itself when a mouse or light gun is unplugged, once as a press and once as a release; no key or button produces it. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments for `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Arguments for `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (also `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments for `mouseSetMode`. @@ -2499,7 +3011,8 @@ available to `controls.cfg` and to `Framework.singe` alike: | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Tone curves for `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns. -| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`. +| `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`, in raw axis units. +| `SINGE_TRIGGER_THRESHOLD` | The analogue trigger threshold in those same raw units; `0` when the triggers are on `SINGE_DEAD_ZONE`, which is the default. | `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order. | `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world. | `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE` | Layout of the controller and mouse input codes; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them. @@ -2588,8 +3101,12 @@ lost. Singe decodes video with FFmpeg's libraries directly, using the platform's hardware decoder when it offers one for the codec (VA-API or VDPAU on Linux, D3D11VA on -Windows, VideoToolbox on macOS) and its own software decoder otherwise; the -program trace reports which, and `--softwarevideo` forces software. Any container +Windows, VideoToolbox on macOS, and on the Pi the V4L2 memory-to-memory decoder, +which on a Pi 4 covers H.264; a Pi 5 has no H.264 decoder and its HEVC +decoder needs a kind of driver FFmpeg does not yet ship, so encode Pi games +as H.264 and expect software decoding on a Pi 5) and its own software +decoder otherwise; the program trace reports which, and `--softwarevideo` forces +software. Any container and codec the bundled FFmpeg can demux and decode will play: MP4, MKV, MPEG program streams, AVI, and the classic Daphne `.m2v` elementary streams with a matching `.ogg` @@ -2959,6 +3476,223 @@ function onWaveFinished() end ---- +[#bezelref] +=== Bezel + +Cabinet artwork and the arcade scoreboard that goes with it. `mainBezelLoaded` +asks about the artwork `--bezel` loaded; the `scoreBezel` family drives the score +panel, an RmlUi document the engine ships and either a theme or a game may +restyle. Every call here is a Hypseus Singe extension, kept so its games run +unchanged, and every one of them is described from the player's side in +<>. + +The panel starts switched off, as it does in Hypseus, and only a game turns it +on. Values given to it are remembered whether it is showing or not, so a game +may set them before it enables the panel. Each readout has the number of digits +Hypseus's scoreboard has -- six for a score, two for the credits, one for a life +count -- and a value is shown right justified in them with leading zeros; a +score of more than six digits shows its last six. Hypseus's negative values are +understood too: `-2` lights a dash in every digit, `-4` an `H` and `-5` an `A`, +and `-1`, `-3` and any other negative number light nothing. + +[#mainbezelloaded] +==== mainBezelLoaded + +[source,text] +---- +loaded, id = mainBezelLoaded() +---- + +Hypseus extension. Whether `--bezel` loaded cabinet artwork, and the identifier +of the file it loaded. The identifier is the 64 bit FNV-1a hash of the file's +bytes written out as a decimal string, which is what Hypseus reports, so a game +that recognises a particular bezel by its number recognises the same file here. +With no artwork loaded the answers are `false` and `"0"`. + +*Returns:* boolean, and the identifier as a string. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Only draw the sprite frame when the player has no artwork of their own. +local loaded, id = mainBezelLoaded() +if not loaded then + spriteDraw(frameSprite, 0, 0) +end +---- + +[#scorebezelclear] +==== scoreBezelClear + +[source,text] +---- +scoreBezelClear() +---- + +Hypseus extension. Blanks every readout on the score panel: the credits, both +scores and both life counts show nothing at all, leaving only the dim digits +behind them. The values are not forgotten, but the next `scoreBezelCredits`, +`scoreBezelScore` or `scoreBezelLives` lights the panel again with whatever it is +then holding. Safe to call with the panel switched off. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +[#scorebezelcredits] +==== scoreBezelCredits + +[source,text] +---- +scoreBezelCredits(credits) +---- + +Hypseus extension. Sets the credit readout, which has two digits. Also lights a +panel that `scoreBezelClear` blanked. + +*Parameters:* + +* `credits` -- integer; the last two digits are shown. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +[#scorebezelenable] +==== scoreBezelEnable + +[source,text] +---- +scoreBezelEnable(enabled) +scoreBezelEnable(enabled, type) +---- + +Hypseus extension. Shows or hides the score panel. The first `true` builds it +from `Singe/scoreBezel.rml`; hiding and showing it again after that costs +nothing and keeps its values. It starts hidden, which is how Hypseus ships as +well, so a game that never calls this behaves exactly as it did before -- and +should ask <> before assuming it has one. + +Hypseus's `type` chooses among its five scoreboard backends. Singe has one, an +RmlUi document, so the argument is checked and ignored. A machine with no GPU has +no GUIs at all; there the panel cannot be built, `scoreBezelGetState` keeps +answering `false`, and a warning is printed once. A non-boolean first argument +aborts the script. + +*Parameters:* + +* `enabled` -- boolean. +* `type` -- integer, accepted and ignored. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- The engine's panel when there is one, sprites when there is not. +scoreBezelEnable(true, 0) +if scoreBezelGetState() then + scoreBezelTwinScoreOn(twoPlayer) + scoreBezelCredits(credits) +else + drawSpriteScoreboard() +end +---- + +[#scorebezelgetstate] +==== scoreBezelGetState + +[source,text] +---- +state = scoreBezelGetState() +---- + +Hypseus extension. Whether the score panel is showing. Every Hypseus game asks +this before using the panel and draws its own scoreboard with sprites when the +answer is `false`, which is the right fallback and not a failure. + +*Returns:* boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +[#scorebezellives] +==== scoreBezelLives + +[source,text] +---- +scoreBezelLives(player, lives) +---- + +Hypseus extension. Sets a player's life readout, which has one digit. `player` is +`1` or `2`; any other number is ignored, and so is `2` until +<> has put the second +player's readouts on the panel. Also lights a panel that `scoreBezelClear` +blanked. + +*Parameters:* + +* `player` -- `1` or `2`. +* `lives` -- integer; the last digit is shown. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +[#scorebezelscore] +==== scoreBezelScore + +[source,text] +---- +scoreBezelScore(player, score) +---- + +Hypseus extension. Sets a player's score readout, which has six digits. `player` +is `1` or `2`, with the same rule for `2` as `scoreBezelLives`. Also lights a +panel that `scoreBezelClear` blanked. + +*Parameters:* + +* `player` -- `1` or `2`. +* `score` -- integer; the last six digits are shown. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +.Example +[source,lua] +---- +function addScore(points) + score = score + points + if scoreBezelGetState() then + scoreBezelScore(1, score) + end +end +---- + +[#scorebezeltwinscoreon] +==== scoreBezelTwinScoreOn + +[source,text] +---- +scoreBezelTwinScoreOn(enabled) +---- + +Hypseus extension. Puts the second player's score and life readouts on the panel, +or takes them off again and leaves the room to the other two. Switching them off +also blanks the panel, exactly as it does in Hypseus, so follow it with a value +if the first player's readouts should stay lit. While they are off, +`scoreBezelScore(2, ...)` and `scoreBezelLives(2, ...)` do nothing. A non-boolean +argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + [#body] === Body @@ -3637,9 +4371,148 @@ end ---- [#controller] +[#commandlineref] +=== Command Line + +Three calls report numbers the user chose when Singe was started. They exist so +a game can be tuned from the launcher rather than by editing the game. All three +are Hypseus Singe extensions, and Singe does nothing with the values itself: the +game decides what they mean. The options are described in +<>, and they may also be set in a +settings file (see <>). + +None of the three changes during a run, so read each one once and keep it. + +[#getfvalue] +==== getFValue + +[source,text] +---- +value = getFValue() +---- + +Hypseus extension. Returns the number given as `--fvalue`, or `0` when the +option was not used. It is a channel of one number from whoever started Singe to +the game, meant for a value a front end wants to vary between launches -- a +difficulty, a starting credit count, a menu index. The engine only carries it. + +The value runs from `0` to `100000` and keeps three decimals, which is what +Hypseus keeps, so a game comparing the number sees the same one it saw there. + +*Returns:* number. + +*Since:* 3.00 (Hypseus) +*See also:* <>, <> + +.Example +[source,lua] +---- +-- The launcher passes --fvalue 2 for the hard cabinet. +local difficulty = getFValue() +if difficulty == 0 then + difficulty = 1 +end +---- + +[#ratiogetx] +==== ratioGetX + +[source,text] +---- +factor = ratioGetX() +---- + +Hypseus extension. Returns the number given as `--xratio`, or `0` when the +option was not used. Light gun games written for Hypseus read it and stretch +their own gun coordinates with it, for a cabinet whose screen is not the shape +the video was mastered in -- `1.33` maps a 16:9 panel onto 4:3 footage. Singe +does not apply it to anything; the game does the arithmetic, exactly as in +Hypseus, which is why every game that reads it treats `0` and anything below `1` +as `1`. + +The value runs from `0` to `100` and keeps two decimals, as Hypseus keeps it. + +*Returns:* number. + +*Since:* 3.00 (Hypseus) +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- The shape of the gun coordinates this cabinet wants. +local ratioX = ratioGetX() +if ratioX <= 1 then + ratioX = 1 +end +local offsetX = (ratioX - 1) * (overlayGetWidth() / 2) +---- + +[#ratiogety] +==== ratioGetY + +[source,text] +---- +factor = ratioGetY() +---- + +Hypseus extension. The vertical half of the pair: the number given as +`--yratio`, or `0` when the option was not used. See <>. + +*Returns:* number. + +*Since:* 3.00 (Hypseus) +*See also:* <>, <> + +.Example +[source,lua] +---- +local ratioY = ratioGetY() +if ratioY <= 1 then + ratioY = 1 +end +---- + === Controller -Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise. +Singe supports up to four game controllers, indices `0` through `3`, opened automatically at startup and whenever one is plugged in or removed; there is no `controllerLoad`. Analog axes are reported through `onControllerMoved` (see <>) and polled with `controllerGetAxis`, in SDL's raw range of `-32768` to `32767`. Digital buttons, and each direction of each axis, are switches like keys: they reach `onInputPressed` and `onInputReleased` when `controls.cfg` maps a code from the `GAMEPAD_0` through `GAMEPAD_3` tables (for example `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), and `controllerGetButton` polls a button's live state. `SINGE_DEAD_ZONE`, from the `DEAD_ZONE` entry in `controls.cfg`, is the threshold the engine itself uses before an axis direction counts as a pressed switch; treat axis motion below it as noise. One axis at a time may be given a different threshold with `DEAD_ZONES`, and the analogue triggers a threshold of their own with `TRIGGER_THRESHOLD` (read back as `SINGE_TRIGGER_THRESHOLD`); see <>. A device SDL does not recognise as a gamepad is opened as one anyway, through a mapping Singe writes for it, so an arcade encoder or a flight stick appears in these slots like any pad -- see <>. + +[#controllerdorumble] +==== controllerDoRumble + +[source,text] +---- +controllerDoRumble(strength, duration) +controllerDoRumble(controller, strength, duration) +---- + +Hypseus extension. Rumbles a controller with both motors at one of four strengths for one of four durations. The two argument form addresses controller `0`. A slot with no controller does nothing (Hypseus aborts there, so its games guard with `controllerIsValid`); a strength or duration outside `1` to `4`, or an index outside `0` to `3`, aborts the script. A controller without rumble support ignores the call. + +*Parameters:* + +* `controller` -- controller index, `0` through `3`. +* `strength` -- `1` to `4`, a quarter to full strength. +* `duration` -- `1` to `4`, giving 150, 300, 600 or 1200 ms. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A short kick on every hit, a long full-strength one when the player dies. +function onHit() + if controllerIsValid(0) then + controllerDoRumble(0, 2, 1) + end +end + +function onPlayerDied() + if controllerIsValid(0) then + controllerDoRumble(0, 4, 4) + end +end +---- [#controllergetaxis] ==== controllerGetAxis @@ -3713,11 +4586,130 @@ function onOverlayUpdate() end ---- +[#controllergetname] +==== controllerGetName + +[source,text] +---- +name = controllerGetName(controller) +---- + +The name of the device in a controller slot, as SDL reports it, or `nil` when the slot is empty. A device SDL did not recognise reports its own name through the mapping Singe wrote for it, so a service screen can list an arcade encoder by name as readily as a branded pad. An index outside `0` to `3` aborts the script. + +*Parameters:* + +* `controller` -- controller index, `0` through `3`. + +*Returns:* string, or `nil`. + +*Since:* 3.00 +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A service screen listing what is plugged in. +for slot = 0, 3 do + if controllerIsValid(slot) then + fontPrint(20, 20 + slot * 18, string.format("Pad %d: %s", slot + 1, controllerGetName(slot))) + else + fontPrint(20, 20 + slot * 18, string.format("Pad %d: not connected", slot + 1)) + end +end +---- + +[#controllerhowmany] +==== controllerHowMany + +[source,text] +---- +count = controllerHowMany() +---- + +Hypseus extension. How many gamepads are open, of the four slots Singe has. Since every joystick Singe can open becomes a gamepad (see <>), an arcade encoder or a spinner is counted here as well. + +The count changes as devices are plugged in and out, so read it when you need it rather than once at startup. It says nothing about which slots are filled: a device may leave slot 0 empty while slot 1 is in use, so <> is still the way to ask about a particular slot. + +*Parameters:* none. + +*Returns:* number, `0` to `4`. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Offer two player only when there is something for player two to hold. +local twoPlayer = controllerHowMany() >= 2 +---- + +[#controllerisvalid] +==== controllerIsValid + +[source,text] +---- +valid = controllerIsValid(controller) +---- + +Hypseus extension. Returns whether a controller is open in the slot right now; controllers come and go with hot plugging, so ask before every `controllerDoRumble`. An index outside `0` to `3` aborts the script. + +*Parameters:* + +* `controller` -- controller index, `0` through `3`. + +*Returns:* boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Show the pad hints only when there is a pad to hint about. +function drawAttract() + if controllerIsValid(0) then + fontPrint(HINT_X, HINT_Y, "Press A to start") + else + fontPrint(HINT_X, HINT_Y, "Press 1 to start") + end +end +---- + [#debug] === Debug Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough. +[[traceheader]] +==== The Trace Header + +Whichever of `--program` and `--trace` opened it, `trace.txt` begins with a +block describing the machine and the run, between two rules so it can be pasted +whole into a bug report: + +---- +---------------------------------------------------------------- +Singe: v3.00 +Command: ./Singe -w -p -d data --screen 2 ActionMax +OS: Linux 6.8.0-124-generic (x86_64) +CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz, 8 cores, 20177 MB +Renderer: gpu, GPU driver vulkan +Decoder: hardware vaapi or vdpau where the codec allows, software otherwise +SDL: built 3.4.16, linked 3.4.16 +Settings: haptic=0 screen=2 volume_vldp=80 +Game: ActionMax/ActionMax.singe +---------------------------------------------------------------- +---- + +`Renderer` is the renderer that was actually created and the GPU backend it +chose, or a note that there is no GPU device and 3D is unavailable. `Decoder` +is what will be tried for video on this machine; which decoder a particular +video actually got is traced as that video opens, a few lines further down, +since a codec may offer no hardware path. `Settings` lists what the settings +file supplied, `none` when there was none. Every game in a queue gets its own +file and its own header. + [#debugprint] ==== debugPrint @@ -3820,6 +4812,40 @@ function startGame() end ---- +[#discaudiosuffix] +==== discAudioSuffix + +[source,text] +---- +opened = discAudioSuffix(suffix) +---- + +Hypseus extension. Switches the disc to a separate audio file beside its video: `.ogg`, where the base is the video file's name without its extension, so `movie.m2v` with `"-es"` plays `movie-es.ogg`. The file is opened at the current frame and playback realigns, as `discSetAudioTrack` does, keeping the volume, the play state and the track index when the new file has that many tracks. With a frame file every segment switches. An empty suffix restores the default audio: the `.ogg` beside an `.m2v`, or the audio inside any other container. Returns `true` when the file opened; when it is missing the previous audio keeps playing, nothing is remembered, and `false` comes back (Hypseus leaves silence in that case). The suffix is forgotten when the script reloads. Without a disc the call returns `false`. Use it for a language menu whose tracks ship as separate files; tracks inside one file are the job of `discSetAudioTrack`. + +*Parameters:* + +* `suffix` -- string appended to the video's base name, such as `"-es"`; `""` for the default audio. + +*Returns:* boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Language files ship as movie.ogg, movie-es.ogg and movie-fr.ogg. +languages = { { "English", "" }, { "Spanish", "-es" }, { "French", "-fr" } } + +function applyLanguage(index) + if not discAudioSuffix(languages[index][2]) then + discAudioSuffix("") + index = 1 + end + settings.language = index +end +---- + [#discchangespeed] ==== discChangeSpeed @@ -5523,6 +6549,39 @@ function onOverlayUpdate() end ---- +[#guidrawscreen] +==== guiDrawScreen + +[source,text] +---- +guiDrawScreen(gui) +guiDrawScreen(gui, x, y) +guiDrawScreen(gui, x, y, width, height) +---- + +The same as <>, except that the rectangle is measured across the whole window instead of across the picture. The coordinates are still overlay coordinates -- `0, 0` is the top left of the window and `overlayGetWidth(), overlayGetHeight()` the bottom right -- so a GUI can sit on the bezel artwork beside the picture, above it, or below it. With no bezel, and with no `--scalefactor`, `--shiftx` or `--shifty`, the picture is the whole window and this is exactly `guiDraw`. + +Screen space GUIs draw after everything else, the bezel artwork included, and share the sixteen draws a frame with `guiDraw`. A click finds them the same way: the pointer is carried into screen space before it is tested, so a screen space GUI that takes input receives the mouse wherever it was drawn. + +*Parameters:* + +* `x`, `y` -- the top left corner, in overlay coordinates measured across the window. +* `width`, `height` -- the size to draw at; the GUI's own size when left out. + +*Since:* 3.00. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A map on the left hand pillar of the cabinet artwork, outside the picture. +function onOverlayUpdate() + overlayClear() + guiDrawScreen(mapGui, 8, 44, 38, 114) + return OVERLAY_UPDATED +end +---- + [#guigetheight] ==== guiGetHeight @@ -6024,11 +7083,96 @@ function onCollision(a, b, x, y, z, speed) end ---- +[#joystick] +=== Joystick + +Every controller Singe opens is a gamepad and is read through the `controller*` +calls; a device SDL does not recognise reaches them through a mapping Singe +writes for it, so there is nothing else to call. See <>. The two calls here are about letting a stick stand in for a mouse. + +[#joymouseenable] +==== joyMouseEnable + +[source,text] +---- +joyMouseEnable(enabled) +---- + +Hypseus extension. Turns joystick-to-mouse on or off while the game runs. With it on, the left stick of gamepad slot `0` drives the pointer the game sees, through the same `onMouseMoved` a real mouse reaches, held inside the picture; `--js_range` is the speed and the axis obeys its own dead zone. It is what makes a light gun game playable on a cabinet with a stick and no mouse. + +It starts from `--joymouse`, which is off by default, and a `singeReload` puts it back there. Singe differs from Hypseus in that default: the shipped `controls.cfg` already binds that stick to the four direction switches, and driving the cursor as well without being asked would surprise every existing game. + +*Parameters:* + +* `enabled` -- boolean. + +*Returns:* nothing. + +*Since:* 3.00 (Hypseus) +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A gun game that lets the player aim with the stick when no mouse is plugged in. +if mouseHowMany() == 0 then + joyMouseEnable(true) +end +---- + +[#joymouseisenabled] +==== joyMouseIsEnabled + +[source,text] +---- +enabled = joyMouseIsEnabled() +---- + +Whether the stick is driving the mouse cursor right now, whether that was decided by `--joymouse` or by `joyMouseEnable`. + +*Returns:* boolean. + +*Since:* 3.00 +*See also:* <> + [#keyboard] === Keyboard Singe reads the keyboard in one of two modes chosen with `keyboardSetMode`. In `MODE_NORMAL` (the default) keys are translated through the mappings in `controls.cfg` into the `SWITCH_*` values that reach `onInputPressed` and `onInputReleased`, and unmapped keys are ignored; in `MODE_FULL` every key reaches the script raw, through `onKeyPressed` and `onKeyReleased` as well as `onInputPressed` and `onInputReleased`. Whatever the mode, `keyboardIsDown`, `keyboardGetLastDown` and `keyboardGetLastUp` poll the live keyboard state, which is what the threaded model wants (see <>). A scancode names a physical key position (the `A` key is scancode `4` on every layout) while a keysym is the character the key produces; the `SCANCODE` table in `Framework.singe` names every scancode, `keyboardIsDown` takes scancodes, and `onKeyPressed` delivers both. The callbacks themselves are described under <>. +[#keyboardcatchquit] +==== keyboardCatchQuit + +[source,text] +---- +keyboardCatchQuit(catch) +---- + +Hypseus extension. Hands the quit switch to the script, exactly as `singeSetQuitKeyEnabled(not catch)` does: `keyboardCatchQuit(true)` stops the engine quitting on `INPUT_QUIT`, and `false` returns it. It is a function defined in `Framework.singe`, kept so games written for Hypseus Singe run unchanged. + +*Notes:* Legacy alias. + +WARNING: New code should call <>. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Hypseus game code; the modern spelling is singeSetQuitKeyEnabled(false). +if keyboardCatchQuit ~= nil then + keyboardCatchQuit(true) +end + +function onInputPressed(what) + if what == SWITCH_QUIT then + askToQuit() + end +end +---- + [#keyboardgetlastdown] ==== keyboardGetLastDown @@ -7535,6 +8679,35 @@ function cutsceneEnd() end ---- +[#mousegetname] +==== mouseGetName + +[source,text] +---- +name = mouseGetName(mouse) +---- + +The name the mouse driver gives a device, or `nil` when there is no device with that index. Two guns on a cabinet are told apart by it on a service screen, which is otherwise guesswork when both report as mouse `0` and `1`. An index outside `0` to `3` aborts the script. + +*Parameters:* + +* `mouse` -- device index, `0` through `3`. + +*Returns:* string, or `nil`. + +*Since:* 3.00 +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Name the guns on the calibration screen. +mouseSetMode(MOUSE_MANY) +for device = 0, mouseHowMany() - 1 do + fontPrint(20, 20 + device * 18, string.format("Gun %d: %s", device + 1, mouseGetName(device))) +end +---- + [#mousegetposition] ==== mouseGetPosition @@ -7662,7 +8835,7 @@ end mouseSetMode(mode) ---- -Chooses how mice are read. In `MOUSE_SINGLE` (the default) the window's pointer events drive one cursor reported as device `0`, with absolute positions and relative motion both converted to overlay coordinates. In `MOUSE_MANY` each physical device is read separately: relative motion is integrated into a per-device position clamped to the video area, absolute devices such as tablets and some lightguns are mapped onto the video, and `onMouseMoved` reports the device index. Both modes deliver the same five buttons and the vertical wheel as switches. `SINGLE_MOUSE` and `MANY_MOUSE` are accepted as older spellings; any other value aborts the script. +Chooses how mice are read. In `MOUSE_SINGLE` (the default) the window's pointer events drive one cursor reported as device `0`, with absolute positions and relative motion both converted to overlay coordinates. In `MOUSE_MANY` each physical device is read separately: relative motion is integrated into a per-device position clamped to the video area, absolute devices such as tablets and some lightguns are mapped onto the video, and `onMouseMoved` reports the device index. Both modes deliver the same five buttons and the vertical wheel as switches. `SINGLE_MOUSE` and `MANY_MOUSE` are accepted as older spellings; any other value aborts the script. `--manymouse` is the user's own decision and outranks this call: with it given the mode is `MOUSE_MANY` from the start and `mouseSetMode(MOUSE_SINGLE)` does not switch it off, so a two gun cabinet can be made to work from outside a game that never asks. *Parameters:* @@ -7685,6 +8858,235 @@ function onMouseMoved(x, y, xr, yr, which) end ---- +[#musicref] +=== Music + +Background music, loaded and played apart from the sound effects and carrying its own volume. Hypseus runs music on a second mixer for exactly that reason, and Singe gives it its own gain instead: <> never moves a sound effect and <> never moves the music. + +A music handle is its own kind of handle, not a sound handle and not a channel: one handle is one track, and it plays, pauses, resumes and stops on its own. There is no channel limit, unlike the sixteen sound effect channels. + +The volume scale is Hypseus's, `0` to `128`, twice the `0` to `63` the `sound` family uses, so a game written for Hypseus keeps its own numbers. Music starts as loud as the sound effects do, at whatever `--volume_nonvldp` set; Hypseus instead starts its music mixer a third of the way up, which every Hypseus game immediately overwrites with `musicSetVolume(soundGetVolume() * 2)`. + +Every format the sound family reads is read here: WAV, Ogg Vorbis, Opus, FLAC, MP3, WavPack and the rest. The trace header written by `--program` and `--trace` lists exactly which decoders the build carries, on its `Audio:` line. + +Pausing the engine (the pause key, or `singeSetPauseFlag`) pauses the music with it, and resuming brings it back; a reload stops and frees every track. + +[#musicisplaying] +==== musicIsPlaying + +[source,text] +---- +playing = musicIsPlaying([id]) +---- + +Hypseus extension. Whether that track is playing, or -- with no handle -- whether any music is. A paused track is not playing. + +*Parameters:* + +* `id` -- optional number; a music handle. + +*Returns:* boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +if not musicIsPlaying(theme) then + musicPlay(theme, -1) +end +---- + +[#musicload] +==== musicLoad + +[source,text] +---- +id = musicLoad(filename) +---- + +Hypseus extension. Loads a music track through the virtual file system and returns its handle. The whole file is decoded up front, as a sound is, so a long track costs memory; the handle is valid until <> or a reload. + +A file that is missing, or that no compiled-in decoder can read, terminates the script, as <> does. + +*Parameters:* + +* `filename` -- string; a path the virtual file system can reach. + +*Returns:* number, the music handle. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +local theme = musicLoad("music/attract.mp3") +musicSetVolume(96) +musicPlay(theme, -1) +---- + +[#musicpause] +==== musicPause + +[source,text] +---- +musicPause([id]) +---- + +Hypseus extension. Pauses that track where it is, or every piece of music when no handle is given. <> picks it up again. + +*Parameters:* + +* `id` -- optional number; a music handle. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- The player opened the map; hold the music rather than restart it. +musicPause() +---- + +[#musicplay] +==== musicPlay + +[source,text] +---- +musicPlay(id [, loops]) +---- + +Hypseus extension. Plays a track from its beginning. `loops` means what <>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there. + +Playing a track that is already playing starts it again from the beginning. + +*Parameters:* + +* `id` -- number; a music handle. +* `loops` -- optional number; `-1` to `64`. Default `0`. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +musicPlay(theme, -1) -- Round and round until something stops it. +---- + +[#musicresume] +==== musicResume + +[source,text] +---- +musicResume([id]) +---- + +Hypseus extension. Resumes a paused track, or every paused piece of music when no handle is given. + +Hypseus's own zero argument form does nothing at all; Singe's resumes everything, which is what pairs with <>. + +*Parameters:* + +* `id` -- optional number; a music handle. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +.Example +[source,lua] +---- +musicResume() +---- + +[#musicsetvolume] +==== musicSetVolume + +[source,text] +---- +volume = musicSetVolume(volume) +---- + +Hypseus extension. Sets the gain over every piece of music at once, on Hypseus's `0` to `128` scale, and answers the level it took. It moves nothing but music: the sound effects keep whatever <> gave them, and `soundSetVolume` leaves the music alone. + +A level outside `0` to `128` terminates the script. The level applies to tracks already playing and to any loaded afterwards, and goes back to the starting level on a reload. + +*Parameters:* + +* `volume` -- number; `0` to `128`. + +*Returns:* number, the level that was set. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- What a Hypseus game writes: the sound level, doubled onto the music scale. +musicSetVolume(soundGetVolume() * 2) +---- + +[#musicstop] +==== musicStop + +[source,text] +---- +musicStop([id [, fadeMs]]) +---- + +Hypseus extension. Stops that track, or every piece of music when no handle is given. `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script. + +*Parameters:* + +* `id` -- optional number; a music handle. +* `fadeMs` -- optional number; milliseconds to fade out over. Default `0`. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +musicStop(theme, 1500) -- Away over a second and a half. +---- + +[#musicunload] +==== musicUnload + +[source,text] +---- +musicUnload(id) +---- + +Hypseus extension. Stops the track and frees it. The handle is invalid afterwards and using it terminates the script. + +*Parameters:* + +* `id` -- number; a music handle. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +.Example +[source,lua] +---- +musicUnload(theme) +---- + [#nav] === Nav @@ -9297,6 +10699,35 @@ end The overlay is a 32-bit RGBA surface composited over the video every frame; all 2D drawing lands on it. Its coordinates are the overlay resolution set by `overlaySetResolution`, which defaults to half the video's width and height, and are scaled to the window at display time. Draw from `onOverlayUpdate` (or from `singeMain` in the <> model) and return `OVERLAY_UPDATED` when something changed. The `overlay*` primitives draw one-pixel outlines in the current foreground color and silently clip whatever falls outside the surface. `overlayPrint` is the exception: it takes character-cell coordinates and copies the built-in console font as is, ignoring the color settings. +[#overlaybanner] +==== overlayBanner + +[source,text] +---- +overlayBanner(text [, height]) +---- + +Hypseus extension. Shows a short message over the picture for about thirty drawn frames and then takes it away again. `height` is where it sits, as a per cent down the picture, from 1 to 95; the default is 47, which is Hypseus's own, and a number outside that range is replaced by the default rather than refused. + +A message longer than 60 characters is ignored, exactly as Hypseus ignores it. The banner is drawn by the same shipped document the subtitles use (`Singe/subtitle.rml`, styled by `Singe/subtitle.rcss`), so it looks like a subtitle and is styled with them; while it is up it takes the place of any subtitle, and the subtitle comes back when it times out. The text is drawn as text, so `<`, `>` and `&` reach the screen as themselves. + +*Parameters:* + +* `text` -- string; up to 60 characters. +* `height` -- optional number; 1 to 95, a per cent down the picture. Default 47. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Tell the player what just happened without building a HUD for it. +overlayBanner("Extra life!", 20) +---- + [#overlaybox] ==== overlayBox @@ -9560,6 +10991,33 @@ function onOverlayUpdate() end ---- +[#overlaysetmonochrome] +==== overlaySetMonochrome + +[source,text] +---- +overlaySetMonochrome(enabled) +---- + +Hypseus extension. Shows the overlay in grey: before each upload the engine converts a copy of it to luma with the BT.601 weights, keeping the alpha channel as it is (Hypseus thresholds it). The overlay's own pixels stay in colour, so nothing the script reads back changes, and drawing goes on as usual. Pair it with `vldpSetMonochrome` for a black and white mode. A non-boolean argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- One setting drives both the disc and the overlay. +function applyChroma(colour) + vldpSetMonochrome(not colour) + overlaySetMonochrome(not colour) +end +---- + [#overlaysetresolution] ==== overlaySetResolution @@ -9590,6 +11048,99 @@ MARQUEE_X = overlayGetWidth() - MARGIN_X - MARQUEE_W CABINET_H = overlayGetHeight() - MARGIN_Y - CABINET_Y ---- +[#setoverlaylinearscale] +==== setOverlayLinearScale + +[source,text] +---- +setOverlayLinearScale(enabled) +---- + +Hypseus extension. Chooses how the overlay is filtered on its way to the window. With `true`, the default and what a reload restores, it is sampled linearly, so a low resolution overlay softens rather than blocking up; with `false` the nearest pixel is taken, which keeps pixel art, one-pixel outlines and small text crisp. Linear is the default because it is how every Singe release has drawn the overlay; Hypseus defaults to the nearest pixel instead, so a game moved from Hypseus that never calls this will look slightly softer until it does. Nothing about the drawing changes, only the final scale from the overlay resolution to the window, so everything on the overlay -- the primitives, fonts, sprites and the 3D layer -- goes through the one filter together, and the disc picture behind it is untouched. The setting survives `overlaySetResolution`, which replaces the overlay. A non-boolean argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A 320x240 overlay on a 4K screen; smooth the artwork, sharpen the text. +overlaySetResolution(320, 240) +setOverlayLinearScale(true) +... +setOverlayLinearScale(false) +overlayPrint(1, 1, "CREDITS 2") +---- + +[#setoverlayontop] +==== setOverlayOnTop + +[source,text] +---- +loaded, id = setOverlayOnTop(enabled) +---- + +Hypseus extension. Chooses whether the overlay and the GUIs drawn over the picture are above the bezel artwork or below it. Only artwork drawn in front of the picture (`--bezelflip`) is ever above them, so with the artwork behind -- which is the default -- this changes nothing. `false` is the setting a game starts with, and Hypseus's own default. It is remembered until the script reloads. + +The setting is only recorded while artwork is loaded; without it the call does nothing and answers `false` and `"0"`, exactly as Hypseus does. A non-boolean argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean; `true` puts the overlay and the GUIs above the artwork. + +*Returns:* boolean, whether artwork is loaded, and its identifier as a string (`"0"` when none is). + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- The cabinet frame overlaps the picture, but the score line has to stay readable. +if mainBezelLoaded() then + setOverlayOnTop(true) +end +---- + +[#setoverlayopacity] +==== setOverlayOpacity + +[source,text] +---- +setOverlayOpacity(alpha) +---- + +Hypseus extension. How solid the whole overlay is, from `0` (invisible) to `255` (as the game drew it). It multiplies whatever alpha the overlay's own pixels already carry, so a half transparent sprite stays half as solid as the rest; it does not change the overlay's contents, and nothing has to be redrawn. + +The level is clamped to `0` to `255` rather than refused, and a script starts at `255`. It is remembered until the script reloads, and it survives a change of overlay resolution. + +The GUIs, the 3D scene and the bezel artwork are drawn separately and are not affected. + +*Parameters:* + +* `alpha` -- number; `0` to `255`. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Fade the whole heads-up display out over half a second. +local fade = 255 +function onOverlayUpdate() + fade = math.max(0, fade - 8) + setOverlayOpacity(fade) + return OVERLAY_UPDATED +end +---- + [#physicsref] === Physics @@ -11694,6 +13245,38 @@ function endInitials() end ---- +[#singesetquitkeyenabled] +==== singeSetQuitKeyEnabled + +[source,text] +---- +singeSetQuitKeyEnabled(enabled) +---- + +Hypseus extension. Chooses whether the engine quits on the switch mapped to `INPUT_QUIT` in `controls.cfg`, from the keyboard or a controller. While enabled (the default, restored on every reload) pressing it ends the game. While disabled the engine ignores it, so a game can confirm or save first; the script receives `SWITCH_QUIT` through `onInputPressed` and `onInputReleased` either way, and `singeQuit` still ends the game. The window's close button always quits. `Framework.singe` defines `keyboardCatchQuit(catch)`, Hypseus Singe's name for the same setting with the opposite sense. A non-boolean argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Ask before leaving a game in progress. +singeSetQuitKeyEnabled(false) + +function onInputPressed(what) + if what == SWITCH_QUIT then + if confirmQuit() then + singeQuit() + end + end +end +---- + [#singeversion] ==== singeVersion @@ -12137,6 +13720,38 @@ coinClip = soundLoad(DIR .. "sounds/coin.wav") ambientClip = soundLoad(DIR .. "sounds/wind.wav") ---- +[#soundloaddata] +==== soundLoadData + +[source,text] +---- +id = soundLoadData(data) +---- + +Hypseus extension. Builds a sound from bytes held in a Lua string instead of from a file. Every format <> reads is read here, and the handle it returns is used with the rest of the `sound` family in exactly the same way. + +The string may hold zero bytes; its length, not its first zero, decides how much is read. The bytes are decoded during the call and the string is not kept, so it may be discarded afterwards. Bytes that are not audio, or an empty string, terminate the script, as an unreadable file does in `soundLoad`. + +Use it for audio a game generates, unpacks or decrypts for itself. Audio the virtual file system can reach by name -- including audio inside a packed `.game` -- is better loaded with `soundLoad`, which does not hold the whole file in a Lua string. + +*Parameters:* + +* `data` -- string of bytes. + +*Returns:* number, the sound handle. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A sound effect the game unpacks from its own archive. +local bytes = myArchive:extract("beep.wav") +local beep = soundLoadData(bytes) +soundPlay(beep) +---- + [#soundpause] ==== soundPause @@ -12453,6 +14068,8 @@ end Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text with `fontToSprite`, and drawn onto the overlay in overlay coordinates. Every `sprite*` call takes the integer handle as its first argument (Singe 2.10 put it last; see <>), and a handle that has been unloaded ends the script with an error. Any format SDL_image reads is accepted; an animated GIF or WEBP with two or more frames loads as an animation that sits on frame `0`, stopped and non-looping, until `spritePlay` and `spriteLoop` say otherwise. Pixels whose raw value is `0` in the image's own pixel format are drawn transparent (palette index `0`, or black in an RGB image without alpha), and a real alpha channel is honored as well. Release every sprite with `spriteUnload`, usually from <>. +*Sprite sheets.* Hypseus Singe extends the family with sprite sheets: `spriteLoadFrames` slices a single row strip of equal frames into an animation that is not playing, and `spriteDrawFrame` draws one chosen frame (1-based) without touching playback. A sheet is otherwise an ordinary animation, so `spriteSetFrame` (0-based), `spritePlay`, `spriteDraw`, `nodeSetSprite` and `emitterSetTexture` all accept it, and `spriteFrameWidth` and `spriteFrameHeight` report the unscaled size of one frame. The entries marked "Hypseus extension" exist so games written for Hypseus Singe run unchanged. + [#spritedraw] ==== spriteDraw @@ -12492,6 +14109,169 @@ function onOverlayUpdate() end ---- +[#spritedrawframe] +==== spriteDrawFrame + +[source,text] +---- +spriteDrawFrame(id, x, y, frame) +spriteDrawFrame(id, x, y, frame, scale) +spriteDrawFrame(id, x, y, frame, scaleX, scaleY) +---- + +Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`. + +*Parameters:* + +* `id` -- sprite handle from `spriteLoadFrames` or `spriteLoad`. +* `x`, `y` -- top-left corner in overlay coordinates; fractions are truncated. +* `frame` -- frame number, `1` to the frame count; fractions are truncated. +* `scale`, `scaleX`, `scaleY` -- size multipliers, `1` for natural size. + +*Notes:* `spriteSetFrame` counts frames from `0`; `spriteDrawFrame` counts from `1`. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <>, <> + +.Example +[source,lua] +---- +-- A ten frame digit strip draws the score, one digit per frame. +digits = spriteLoadFrames(10, DIR .. "digits.png") + +function drawScore(x, y, score) + local text = tostring(score) + for i = 1, #text do + spriteDrawFrame(digits, x + (i - 1) * spriteFrameWidth(digits), y, tonumber(text:sub(i, i)) + 1) + end +end +---- + +[#spritedrawgrid] +==== spriteDrawGrid + +[source,text] +---- +spriteDrawGrid(id, x, y, sourceX, sourceY, width, height) +---- + +Hypseus extension. Draws one rectangle out of a sprite, at its own size, with its top left corner at `x`, `y`. It is the only call that takes a source rectangle: <> stretches the whole sprite into a destination and <> takes a whole frame of a sheet, while this takes any rectangle at all -- which is how a game cuts a tile, a digit or a letter out of one image without loading it many times over. + +The rectangle is in the sprite's own pixels and must lie inside it; a rectangle that does not, or one with no width or height, terminates the script naming the sprite's size. The sprite drawn from is the sprite as it is currently drawn, so a <> or a <> changes what the coordinates mean. + +Under `LEGACY_SPRITE_ARGS` the handle is third, `spriteDrawGrid(x, y, spr, sourceX, sourceY, width, height)`, which is where Hypseus puts it. + +*Parameters:* + +* `id` -- number; a sprite handle. +* `x`, `y` -- numbers; where the rectangle's top left corner lands, in overlay coordinates. +* `sourceX`, `sourceY` -- numbers; the rectangle's top left corner in the sprite. +* `width`, `height` -- numbers; its size, at least 1. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A number drawn from one strip of ten digits, each 16 by 24. +local function drawNumber(sheet, x, y, value) + for i = 1, #value do + local digit = tonumber(value:sub(i, i)) + spriteDrawGrid(sheet, x + (i - 1) * 16, y, digit * 16, 0, 16, 24) + end +end +---- + +[#spritedrawrotatedframe] +==== spriteDrawRotatedFrame + +[source,text] +---- +spriteDrawRotatedFrame(id, x, y) +spriteDrawRotatedFrame(id, x, y, scale) +spriteDrawRotatedFrame(id, x, y, scaleX, scaleY) +---- + +Hypseus extension. Draws the frame <> last turned, *centred* on `x`, `y`. This is the pair to that call and the reason both exist: <> deliberately ignores a sprite's rotation and scale, so a game that wants one frame of a sheet turned uses these two instead. + +Given a scale, the turned frame is stretched by it as it is drawn -- on top of whatever scale it was turned with -- and the result is still centred on `x`, `y`. `scaleX` alone scales both axes. + +Drawing before any `spriteRotateFrame` terminates the script, since there is no turned frame to draw. + +Under `LEGACY_SPRITE_ARGS` the handle is last, as Hypseus writes it. + +*Parameters:* + +* `id` -- number; a sprite handle. +* `x`, `y` -- numbers; the *centre* of the drawn frame, in overlay coordinates. +* `scale` or `scaleX`, `scaleY` -- optional numbers; a further scale at draw time. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A compass needle: one frame of the sheet, pointing where the player is going. +spriteRotateFrame(needle, heading, 1) +spriteDrawRotatedFrame(needle, 320, 240) +---- + +[#spriteframeheight] +==== spriteFrameHeight + +[source,text] +---- +height = spriteFrameHeight(id) +---- + +Hypseus extension. Returns the height of one frame of the sprite before any scaling or rotation: the strip's height for a `spriteLoadFrames` sheet, a frame's height for an animation, and the image's height for a still (Hypseus reports the drawn surface here). `spriteGetHeight` reports the size as drawn instead. + +*Returns:* integer. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Centre a frame on a point. +function drawCentred(sheet, frame, x, y) + spriteDrawFrame(sheet, x - spriteFrameWidth(sheet) / 2, y - spriteFrameHeight(sheet) / 2, frame) +end +---- + +[#spriteframewidth] +==== spriteFrameWidth + +[source,text] +---- +width = spriteFrameWidth(id) +---- + +Hypseus extension. Returns the width of one frame of the sprite before any scaling or rotation: the strip's width divided by the frame count for a `spriteLoadFrames` sheet, a frame's width for an animation, and the image's width for a still (Hypseus reports `0` there). `spriteGetWidth` reports the size as drawn instead. + +*Returns:* integer. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Lay the frames of a strip out side by side. +function drawStrip(sheet, count, x, y) + for frame = 1, count do + spriteDrawFrame(sheet, x + (frame - 1) * spriteFrameWidth(sheet), y, frame) + end +end +---- + [#spritegetframe] ==== spriteGetFrame @@ -12522,6 +14302,35 @@ function onOverlayUpdate() end ---- +[#spritegetframes] +==== spriteGetFrames + +[source,text] +---- +count = spriteGetFrames(id) +---- + +Hypseus extension. How many frames a sprite has: the frame count of a sheet loaded by <> or of an animated GIF or WEBP, and `0` for a still image. It is the count <>'s zero based frame number runs up to, and one more than the largest it will take. + +*Parameters:* + +* `id` -- number; a sprite handle. + +*Returns:* number; `0` for a still image. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Step to the next frame, whatever the sheet turned out to be. +local frames = spriteGetFrames(walk) +if frames > 0 then + spriteSetFrame(walk, (spriteGetFrame(walk) + 1) % frames) +end +---- + [#spritegetheight] ==== spriteGetHeight @@ -12639,6 +14448,72 @@ spriteLoop(attractLoop, true) spritePlay(attractLoop) ---- +[#spriteloaddata] +==== spriteLoadData + +[source,text] +---- +id = spriteLoadData(data) +---- + +Hypseus extension. Builds a sprite from an image held in a Lua string instead of from a file. Every format <> reads is read here, animated GIF and WEBP included, and an animation loaded this way behaves exactly as one loaded from a file. + +The string may hold zero bytes; its length, not its first zero, decides how much is read. The image is decoded during the call and the string is not kept. Bytes that are not an image, or an empty string, terminate the script, as an unreadable file does in `spriteLoad`. + +Use it for artwork a game generates, unpacks or decrypts for itself. Artwork the virtual file system can reach by name -- including artwork inside a packed `.game` -- is better loaded with `spriteLoad`, which does not hold the whole file in a Lua string. + +*Parameters:* + +* `data` -- string of bytes. + +*Returns:* number, the sprite handle. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- An image the game keeps inside its own data file. +local bytes = myArchive:extract("logo.png") +local logo = spriteLoadData(bytes) +spriteDraw(logo, 10, 10) +---- + +[#spriteloadframes] +==== spriteLoadFrames + +[source,text] +---- +result = spriteLoadFrames(count, filename) +---- + +Hypseus extension. Loads an image holding `count` equal frames in a single row and returns a sprite handle. The frame width is the image width divided by `count` (integer division; leftover columns on the right are dropped) and the frame height is the image height. The sheet becomes an ordinary animation parked on frame `0`, not playing and not looping, so `spriteDraw` shows the current frame rather than the whole strip (as Hypseus draws it), and `spriteDrawFrame` picks any frame directly. The name goes through the virtual file system like `spriteLoad`, and pixels with a raw value of `0` become transparent in every frame. A count outside `2` to `512`, an image narrower than `count` pixels, or a file that cannot be read ends the script with an error. + +*Parameters:* + +* `count` -- number of frames across the image, `2` to `512`. +* `filename` -- the strip image. + +*Returns:* integer sprite handle. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <>, <> + +.Example +[source,lua] +---- +-- A five frame car strip; frame 3 is the car pointing straight ahead. +carSprite = spriteLoadFrames(5, DIR .. "car.png") +carWidth = spriteFrameWidth(carSprite) + +function onOverlayUpdate() + overlayClear() + spriteDrawFrame(carSprite, (overlayGetWidth() - carWidth) / 2, CAR_Y, carFrame) + return OVERLAY_UPDATED +end +---- + [#spriteloop] ==== spriteLoop @@ -12754,6 +14629,32 @@ spriteScale(marqueeSprite, 0.5) spriteScale(playerSprite, 3) ---- +[#spriteresetcolorkey] +==== spriteResetColorKey + +[source,text] +---- +spriteResetColorKey(id, enabled) +---- + +Hypseus extension. Accepted for compatibility and does nothing. In Hypseus the flag re-applies the transparent colour to the copies `spriteRotate` and `spriteScale` produce, which would otherwise draw their background opaque; Singe's transformed copies carry the transparency in their alpha channel, so there is nothing to re-key. A bad handle or a non-boolean argument aborts the script, as for every other sprite call. + +*Parameters:* + +* `id` -- sprite handle. +* `enabled` -- boolean, ignored. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Hypseus game code; harmless in Singe. +badge = spriteLoad(DIR .. "badge.png") +spriteResetColorKey(badge, true) +---- + [#spriterotate] ==== spriteRotate @@ -12819,6 +14720,41 @@ function onOverlayUpdate() end ---- +[#spriterotateframe] +==== spriteRotateFrame + +[source,text] +---- +spriteRotateFrame(id, degrees, frame) +---- + +Hypseus extension. Takes one frame out of a sprite sheet, turns it clockwise by `degrees` and scales it by the sprite's own <>, and keeps the result for <>. The sprite's own drawn surface is untouched, so <> and `spriteDrawFrame` go on showing what they showed. + +Frames are numbered from 1, as `spriteDrawFrame` numbers them (and unlike `spriteGetFrame` and `spriteSetFrame`, which count from 0); a frame outside the sheet is frame 1 rather than an error. A still image turns its whole picture. + +Turning is not free: the frame is redrawn pixel by pixel each time, so turn it when the angle changes rather than every frame. The angle is taken modulo 360, and <> decides whether the turned edges are smoothed. + +Under `LEGACY_SPRITE_ARGS` the handle is last, `spriteRotateFrame(degrees, frame, spr)`, as Hypseus writes it. + +*Parameters:* + +* `id` -- number; a sprite handle. +* `degrees` -- number; clockwise. +* `frame` -- number; 1 to the frame count. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A ship sprite whose hull is frame 1 and whose thrust is frame 2. +spriteRotateFrame(ship, angle, thrusting and 2 or 1) +spriteDrawRotatedFrame(ship, shipX, shipY) +---- + [#spritescale] ==== spriteScale @@ -12866,8 +14802,10 @@ spriteSetFrame(id, frame) Jumps an animation to the given frame and restarts that frame's delay timer, so the frame is shown for its full duration before the next one. Playback state is not changed: a paused sprite stays paused on the new frame and a playing one continues from it. The call is ignored for still images, for frames outside `0` to the last frame, and when the sprite already shows that frame. +*Notes:* Frames are numbered from `0` here; `spriteDrawFrame`, the Hypseus extension, numbers them from `1`. + *Since:* 2.10 -*See also:* <>, <> +*See also:* <>, <>, <> .Example [source,lua] @@ -12914,6 +14852,123 @@ function onShutdown() end ---- +[#subtitleref] +=== Subtitle + +SubRip subtitles over the disc picture. A game loads a `.srt` with <>, switches it on with <>, and the engine puts each cue up and takes it down again as the disc reaches it. See <> for the file format, the timing and how to restyle them. + +The subtitles and <> share one shipped document, `Singe/subtitle.rml`, styled by `Singe/subtitle.rcss`; a game or a theme may replace either. Both are drawn over the picture, above the overlay and below the screen space GUIs, and they take no mouse input. + +[#srtclear] +==== srtClear + +[source,text] +---- +srtClear() +---- + +Hypseus extension. Takes whatever is on screen off at once -- a cue or a banner -- and leaves the loaded cues and the enabled state alone. The cue that was showing stays off until the disc reaches the next one; nothing has to be reloaded and nothing has to be switched back on. + +*Parameters:* none. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A cutscene ends early; drop the line that was still up. +srtClear() +---- + +[#srtenable] +==== srtEnable + +[source,text] +---- +srtEnable(enabled) +---- + +Hypseus extension. Whether the loaded cues are shown as the disc plays. Switching it off takes any cue that is showing off with it; switching it on again puts up whatever cue covers the frame the disc is on. A script starts with subtitles off, so a game that loads them must switch them on. + +A non-boolean argument terminates the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +srtEnable(SETTINGS.subtitles) +---- + +[#srtload] +==== srtLoad + +[source,text] +---- +loaded = srtLoad(filename) +---- + +Hypseus extension. Reads a SubRip file through the virtual file system and turns its timestamps into disc frames at the disc's own frame rate. Loading replaces whatever was loaded before; <> then shows it. + +It answers `false`, having loaded nothing, when the file cannot be read, when it holds no cue that parses, or when there is no disc to time it against -- so a malformed file is a `false`, not a crash. A warning naming the file is printed in every one of those cases. Cues that do not parse inside an otherwise good file are skipped, as Hypseus skips them. + +Because the timestamps become frame numbers when the file is loaded, and the frame number is what the engine matches, a cue is found again wherever the disc is sent: a search, a skip or a step lands on the right line at once. + +*Parameters:* + +* `filename` -- string; a path the virtual file system can reach. + +*Returns:* boolean, whether any cue was loaded. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +if srtLoad("subs/" .. language .. ".srt") then + srtEnable(true) +end +---- + +[#srtposition] +==== srtPosition + +[source,text] +---- +srtPosition(percent) +---- + +Hypseus extension. Where a subtitle sits, as a per cent down the picture, from 1 to 95. The default is 80. A number outside that range is ignored, as Hypseus ignores it, rather than being clamped or refused. + +The height applies to the cue on screen straight away and to every cue after it. <> carries its own height and is not affected. + +*Parameters:* + +* `percent` -- number; 1 to 95. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Keep the lines clear of the scoreboard along the bottom. +srtPosition(70) +---- + [#terrain] === Terrain @@ -14318,6 +16373,69 @@ The `vldp*` namespace predates Singe 2.00 and exists to keep older scripts runni WARNING: *Legacy.* Prefer <> and <> in new code. `vldpGetPixel` remains current. +Hypseus Singe added eleven more members, kept under this prefix so its games run unchanged: `vldpFocusArea` and `vldpResetFocus` crop the disc picture, `vldpGetScale` and `vldpSetScale` report and change the display scale factor, `vldpGetRotate` and `vldpSetRotate` do the same for the presentation rotation, `vldpGetYUVPixel` reads raw samples, and `vldpFlash`, `vldpSetBlend`, `vldpSetLuma` and `vldpSetMonochrome` change how the picture itself looks. Each is marked "Hypseus extension" below. The scale and the rotation are two of the inputs to <>. + +The four picture controls work on the decoded frame, on its luma and chroma planes, before it reaches the screen: `vldpSetBlend` and `vldpSetLuma` are applied one after the other as each frame is uploaded, `vldpSetMonochrome` replaces the chroma, and `vldpFlash` replaces the picture outright for one frame. They cost a pass over the luma plane per frame while they are on and nothing at all while they are off, they leave `vldpGetPixel` and `vldpGetYUVPixel` reading the picture as it was decoded, and with a frame file they follow the playback from segment to segment. All four go back to off when the script reloads (except monochrome, which goes back to whatever `--monochrome` asked for) and none of them touches a video drawn with `videoDraw`. + +[#vldpflash] +==== vldpFlash + +[source,text] +---- +vldpFlash() +---- + +Hypseus extension. Puts one white picture on the disc, the way a real laserdisc player flashes when it is searched. It goes up at once and the next disc update takes it off again, so it lasts one drawn frame whether the disc is playing, paused or stopped. + +The white is the value Hypseus flashes, 90 per cent luma with neutral chroma, not pure white, which is what a video signal's white actually is. The overlay, the GUIs and the bezel artwork are drawn over it as usual, so a heads-up display stays readable through the flash. + +Without a disc, or on a disc whose first frame has not been decoded yet, the call does nothing. + +*Parameters:* none. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Punctuate a hit. +vldpFlash() +---- + +[#vldpfocusarea] +==== vldpFocusArea + +[source,text] +---- +vldpFocusArea(x, y, width, height) +vldpFocusArea() +---- + +Hypseus extension. Shows only a rectangle of the disc frame, given in disc pixels, stretched into the space the whole frame normally fills, with the renderer's usual smooth scaling; the aspect follows whatever `width` and `height` give. The rectangle is held inside the frame, and one that lies entirely outside it aborts the script. With no arguments the whole frame is shown again (Hypseus ignores that call and uses `vldpResetFocus`; Singe accepts both). The focus is a display effect only: `vldpGetPixel`, `vldpGetYUVPixel`, the mouse mapping and the disc on a 3D material all keep unfocused coordinates, so hit detection on a zoomed picture must be scaled by the caller. Without a disc the call does nothing. Reset when the script reloads. + +*Parameters:* + +* `x`, `y` -- top-left corner in disc pixels. +* `width`, `height` -- size of the rectangle in disc pixels. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Zoom into the middle 80% of the picture during the chase. +local w, h = vldpGetWidth(), vldpGetHeight() +vldpFocusArea(w * 0.1, h * 0.1, w * 0.8, h * 0.8) + +function onChaseEnded() + vldpFocusArea() +end +---- + [#vldpgetheight] ==== vldpGetHeight @@ -14382,6 +16500,58 @@ function onInputPressed(what) end ---- +[#vldpgetrotate] +==== vldpGetRotate + +[source,text] +---- +degrees = vldpGetRotate() +---- + +Hypseus extension. Returns the presentation rotation in degrees clockwise: the `--rotate` option, or whatever `vldpSetRotate` last set, and `0` when neither has. Only `0`, `90`, `180` and `270` are ever returned, because those are the only angles Singe turns to. + +*Returns:* integer, `0`, `90`, `180` or `270`. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A portrait cabinet wants its score panel down the long side. +if vldpGetRotate() == 90 or vldpGetRotate() == 270 then + layoutPanel(PANEL_TALL) +else + layoutPanel(PANEL_WIDE) +end +---- + +[#vldpgetscale] +==== vldpGetScale + +[source,text] +---- +scale = vldpGetScale() +---- + +Hypseus extension. Returns the display scale factor as a percentage: the `--scalefactor` option, or whatever `vldpSetScale` last applied, and `100` when neither has been given. Hypseus games divide `100` by it to size their overlay geometry to the shrunken picture. It has nothing to do with `vldpFocusArea` or the overlay resolution. + +*Returns:* integer, `25` to `100`. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Hypseus game code: the gun sights scale with the picture. +if vldpGetScale ~= nil then + gunScale = 100 / vldpGetScale() +else + gunScale = 1 +end +---- + [#vldpgetwidth] ==== vldpGetWidth @@ -14410,6 +16580,208 @@ centerX = vldpGetWidth() / 2 centerY = vldpGetHeight() / 2 ---- +[#vldpgetyuvpixel] +==== vldpGetYUVPixel + +[source,text] +---- +y, u, v = vldpGetYUVPixel(x, y) +---- + +Hypseus extension. Reads the raw samples of one pixel of the disc frame currently shown, before any colour conversion: the luma and the two chroma samples, each `0` to `255`, with chroma at the 4:2:0 resolution of the decoded picture (neutral is `128`). The coordinates are overlay coordinates, scaled and truncated exactly as `vldpGetPixel` does. Returns `-1, -1, -1` when there is no disc, when no frame has been decoded yet, or when the scaled coordinates fall outside the video. Unaffected by `vldpFocusArea` and `vldpSetMonochrome`. Cheaper than `vldpGetPixel` when only the brightness matters. + +*Parameters:* + +* `x`, `y` -- overlay coordinates; fractional values are accepted. + +*Returns:* three integers, `Y`, `U` and `V`, each `0` to `255`, or `-1, -1, -1`. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- The road is dark; anything bright under the car is an obstacle. +function carHitsSomething() + local y = vldpGetYUVPixel(carX + carWidth / 2, carY + carHeight) + return y >= 120 +end +---- + +[#vldpresetfocus] +==== vldpResetFocus + +[source,text] +---- +vldpResetFocus() +---- + +Hypseus extension. Shows the whole disc frame again after `vldpFocusArea`, exactly as `vldpFocusArea()` with no arguments does. Hypseus's own name for the reset, kept so its games run unchanged. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <> + +.Example +[source,lua] +---- +function onChaseEnded() + vldpResetFocus() +end +---- + +[#vldpsetblend] +==== vldpSetBlend + +[source,text] +---- +vldpSetBlend(enabled) +---- + +Hypseus extension. Smooths the disc picture down its rows: each luma sample becomes the average of itself and the samples immediately above and below it. This is Hypseus's own three tap blend filter, and it exists to take the hard edges off an interlaced transfer without softening the picture sideways. + +The chroma planes are left alone, so the smoothing changes the brightness detail and not the colour. Turning it off puts the picture back exactly as it was decoded. + +Without a disc the call does nothing but is still remembered, so it takes effect if a disc is loaded later. A non-boolean argument terminates the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A service screen switch for a noisy transfer. +vldpSetBlend(SETTINGS.softenVideo) +---- + +[#vldpsetluma] +==== vldpSetLuma + +[source,text] +---- +vldpSetLuma(enabled [, level]) +---- + +Hypseus extension. Brightens or darkens the disc picture. `level` runs from 0 to 8 and 4, the default, changes nothing: the distance from 4 is added to every luma sample in eighths, so 0 halves the brightness, 8 raises it by half, and the steps between are even. A level outside 0 to 8 terminates the script. + +Since only the luma is changed, the colour stays where it is: raising the level brightens the picture without washing the colour out, which is what a real player's picture control does and what a colour multiplier would not. Turning it off, or asking for level 4, puts the picture back exactly as it was decoded. + +`vldpSetLuma(true)` with no level therefore does nothing visible, which is Hypseus's behaviour as well. + +Without a disc the call does nothing but is still remembered, so it takes effect if a disc is loaded later. + +*Parameters:* + +* `enabled` -- boolean. +* `level` -- optional number; 0 to 8, 4 being neutral. Default 4. + +*Returns:* nothing. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A night scene the player cannot see; let them lift it. +vldpSetLuma(true, 6) +---- + +[#vldpsetmonochrome] +==== vldpSetMonochrome + +[source,text] +---- +vldpSetMonochrome(enabled) +---- + +Hypseus extension. Shows the disc in luma only: the chroma planes are replaced by neutral samples when each frame is uploaded, so a paused disc changes at once. With a frame file the setting follows the playback from segment to segment. `vldpGetPixel` and `vldpGetYUVPixel` keep returning the colour samples, and extra videos drawn with `videoDraw` are unaffected. Pair it with `overlaySetMonochrome` for a black and white mode. Without a disc the call does nothing; reset when the script reloads. A non-boolean argument aborts the script. + +*Parameters:* + +* `enabled` -- boolean. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A dip switch picks colour or black and white. +vldpSetMonochrome(not dipChroma) +overlaySetMonochrome(not dipChroma) +---- + +[#vldpsetrotate] +==== vldpSetRotate + +[source,text] +---- +applied = vldpSetRotate(degrees) +---- + +Hypseus extension. Turns the whole presentation clockwise: the disc, the overlay, the GUIs, the 3D scene and the particles all turn together, and mouse and light gun positions are turned back so a click still lands where it is drawn. A quarter turn swaps the shape of the presented area, so a portrait window is filled rather than letterboxed into a strip. + +Hypseus accepts any angle from `0` to `359`; Singe takes only `0`, `90`, `180` and `270`, and answers `false` for anything else instead of ending the script. An arbitrary angle would leave the mouse mapping and the Sinden border without a sensible meaning, and no game in the Hypseus library asks for one. + +The rotation costs one screen sized render target and one extra blit per frame while it is not zero, and nothing at all while it is. Reset to the `--rotate` value when the script reloads. + +*Parameters:* + +* `degrees` -- integer, `0`, `90`, `180` or `270`. + +*Returns:* boolean, true when the rotation was applied. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A vertical shooter section on a pivoting monitor. +if not vldpSetRotate(90) then + debugPrint("This build will not turn the screen.") +end +---- + +[#vldpsetscale] +==== vldpSetScale + +[source,text] +---- +applied = vldpSetScale(percent) +---- + +Hypseus extension. Shrinks the picture about its centre to `percent` of the window, the running equivalent of `--scalefactor`, and rebuilds the video rectangle so the disc, the overlay, the GUIs, the 3D scene, the particles, any Sinden border and the mouse mapping all follow it. Values outside `25` to `100` answer `false` and change nothing, rather than ending the script. + +Changes are limited to one every 15 milliseconds, as Hypseus limits its own held-key zoom; a call inside that window answers `false`, so a script driving the scale from a held key should keep calling until it takes. Reset to the `--scalefactor` value when the script reloads. + +*Parameters:* + +* `percent` -- integer, `25` to `100`. + +*Returns:* boolean, true when the scale was applied. + +*Since:* 3.00, as a Hypseus Singe extension. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Zoom out while the player holds the adjust key. +function onInputPressed(input) + if input == SWITCH_BUTTON3 then + vldpSetScale(vldpGetScale() - 5) + end +end +---- + [#vldpsetverbose] ==== vldpSetVerbose @@ -14488,7 +16860,7 @@ function onInputReleased(what) end ---- -Called when a logical input goes down or up. What arrives depends on the keyboard mode. In `MODE_NORMAL`, `what` is a `SWITCH_*` value: keys, controller buttons, axis directions past the dead zone, mouse buttons and the mouse wheel all arrive this way, translated through `controls.cfg`, each press once with no key repeat, and inputs mapped to no switch are ignored. `SWITCH_PAUSE` arrives only after `singeSetPauseKeyEnabled(false)`; otherwise the engine freezes the game on that key and the script never sees it. In `MODE_FULL`, `what` is the keysym of the key (the character value, not the scancode), repeats are delivered, and controller and mouse buttons pass `0`; every event also reaches `onKeyPressed` and `onKeyReleased`, which carry the scancode or button code. When the pause key freezes the game every switch the script holds is released through `onInputReleased` first, and on thawing whatever is still physically down is pressed again, so a game never sees a stale button. Presses within the first second after the script starts, or after the window regains focus, are treated as held over from before and swallowed together with their release. +Called when a logical input goes down or up. What arrives depends on the keyboard mode. In `MODE_NORMAL`, `what` is a `SWITCH_*` value: keys, controller buttons, axis directions past the dead zone, mouse buttons and the mouse wheel all arrive this way, translated through `controls.cfg`, each press once with no key repeat, and inputs mapped to no switch are ignored. `SWITCH_PAUSE` arrives only after `singeSetPauseKeyEnabled(false)`; otherwise the engine freezes the game on that key and the script never sees it. In `MODE_FULL`, `what` is the keysym of the key (the character value, not the scancode), repeats are delivered, and controller and mouse buttons pass `0`; every event also reaches `onKeyPressed` and `onKeyReleased`, which carry the scancode or button code. When the pause key freezes the game every switch the script holds is released through `onInputReleased` first, and on thawing whatever is still physically down is pressed again, so a game never sees a stale button. Presses within the first second after the script starts, or after the window regains focus, are treated as held over from before and swallowed together with their release. One switch arrives from no control at all: `SWITCH_MOUSE_DISCONNECT` is raised by the engine when a mouse or light gun is unplugged, once as a press and immediately once as a release, so a gun game can notice its gun going (see <>). *See also:* <>, <>, <>, <> @@ -14803,6 +17175,41 @@ end ---- [#unimplementedandlegacy] +=== Hypseus Compatibility + +Hypseus Singe registers a few names in its engine that Singe answers from +`Singe/Framework.singe` instead, so that a game written for Hypseus runs +unchanged. Each one is defined only when the engine does not already provide +it, so a future engine implementation wins. `keyboardCatchQuit` is documented +under <>; `setOverlayLinearScale` and `setOverlayOnTop` are +real engine calls under <>; `mainBezelLoaded` and the seven +`scoreBezel` calls are real engine calls under <>; and +`getFValue`, `ratioGetX` and `ratioGetY` are real engine calls under +<>; `joyMouseEnable` is a real engine call +under <>; and the whole `music` family is now a set of real +engine calls under <>. The six `spriteAnim` names below are +plain aliases set after the legacy sprite wrapping, so they take whichever +argument order the rest of the sprite family is taking: Hypseus's own (handle +last) under `LEGACY_SPRITE_ARGS`, and Singe 3.00's (handle first) without it. + +[cols="1,1",options="header"] +|=== +| Function | Provided by Framework.singe as +| `setOverlaySize(preset [, width, height])` | `overlaySetResolution` with a chosen size: `1` is the disc's video size, `3` is `360x240`, `4` is the size given (ignored when it is missing, as in Hypseus) and anything else, `2` included, is half the disc on both axes. Without a disc `discGetWidth` and `discGetHeight` report the canvas, which is what a disc-less game draws into. +| `setOverlayResolution(width, height)` | An alias of <>. Hypseus registers one function under both names. +| `rewriteStatus()` | Stub returning `false`. In Hypseus it reports whether asset paths are being rewritten for an `-espath` install; Singe's virtual file system finds a game's files where they were installed, so nothing is rewritten and the game keeps its own asset directory. +| `hypseusGetHeight()` | An alias of <>. +| `hypseusGetWidth()` | An alias of <>. +| `takeScreenshot()` | An alias of <>. Hypseus refuses a second screenshot within five seconds; Singe has no such throttle. +| `mouseHowManyReal()` | An alias of <>. Hypseus counts real mice apart from the ones it derives from a gamepad; Singe has no derived mice, so its count is already the real one. +| `spriteAnimGetFrame(spr)` | An alias of <>. +| `spriteAnimIsPlaying(spr)` | An alias of <>. +| `spriteAnimLoop(loop, spr)` | An alias of <>. +| `spriteAnimPause(spr)` | An alias of <>. +| `spriteAnimPlay(spr)` | An alias of <>. +| `spriteAnimSetFrame(frame, spr)` | An alias of <>. +|=== + === Unimplemented and Legacy A handful of API functions are retained for script-level compatibility but do nothing. They are safe to call; they simply log a trace message and return. Prefer the listed replacements for new code. diff --git a/src/embedded.h b/src/embedded.h index 482bb93b4..b17d402fd 100644 --- a/src/embedded.h +++ b/src/embedded.h @@ -31,11 +31,16 @@ #include "generated/singeLogo.h" #include "generated/Framework_singe.h" #include "generated/controls_cfg.h" +#include "generated/settings_cfg.h" #include "generated/Menu_singe.h" #include "generated/MenuClassic_singe.h" #include "generated/Menu_rml.h" #include "generated/menu_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/menuBackground_mkv.h" #include "generated/click_wav.h" diff --git a/src/frameFile.c b/src/frameFile.c index bb1192821..2253b5ea2 100644 --- a/src/frameFile.c +++ b/src/frameFile.c @@ -35,6 +35,9 @@ #define REPORT_COLUMN_WIDTH 8 +#define LEGACY_VIDEO_EXTENSION "m2v" // The one container whose audio is always a sidecar + + typedef struct FrameLineS { int32_t videoHandle; 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 videoGetVolume(oldHandle, &left, &right); videoSetVolume(newHandle, left, right); + videoSetMonochrome(newHandle, videoGetMonochrome(oldHandle)); track = videoGetAudioTrack(oldHandle); if ((track >= 0) && (track < videoGetAudioTracks(newHandle))) { videoSetAudioTrack(newHandle, track); @@ -127,6 +131,31 @@ static void _showCalculated(const FrameFileT *f) { } +// The sidecar audio file a video plays with: ".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) { FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileGetFrame"); int32_t i = 0; @@ -218,18 +247,10 @@ int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer if (!newFiles) { utilDie("Unable to allocate new framefile entry!"); } - files = newFiles; - files[count].frame = frame; - 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 = 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 = newFiles; + files[count].frame = frame; + files[count].filename = utilCreateString("%s%s", path, name); + audio = frameFileAudioName(files[count].filename, ""); files[count].videoHandle = videoLoad(files[count].filename, audio, indexPath, renderer, false); free(audio); count++; @@ -301,6 +322,35 @@ void frameFileSeek(int32_t frameFileHandle, int64_t seekFrame, int32_t *videoHan } +// Points every segment at the ".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) { FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileUnload"); int32_t i = 0; diff --git a/src/frameFile.h b/src/frameFile.h index 8f36e3a94..d0ffaf958 100644 --- a/src/frameFile.h +++ b/src/frameFile.h @@ -30,10 +30,12 @@ #include "common.h" +char *frameFileAudioName(const char *filename, const char *suffix); int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle); int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer *renderer, bool showCalculated); void frameFileQuit(void); 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 frameFileUpdate(int32_t frameFileHandle, int32_t *videoHandle); diff --git a/src/main.c b/src/main.c index f6650f73e..a852ae9ef 100644 --- a/src/main.c +++ b/src/main.c @@ -37,6 +37,10 @@ #include #ifndef _WIN32 #include +#include +#endif +#ifdef __APPLE__ +#include #endif #include @@ -61,13 +65,25 @@ #define MENU_OPTIONS "-k -w -v" +#define MENU_BINARY_PREFIX "Singe" // The launcher searches for this beside itself; the binary is named Singe-vX.YY-... #define PRIMARY_DISPLAY 0 #define MIXER_FREQUENCY 44100 #define MIXER_CHANNELS 2 #define MIXER_CHUNK_SAMPLES "1024" // Device buffer, kept small so the audio queue and any error in measuring it stay small -#define USAGE_OPTION_WIDTH 27 +#define USAGE_OPTION_WIDTH 28 // The longest long form is --trigger_threshold=PERCENT plus a space #define USAGE_LINE_WIDTH 79 // Help text wraps so no line is wider than this #define CRASH_FRAMES_MAX 64 +#define SCREEN_MIN 1 // --screen numbers the displays from 1, as Hypseus does +#define IDLE_EXIT_MIN 0 // --idleexit seconds; 0 never quits +#define IDLE_EXIT_MAX 86400 // ... and a day is as long as anyone can mean +#define RATIO_MIN 0.0 // --xratio and --yratio; 0 is "not given", Hypseus's own default +#define RATIO_MAX 100.0 +#define RATIO_PRECISION 100.0 // Truncated to two decimals, as Hypseus truncates them +#define FVALUE_MIN 0.0 // --fvalue +#define FVALUE_MAX 100000.0 +#define FVALUE_PRECISION 1000.0 // ... and three decimals for this one +#define API_VERSION_PROTOCOL 1 // Format version of the --apiversion line; see the manual +#define TRACE_HEADER_RULE "----------------------------------------------------------------" typedef struct RatioS { @@ -90,13 +106,39 @@ typedef struct QueueS { struct QueueS *next; } QueueT; +// Options with no short letter: a code outside the unsigned char range is how carg_parser names +// one. The alphabet ran out, and a letter picked at random helps nobody. +typedef enum LongOptionE { + OPT_ABSOLUTES_ONLY = 256, + OPT_ALTAUDIO, + OPT_APIVERSION, + OPT_FVALUE, + OPT_GAMEPAD_REORDER, + OPT_HAPTIC, + OPT_IDLEEXIT, + OPT_JOYMOUSE, + OPT_JS_RANGE, + OPT_KEYMAPFILE, + OPT_LINEARSCALE, + OPT_MANYMOUSE, + OPT_MAPJOYSTICKS, + OPT_MONOCHROME, + OPT_NOGAMEPAD, + OPT_SCREEN, + OPT_STARTSILENT, + OPT_TRIGGER_THRESHOLD, + OPT_XRATIO, + OPT_YRATIO +} LongOptionE; + typedef struct OptionS { int32_t code; const char *name; enum ap_Has_arg hasArgument; const char *value; // Placeholder shown in the usage text, NULL when the option takes none. const char *help; - bool hidden; // Parsed but not listed in the usage text. + bool hidden; // Parsed but not listed in the usage text. + bool settable; // May be set in settings.cfg; the ones naming the game or a path may not. } OptionT; typedef struct EmbeddedFileS { @@ -110,42 +152,82 @@ static QueueT *_scriptQueue = NULL; // Single source of truth for the command line: feeds both the parser and the usage text. static const OptionT _options[] = { - { 'a', "aspect", ap_yes, "N:D", "force aspect ratio", false }, - { 'A', "audiodelay", ap_yes, "MS", "compensate for audio heard MS milliseconds late (negative if early)", false }, - { 'b', "scalefactor", ap_yes, "PERCENT", "reduce screen size for overscan compensation", false }, - { 'c', "showcalculated", ap_no, NULL, "show calculated framefile values for debugging", false }, - { 'C', "canvas", ap_yes, "WxH", "world size for games without a disc (default 720x480)", false }, - { 'D', "disc", ap_no, NULL, "play a laserdisc video (implied by --framefile)", false }, - { 'd', "datadir", ap_yes, "PATHNAME", "alternate location for written files", false }, - { 'E', "entry", ap_yes, "N", "run the Nth games.dat entry of a .game file (default 1)", false }, - { 'e', "volume_nonvldp", ap_yes, "PERCENT", "specify sound effects volume in percent", false }, - { 'f', "fullscreen", ap_no, NULL, "run in full screen mode", false }, - { 'g', "sindengun", ap_yes, "'PARAMS'", "enable Sinden Light Gun support", false }, - { 'G', "gamedir", ap_yes, "PATHNAME", "directory holding the games and the Singe folder (default: the current one)", false }, - { 'H', "softwarevideo", ap_no, NULL, "decode video in software even when a hardware decoder exists", false }, - { 'h', "help", ap_no, NULL, "this display", false }, - { 'k', "nologos", ap_no, NULL, "kill the splash screens", false }, - { 'l', "volume_vldp", ap_yes, "PERCENT", "specify laserdisc volume in percent", false }, - { 'm', "nomouse", ap_no, NULL, "disable mouse", false }, - { 'n', "nocrosshair", ap_no, NULL, "request game not display gun crosshairs", false }, - { 'o', "audio", ap_yes, "TRACK", "select default track for audio output", false }, - { 'P', "pack", ap_yes, "DIRECTORY", "pack the game in DIRECTORY into the .game named after the options, or changed files into a .patch", false }, - { 'p', "program", ap_no, NULL, "trace Singe execution to screen and file", false }, - { 'R', "reload", ap_no, NULL, "reload the game when a loose script file changes (F5 reloads too)", false }, - { 's', "nosound", ap_no, NULL, "mutes all sound", false }, - { 'T', "patch", ap_yes, "DATABASE", "patch the game DATABASE from the directory or .patch named after the options", false }, - { 't', "trace", ap_no, NULL, "trace script execution to screen and file", false }, - { 'U', "unpack", ap_yes, "DATABASE", "unpack the game or patch DATABASE into the directory named after the options", false }, - { 'u', "stretch", ap_no, NULL, "use ugly stretched video", false }, - { 'v', "framefile", ap_yes, "FILENAME", "use an alternate video file", false }, - { 'w', "fullscreen_window", ap_no, NULL, "run in windowed full screen mode", false }, - { 'x', "xresolution", ap_yes, "VALUE", "specify horizontal resolution", false }, - { 'y', "yresolution", ap_yes, "VALUE", "specify vertical resolution", false }, - { 'z', "noconsole", ap_no, NULL, "zero console output", false } + { 'a', "aspect", ap_yes, "N:D", "force aspect ratio", false, true }, + { 'A', "audiodelay", ap_yes, "MS", "compensate for audio heard MS milliseconds late (negative if early)", false, true }, + { 'B', "bezel", ap_yes, "FILENAME", "cabinet artwork from the bezels folder, drawn around the picture", false, true }, + { 'b', "scalefactor", ap_yes, "PERCENT", "reduce screen size for overscan compensation", false, true }, + { 'c', "showcalculated", ap_no, NULL, "show calculated framefile values for debugging", false, true }, + { 'C', "canvas", ap_yes, "WxH", "world size for games without a disc (default 720x480)", false, true }, + { 'D', "disc", ap_no, NULL, "play a laserdisc video (implied by --framefile)", false, false }, + { 'd', "datadir", ap_yes, "PATHNAME", "alternate location for written files", false, false }, + { 'E', "entry", ap_yes, "N", "run the Nth games.dat entry of a .game file (default 1)", false, false }, + { 'e', "volume_nonvldp", ap_yes, "PERCENT", "specify sound effects volume in percent", false, true }, + { 'F', "bezelflip", ap_no, NULL, "draw the bezel artwork in front of the picture instead of behind it", false, true }, + { 'f', "fullscreen", ap_no, NULL, "run in full screen mode", false, true }, + { 'g', "sindengun", ap_yes, "'PARAMS'", "enable Sinden Light Gun support", false, true }, + { 'G', "gamedir", ap_yes, "PATHNAME", "directory holding the games and the Singe folder (default: the current one)", false, false }, + { 'H', "softwarevideo", ap_no, NULL, "decode video in software even when a hardware decoder exists", false, true }, + { 'h', "help", ap_no, NULL, "this display", false, false }, + { 'I', "bezeldir", ap_yes, "PATHNAME", "folder holding the bezel artwork, in place of bezels", false, true }, + { 'k', "nologos", ap_no, NULL, "kill the splash screens", false, true }, + { 'l', "volume_vldp", ap_yes, "PERCENT", "specify laserdisc volume in percent", false, true }, + { 'm', "nomouse", ap_no, NULL, "disable mouse", false, true }, + { 'n', "nocrosshair", ap_no, NULL, "request game not display gun crosshairs", false, true }, + { 'o', "audio", ap_yes, "TRACK", "select default track for audio output", false, true }, + { 'P', "pack", ap_yes, "DIRECTORY", "pack the game in DIRECTORY into the .game named after the options, or changed files into a .patch", false, false }, + { 'p', "program", ap_no, NULL, "trace Singe execution to screen and file", false, true }, + { 'R', "reload", ap_no, NULL, "reload the game when a loose script file changes (F5 reloads too)", false, true }, + { 'r', "rotate", ap_yes, "DEGREES", "turn the whole picture clockwise: 0, 90, 180 or 270", false, true }, + { 'S', "sindenedge", ap_yes, "WHERE", "where the Sinden border sits: video or window", false, true }, + { 's', "nosound", ap_no, NULL, "mutes all sound", false, true }, + { 'T', "patch", ap_yes, "DATABASE", "patch the game DATABASE from the directory or .patch named after the options", false, false }, + { 't', "trace", ap_no, NULL, "trace script execution to screen and file", false, true }, + { 'U', "unpack", ap_yes, "DATABASE", "unpack the game or patch DATABASE into the directory named after the options", false, false }, + { 'u', "stretch", ap_no, NULL, "use ugly stretched video", false, true }, + { 'v', "framefile", ap_yes, "FILENAME", "use an alternate video file", false, false }, + { 'w', "fullscreen_window", ap_no, NULL, "run in windowed full screen mode", false, true }, + { 'X', "shiftx", ap_yes, "PERCENT", "move the picture right (or left, negative) inside the room --scalefactor leaves", false, true }, + { 'x', "xresolution", ap_yes, "VALUE", "specify horizontal resolution", false, true }, + { 'Y', "shifty", ap_yes, "PERCENT", "move the picture down (or up, negative) inside the room --scalefactor leaves", false, true }, + { 'y', "yresolution", ap_yes, "VALUE", "specify vertical resolution", false, true }, + { 'z', "noconsole", ap_no, NULL, "zero console output", false, false }, + // No short letter left, and one picked at random helps nobody. + { OPT_ABSOLUTES_ONLY, "absolutes_only", ap_no, NULL, "keep only the mice that report absolute positions, which is what light guns do", false, true }, + { OPT_ALTAUDIO, "altaudio", ap_yes, "SUFFIX", "play .ogg beside the disc video in place of its own audio", false, true }, + { OPT_APIVERSION, "apiversion", ap_no, NULL, "print one machine readable version line and exit", false, false }, + { OPT_FVALUE, "fvalue", ap_yes, "NUMBER", "one number handed to the game, which reads it with getFValue()", false, true }, + { OPT_GAMEPAD_REORDER, "gamepad_reorder", ap_yes, "DIGITS", "which pad fills which slot, as enumeration positions from 0", false, true }, + { OPT_HAPTIC, "haptic", ap_yes, "STEP", "strongest rumble step a game may use, 0 to 4; 0 turns rumble off", false, true }, + { OPT_IDLEEXIT, "idleexit", ap_yes, "SECONDS", "quit after that long with no input at all; 0 never does", false, true }, + { OPT_JOYMOUSE, "joymouse", ap_yes, "BOOLEAN", "let the first gamepad's left stick drive the mouse cursor", false, true }, + { OPT_JS_RANGE, "js_range", ap_yes, "SPEED", "how fast that stick drives it, 1 to 20 (default 5)", false, true }, + { OPT_KEYMAPFILE, "keymapfile", ap_yes, "FILENAME", "read the control mappings from this file instead of searching for controls.cfg", false, true }, + { OPT_LINEARSCALE, "linearscale", ap_yes, "BOOLEAN", "smooth the overlay as the window scales it (the default) or take the nearest pixel", false, true }, + { OPT_MANYMOUSE, "manymouse", ap_no, NULL, "tell the mice apart whatever the game asks for, for a cabinet with two guns", false, true }, + { OPT_MAPJOYSTICKS, "mapjoysticks", ap_yes, "BOOLEAN", "write a gamepad mapping for a device SDL does not recognise (the default)", false, true }, + { OPT_MONOCHROME, "monochrome", ap_no, NULL, "start with the disc picture in grey", false, true }, + { OPT_NOGAMEPAD, "nogamepad", ap_no, NULL, "ignore every gamepad, as --nomouse ignores the mice", false, true }, + { OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true }, + { OPT_STARTSILENT, "startsilent", ap_no, NULL, "start muted until the first input of any kind", false, true }, + { OPT_TRIGGER_THRESHOLD, "trigger_threshold", ap_yes, "PERCENT", "how far a trigger travels before it counts as a button; 0 uses DEAD_ZONE", false, true }, + { OPT_XRATIO, "xratio", ap_yes, "FACTOR", "horizontal gun coordinate scale a game reads with ratioGetX()", false, true }, + { OPT_YRATIO, "yratio", ap_yes, "FACTOR", "vertical gun coordinate scale a game reads with ratioGetY()", false, true } }; #define OPTION_COUNT (sizeof(_options) / sizeof(_options[0])) + +// Options the command line carried, so the settings file knows to leave those alone, and what the +// settings file did set, for the trace header. +static bool _optionSeen[OPTION_COUNT]; +static char *_settingsSummary = NULL; + +// The trace header has to be the first thing in trace.txt, and it cannot be written until the +// renderer is chosen, so main.c's own trace lines wait in _tracePending until it has been. +static char *_commandLine = NULL; +static char *_tracePending = NULL; +static bool _traceHeaderDone = false; + // Sorted ascending within each ratio; the resolution search relies on that. static const ModeT _modes[] = { { { 4, 3 }, { 640, 480 } }, @@ -177,27 +259,614 @@ static const ModeT _modes[] = { }; +static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, const char *argv[], const char *source); +static bool _applySetting(const char *exeName, ConfigT *conf, const SettingT *setting); +static void _applySettings(const char *exeName, ConfigT *conf); static char *_cloneString(const char *string); +#ifndef _WIN32 +static void _crashHandler(int signalNumber); +#endif +static char *_describeAudioDecoders(void); +static char *_describeCpu(void); +static char *_describeOs(void); static bool _extractFile(const char *filename, const uint8_t *data, size_t length); static char *_findVideoFile(const char *baseName); -#ifndef _WIN32 -static void _crashHandler(int signalNumber); -#endif static void _launcher(const char *exeName, ConfigT *conf); static void _mainTrace(const ConfigT *conf, const char *fmt, ...) __attribute__((format(printf, 2, 3))); static bool _modeMatchesRatio(int32_t index, int32_t ratioIndex); +static void _optionFail(const char *exeName, const char *source, const char *message) __attribute__((noreturn)); +static int32_t _optionIndex(int32_t code); +static int32_t _optionNamed(const char *name); static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]); +static bool _parseBoolean(const char *text, bool *value); +static bool _parseGamepadOrder(const char *text); +static bool _parseFloat(const char *text, double *value); static bool _parseInteger(const char *text, int32_t *value); -static void _requireRange(const char *exeName, int32_t value, int32_t min, int32_t max, const char *what, const char *unit); +static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit); +static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what); static void _resolveFiles(const char *exeName, ConfigT *conf); static bool _runTool(const ConfigT *conf); +static void _showApiVersion(void) __attribute__((noreturn)); static void _showHeader(void); static void _showUsage(const char *name, const char *message) __attribute__((noreturn)); static void _startSDL(void); static void _stopSDL(void); +static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device); static void _unpackData(const char *exePath, bool absolute); +// Runs one list of options into a configuration: the command line when source is NULL, or one +// line of a settings file, which names itself so an error can point at it. One switch, one set of +// checks, whichever the options came from. +static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, const char *argv[], const char *source) { + int32_t x = 0; + int32_t argIndex = 0; + int32_t code = 0; + int32_t index = -1; + int32_t aspectNum = -1; + int32_t aspectDom = -1; + int32_t *target = NULL; + double *targetFloat = NULL; + char *aspectString = NULL; + char *canvasString = NULL; + char *sindenString = NULL; + char *edgeString = NULL; + char *temp = NULL; + const char *arg = NULL; + struct Arg_parser parser; + struct ap_Option options[OPTION_COUNT + 1]; + + // Build the parser table from our option list. + for (x = 0; x < (int32_t)OPTION_COUNT; x++) { + options[x].code = _options[x].code; + options[x].long_name = _options[x].name; + options[x].has_arg = _options[x].hasArgument; + } + options[OPTION_COUNT].code = 0; + options[OPTION_COUNT].long_name = NULL; + options[OPTION_COUNT].has_arg = ap_no; + + if (!ap_init(&parser, argc, argv, options, 0)) { + utilDie("Out of memory parsing arguments."); + } + if (ap_error(&parser)) { + utilDie("%s", ap_error(&parser)); + } + + // Parse command line + for (argIndex = 0; argIndex < ap_arguments(&parser); argIndex++) { + code = ap_code(&parser, argIndex); + arg = ap_argument(&parser, argIndex); + index = _optionIndex(code); + target = NULL; + targetFloat = NULL; + if (index >= 0) { + // The command line always beats the settings file, so remember what it carried. + _optionSeen[index] |= (source == NULL); + } + + switch (code) { + + // Non-option: the script file. + case 0: + if (conf->scriptFile) { + _optionFail(exeName, source, "Only one game may be specified."); + } + conf->scriptFile = strdup(arg); + break; + + // Aspect + case 'a': + free(aspectString); + aspectString = strdup(arg); + conf->resolutionWasCalculated = false; + break; + + // Bezel Artwork + case 'B': + free(conf->bezelFile); + conf->bezelFile = strdup(arg); + break; + + // Overscan Zoom + case 'b': + target = &conf->scaleFactor; + break; + + // Show Calculated Frame File Values + case 'c': + conf->showCalculated = true; + break; + + // Canvas size + case 'C': + conf->given |= GIVEN_CANVAS; + free(canvasString); + canvasString = strdup(arg); + break; + + // Laserdisc + case 'D': + conf->given |= GIVEN_VIDEO; + conf->disc = true; + break; + + // Data Dir + case 'd': + free(conf->dataDir); + conf->dataDir = strdup(arg); + break; + + // Effects Volume + case 'e': + target = &conf->volumeNonVldp; + break; + + // Bezel Artwork In Front Of The Picture + case 'F': + conf->bezelFlip = true; + break; + + // Full Screen + case 'f': + conf->fullScreen = true; + break; + + // Audio Delay + case 'A': + conf->given |= GIVEN_AUDIO_DELAY; + target = &conf->audioDelayMs; + break; + + // Sinden Light Gun + case 'G': + free(conf->gameDir); + conf->gameDir = strdup(arg); + break; + case 'g': + conf->given |= GIVEN_SINDEN; + free(sindenString); + sindenString = strdup(arg); + break; + + // Which entry of a game database to run + case 'E': + if (!_parseInteger(arg, &conf->entry) || (conf->entry < 1)) { + _optionFail(exeName, source, "--entry needs a number from 1."); + } + break; + + // Software video decoding + case 'H': + conf->softwareVideo = true; + break; + + // Packing tools: the second name comes from the script argument + case 'P': + case 'T': + case 'U': + conf->toolMode = (code == 'P') ? TOOL_PACK : (code == 'T') ? TOOL_PATCH : TOOL_UNPACK; + free(conf->toolSource); + conf->toolSource = strdup(arg); + break; + + // Help + case 'h': + _showUsage(exeName, NULL); + break; + + // Bezel Artwork Folder + case 'I': + free(conf->bezelDir); + conf->bezelDir = strdup(arg); + break; + + // No Logos + case 'k': + conf->noLogos = true; + break; + + // Video Volume + case 'l': + target = &conf->volumeVldp; + break; + + // No Mouse + case 'm': + conf->given |= GIVEN_NO_MOUSE; + conf->noMouse = true; + break; + + // No Crosshairs + case 'n': + conf->noCrosshair = true; + break; + + // Audio Track Output + case 'o': + conf->given |= GIVEN_AUDIO_TRACK; + target = &conf->audioOutputTrack; + break; + + // Program Tracing + case 'p': + conf->programTracing = true; + break; + + case 'R': + conf->reload = true; + break; + + // Presentation Rotation + case 'r': + target = &conf->rotate; + break; + + // Where The Sinden Border Sits + case 'S': + free(edgeString); + edgeString = strdup(arg); + break; + + // No Sound + case 's': + conf->noSound = true; + break; + + // Script Tracing + case 't': + conf->scriptTracing = true; + break; + + // Ugly Stretched Video + case 'u': + conf->given |= GIVEN_STRETCH; + conf->stretchVideo = true; + break; + + // Video File - a video means a disc. + case 'v': + conf->given |= GIVEN_VIDEO; + free(conf->videoFile); + conf->videoFile = strdup(arg); + conf->disc = true; + break; + + // Full Screen Windowed + case 'w': + conf->fullScreenWindow = true; + break; + + // Horizontal Shift + case 'X': + target = &conf->shiftX; + break; + + // X Resolution + case 'x': + conf->given |= GIVEN_RESOLUTION; + target = &conf->xResolution; + conf->resolutionWasCalculated = false; + break; + + // Vertical Shift + case 'Y': + target = &conf->shiftY; + break; + + // Y Resolution + case 'y': + conf->given |= GIVEN_RESOLUTION; + target = &conf->yResolution; + conf->resolutionWasCalculated = false; + break; + + // Light Guns Only + case OPT_ABSOLUTES_ONLY: + conf->absolutesOnly = true; + break; + + // Alternate Disc Audio + case OPT_ALTAUDIO: + conf->given |= GIVEN_AUDIO_SUFFIX; + free(conf->audioSuffix); + conf->audioSuffix = strdup(arg); + break; + + // Machine Readable Version + case OPT_APIVERSION: + _showApiVersion(); + break; + + // The Launcher's Number + case OPT_FVALUE: + targetFloat = &conf->fValue; + break; + + // Which Pad Is Player One + case OPT_GAMEPAD_REORDER: + if (!_parseGamepadOrder(arg)) { + _optionFail(exeName, source, "--gamepad_reorder takes the enumeration positions of the pads, from 0, one for each slot: 3210 or 3,2,1,0."); + } + free(conf->gamepadOrder); + conf->gamepadOrder = strdup(arg); + break; + + // Strongest Rumble Step + case OPT_HAPTIC: + target = &conf->haptic; + break; + + // Attract Timeout + case OPT_IDLEEXIT: + target = &conf->idleExitSeconds; + break; + + // A Stick For The Mouse + case OPT_JOYMOUSE: + if (!_parseBoolean(arg, &conf->joyMouse)) { + _optionFail(exeName, source, "--joymouse takes true or false."); + } + break; + + // How Fast It Drives It + case OPT_JS_RANGE: + target = &conf->joyMouseRange; + break; + + // Named Control Mappings + case OPT_KEYMAPFILE: + free(conf->keymapFile); + conf->keymapFile = strdup(arg); + utilFixPathSeparators(&conf->keymapFile, false); + break; + + // Overlay Filter + case OPT_LINEARSCALE: + if (!_parseBoolean(arg, &conf->linearScale)) { + _optionFail(exeName, source, "--linearscale takes true or false."); + } + break; + + // One Mouse Device Per Player + case OPT_MANYMOUSE: + conf->manyMouse = true; + break; + + // Mappings For Unrecognised Devices + case OPT_MAPJOYSTICKS: + if (!_parseBoolean(arg, &conf->mapJoysticks)) { + _optionFail(exeName, source, "--mapjoysticks takes true or false."); + } + break; + + // Grey Disc Picture + case OPT_MONOCHROME: + conf->monochrome = true; + break; + + // No Gamepads + case OPT_NOGAMEPAD: + conf->noGamepad = true; + break; + + // Which Display + case OPT_SCREEN: + if (!_parseInteger(arg, &conf->screen) || (conf->screen < SCREEN_MIN)) { + _optionFail(exeName, source, "--screen counts the displays from 1."); + } + break; + + // Silent Until Touched + case OPT_STARTSILENT: + conf->startSilent = true; + break; + + // How Far A Trigger Travels To Count + case OPT_TRIGGER_THRESHOLD: + targetFloat = &conf->triggerThreshold; + break; + + // Gun Coordinate Scales + case OPT_XRATIO: + targetFloat = &conf->ratioX; + break; + case OPT_YRATIO: + targetFloat = &conf->ratioY; + break; + + // No console output + case 'z': + conf->noConsole = true; + utilEnableConsole(false); + break; + + default: + utilDie("Unknown option code %d.", code); + } + + // Numeric options all validate the same way. + if ((target != NULL) && !_parseInteger(arg, target)) { + temp = utilCreateString("Bad value for --%s: %s", _options[index].name, arg); + _optionFail(exeName, source, temp); + } + if ((targetFloat != NULL) && !_parseFloat(arg, targetFloat)) { + temp = utilCreateString("Bad value for --%s: %s", _options[index].name, arg); + _optionFail(exeName, source, temp); + } + } + ap_free(&parser); + + // A missing script is reported by main() after the support files have + // been dealt with: running with no arguments is the documented way to + // set up a fresh install. + + // Do the full screen options make sense? They are the one pair that excludes the other, so + // when the command line named one of them it wins over a settings file that named the other; + // two on the command line is still the user contradicting themselves. + if (conf->fullScreen && conf->fullScreenWindow) { + if (_optionSeen[_optionNamed("fullscreen")] && !_optionSeen[_optionNamed("fullscreen_window")]) { + conf->fullScreenWindow = false; + } else if (_optionSeen[_optionNamed("fullscreen_window")] && !_optionSeen[_optionNamed("fullscreen")]) { + conf->fullScreen = false; + } else { + _optionFail(exeName, source, "Full Screen or Full Screen Windowed. Pick one."); + } + } + + // Sane volume, delay and scale values? + _requireRange(exeName, source, conf->volumeVldp, VOLUME_MIN, VOLUME_MAX, "Laserdisc volume", "percent"); + _requireRange(exeName, source, conf->volumeNonVldp, VOLUME_MIN, VOLUME_MAX, "Effects volume", "percent"); + _requireRange(exeName, source, conf->audioDelayMs, -VIDEO_AUDIO_DELAY_MAX, VIDEO_AUDIO_DELAY_MAX, "Audio delay", "milliseconds"); + _requireRange(exeName, source, conf->scaleFactor, SCALE_FACTOR_MIN, SCALE_FACTOR_MAX, "Display scale", "percent"); + _requireRange(exeName, source, conf->shiftX, SHIFT_MIN, SHIFT_MAX, "Horizontal shift", "percent"); + _requireRange(exeName, source, conf->shiftY, SHIFT_MIN, SHIFT_MAX, "Vertical shift", "percent"); + _requireRange(exeName, source, conf->rotate, ROTATE_MIN, ROTATE_MAX, "Rotation", "degrees"); + _requireRange(exeName, source, conf->haptic, RUMBLE_LEVEL_NONE, RUMBLE_LEVEL_MAX, "Rumble strength", "steps"); + _requireRange(exeName, source, conf->idleExitSeconds, IDLE_EXIT_MIN, IDLE_EXIT_MAX, "Idle timeout", "seconds"); + _requireRange(exeName, source, conf->joyMouseRange, JOY_MOUSE_RANGE_MIN, JOY_MOUSE_RANGE_MAX, "Joystick mouse speed", "steps"); + _requireRangeFloat(exeName, source, conf->triggerThreshold, TRIGGER_THRESHOLD_MIN, TRIGGER_THRESHOLD_MAX, "Trigger threshold"); + _requireRangeFloat(exeName, source, conf->ratioX, RATIO_MIN, RATIO_MAX, "Horizontal gun ratio"); + _requireRangeFloat(exeName, source, conf->ratioY, RATIO_MIN, RATIO_MAX, "Vertical gun ratio"); + _requireRangeFloat(exeName, source, conf->fValue, FVALUE_MIN, FVALUE_MAX, "The --fvalue number"); + + // Hypseus truncates these three as it parses them and hands games the truncated numbers, so + // a game comparing what ratioGetX or getFValue answers sees exactly what it saw there. + conf->ratioX = floor(conf->ratioX * RATIO_PRECISION) / RATIO_PRECISION; + conf->ratioY = floor(conf->ratioY * RATIO_PRECISION) / RATIO_PRECISION; + conf->fValue = floor(conf->fValue * FVALUE_PRECISION) / FVALUE_PRECISION; + + // Only the four right angles: an arbitrary angle would leave the mouse mapping and the + // Sinden border with no sensible meaning, and no game asks for one. + if ((conf->rotate % ROTATE_STEP) != 0) { + _optionFail(exeName, source, "--rotate takes 0, 90, 180 or 270 degrees."); + } + + // Where does the Sinden border go? Without the option a bezel decides: the gun's camera + // sees the whole screen, so artwork around the picture pushes the border out to the window. + if (edgeString) { + if (utilStricmp(edgeString, "video") == 0) { + conf->sindenEdge = SINDEN_EDGE_VIDEO; + } else if (utilStricmp(edgeString, "window") == 0) { + conf->sindenEdge = SINDEN_EDGE_WINDOW; + } else { + _optionFail(exeName, source, "--sindenedge takes video or window."); + } + free(edgeString); + } + + // Sinden light gun? The border is built around whatever the shift and the scale factor + // leave, so the two compose and there is nothing to forbid. + if (sindenString) { + if (!parseSindenString(sindenString, conf)) { + _optionFail(exeName, source, "Bad argument count to --sindengun."); + } + free(sindenString); + } + + // Did they specify an aspect ratio? + if (aspectString) { + temp = strchr(aspectString, ':'); + if (temp != NULL) { + *temp = 0; + if (!_parseInteger(aspectString, &aspectNum) || !_parseInteger(temp + 1, &aspectDom)) { + aspectNum = -1; + } + } + if ((aspectNum > 0) && (aspectDom > 0)) { + // Do we understand what they asked for? + for (x = 0; _modes[x].ratio.aspectNum != 0; x++) { + if ((_modes[x].ratio.aspectNum == aspectNum) && (_modes[x].ratio.aspectDom == aspectDom)) { + conf->bestRatioIndex = x; + break; + } + } + } + if (conf->bestRatioIndex < 0) { + _optionFail(exeName, source, "Unknown aspect ratio."); + } + free(aspectString); + } + + // Did they specify a canvas size? + if (canvasString) { + temp = strchr(canvasString, 'x'); + if (temp == NULL) { + _optionFail(exeName, source, "Canvas size must be WIDTHxHEIGHT, for example 640x480."); + } + *temp = 0; + if (!_parseInteger(canvasString, &conf->canvasWidth) || !_parseInteger(temp + 1, &conf->canvasHeight) || (conf->canvasWidth <= 0) || (conf->canvasHeight <= 0)) { + _optionFail(exeName, source, "Canvas size must be WIDTHxHEIGHT, for example 640x480."); + } + free(canvasString); + } + + +} + + +// One key of a settings file, applied through the same switch the command line uses so the two +// cannot drift apart. Answers whether it took: a key the command line also carried does not. +static bool _applySetting(const char *exeName, ConfigT *conf, const SettingT *setting) { + const char *argv[2]; + char *text = NULL; + int32_t index = _optionNamed(setting->key); + bool flag = false; + + if (index < 0) { + _optionFail(exeName, setting->source, utilCreateString("%s is not an option.", setting->key)); + } + if (!_options[index].settable) { + _optionFail(exeName, setting->source, utilCreateString("%s cannot be set here; it belongs on the command line.", setting->key)); + } + if (_optionSeen[index]) { + return false; + } + // carg_parser skips the first entry, as it would the program name. + argv[0] = "settings"; + if (_options[index].hasArgument == ap_no) { + // A switch is written as a boolean here; false simply leaves the built in default alone. + if (!_parseBoolean(setting->value, &flag)) { + _optionFail(exeName, setting->source, utilCreateString("%s takes true or false.", setting->key)); + } + if (!flag) { + return true; + } + text = utilCreateString("--%s", setting->key); + } else { + text = utilCreateString("--%s=%s", setting->key, setting->value); + } + argv[1] = text; + _applyOptions(exeName, conf, (int32_t)SDL_arraysize(argv), argv, setting->source); + free(text); + + return true; +} + + +// The settings file, applied over the built in defaults and under the command line. The GivenE +// bits are put back afterwards: a settings file is a set of defaults, not something typed, so a +// games.dat entry still overrides what it said exactly as it overrides the built in values. +static void _applySettings(const char *exeName, ConfigT *conf) { + SettingT *settings = NULL; + char *joined = NULL; + int32_t count = 0; + int32_t x = 0; + uint32_t given = conf->given; + + settings = settingsLoad(conf, &count); + for (x = 0; x < count; x++) { + if (_applySetting(exeName, conf, &settings[x])) { + joined = _settingsSummary; + _settingsSummary = utilCreateString("%s%s%s=%s", (joined != NULL) ? joined : "", (joined != NULL) ? " " : "", settings[x].key, settings[x].value); + free(joined); + } + } + conf->given = given; + settingsFree(settings, count); +} + + static char *_cloneString(const char *string) { if (string == NULL) { return NULL; @@ -223,6 +892,98 @@ static void _crashHandler(int signalNumber) { #endif +// Which audio formats this build can decode, for a bug report and so a user whose music is silent +// can see at a glance whether its format was ever compiled in. The mixer keeps the list. +static char *_describeAudioDecoders(void) { + char *list = strdup(""); + char *grown = NULL; + int32_t count = MIX_GetNumAudioDecoders(); + int32_t x = 0; + + for (x = 0; x < count; x++) { + grown = utilCreateString("%s%s%s", list, (x > 0) ? ", " : "", MIX_GetAudioDecoder(x)); + free(list); + list = grown; + } + + return list; +} + + +// The processor, for a bug report. Every platform keeps the name somewhere different, and none of +// them is worth failing over: the core count is always there as a fallback. +static char *_describeCpu(void) { +#ifdef _WIN32 + char name[128]; + DWORD bytes = sizeof(name); + + if (RegGetValueA(HKEY_LOCAL_MACHINE, "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", "ProcessorNameString", RRF_RT_REG_SZ, NULL, name, &bytes) == ERROR_SUCCESS) { + return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM()); + } +#elif defined(__APPLE__) + char name[128]; + size_t bytes = sizeof(name); + + if (sysctlbyname("machdep.cpu.brand_string", name, &bytes, NULL, 0) == 0) { + return utilCreateString("%s, %d cores, %d MB", name, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM()); + } +#else + char line[256]; + FILE *info = fopen("/proc/cpuinfo", "r"); + char *colon = NULL; + char *end = NULL; + char *found = NULL; + + // /proc/cpuinfo calls it "model name" on x86 and "Model" on a Raspberry Pi. The kernel + // reports its size as zero, so it is read a line at a time rather than in one piece. + while ((info != NULL) && (found == NULL) && (fgets(line, sizeof(line), info) != NULL)) { + if (utilStartsWith(line, "model name") || utilStartsWith(line, "Model")) { + colon = strchr(line, ':'); + if (colon != NULL) { + colon++; + while (*colon == ' ') { + colon++; + } + for (end = colon + strlen(colon); (end > colon) && ((uint8_t)end[-1] <= ' '); end--) { + end[-1] = 0; + } + found = utilCreateString("%s, %d cores, %d MB", colon, SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM()); + } + } + } + if (info != NULL) { + fclose(info); + } + if (found != NULL) { + return found; + } +#endif + + return utilCreateString("%s, %d cores, %d MB", SDL_GetPlatform(), SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM()); +} + + +// The operating system and its version, for a bug report. +static char *_describeOs(void) { +#ifdef _WIN32 + char release[64]; + DWORD bytes = sizeof(release); + + if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuild", RRF_RT_REG_SZ, NULL, release, &bytes) == ERROR_SUCCESS) { + return utilCreateString("%s build %s", SDL_GetPlatform(), release); + } +#else + struct utsname system; + + if (uname(&system) == 0) { + return utilCreateString("%s %s (%s)", system.sysname, system.release, system.machine); + } +#endif + + return strdup(SDL_GetPlatform()); +} + + // Writes an embedded support file, or rewrites it when the installed copy differs from this build's. static bool _extractFile(const char *filename, const uint8_t *data, size_t length) { FILE *out = NULL; @@ -295,24 +1056,45 @@ static char *_findVideoFile(const char *baseName) { static void _launcher(const char *exeName, ConfigT *conf) { - int32_t x = 0; - int32_t bestResIndex = -1; - float thisRatio = 0.0f; - float bestRatio = HUGE_VALF; + int32_t x = 0; + int32_t count = 0; + int32_t bestResIndex = -1; + float thisRatio = 0.0f; + float bestRatio = HUGE_VALF; SDL_Window *window = NULL; SDL_Renderer *renderer = NULL; SDL_GPUDevice *device = NULL; SDL_Surface *icon = NULL; MIX_Mixer *mixer = NULL; + SDL_DisplayID *displays = NULL; + SDL_DisplayID display = 0; const SDL_DisplayMode *mode = NULL; SDL_AudioSpec spec; + _traceHeaderDone = false; + + // Which display? Without --screen, the primary one, as every release has used. + display = SDL_GetPrimaryDisplay(); + displays = SDL_GetDisplays(&count); + if (conf->screen > 0) { + if ((displays == NULL) || (conf->screen > count)) { + utilSay("There %s %d display%s:", (count == 1) ? "is" : "are", count, (count == 1) ? "" : "s"); + for (x = 0; (displays != NULL) && (x < count); x++) { + mode = SDL_GetCurrentDisplayMode(displays[x]); + utilSay(" --screen %d %s %dx%d", x + SCREEN_MIN, SDL_GetDisplayName(displays[x]), (mode != NULL) ? mode->w : 0, (mode != NULL) ? mode->h : 0); + } + utilDie("There is no display %d.", conf->screen); + } + display = displays[conf->screen - SCREEN_MIN]; + } + SDL_free(displays); + // Get current screen resolution - mode = SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay()); + mode = SDL_GetCurrentDisplayMode(display); if (mode == NULL) { utilDie("%s", SDL_GetError()); } - _mainTrace(conf, "Display is %dx%d", mode->w, mode->h); + _mainTrace(conf, "Display %s is %dx%d", SDL_GetDisplayName(display), mode->w, mode->h); // Determine resolution if not specified if ((conf->xResolution <= 0) || (conf->yResolution <= 0)) { @@ -382,6 +1164,9 @@ static void _launcher(const char *exeName, ConfigT *conf) { if (window == NULL) { utilDie("%s", SDL_GetError()); } + // Centred on the display that was asked for, before any full screen mode is applied, since + // full screen takes the display the window is already on. + SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); // Window Icon _mainTrace(conf, "Setting icon"); @@ -440,10 +1225,14 @@ static void _launcher(const char *exeName, ConfigT *conf) { utilDie("%s", SDL_GetError()); } + // How video will be decoded is part of the header, so it is settled before the header is + // written; everything else in it is known by now. + videoSetHardwareDecoding(!conf->softwareVideo); + _traceHeader(conf, renderer, device); + // Start our video playback system _mainTrace(conf, "Initializing laserdisc video"); videoInit(mixer); - videoSetHardwareDecoding(!conf->softwareVideo); // Finish our setup _mainTrace(conf, "Disabling screen saver"); @@ -473,13 +1262,24 @@ static void _launcher(const char *exeName, ConfigT *conf) { static void _mainTrace(const ConfigT *conf, const char *fmt, ...) { - va_list args; + va_list args; + char *line = NULL; + char *joined = NULL; - if (conf->programTracing) { - va_start(args, fmt); - utilTraceVArgs(fmt, args); - va_end(args); + if (!conf->programTracing) { + return; } + va_start(args, fmt); + if (_traceHeaderDone) { + utilTraceVArgs(fmt, args); + } else { + line = utilCreateStringVArgs(fmt, args); + joined = _tracePending; + _tracePending = utilCreateString("%s%s%s", (joined != NULL) ? joined : "", (joined != NULL) ? "\n" : "", line); + free(joined); + free(line); + } + va_end(args); } @@ -488,41 +1288,48 @@ static bool _modeMatchesRatio(int32_t index, int32_t ratioIndex) { } -static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]) { - int32_t x = 0; - int32_t argIndex = 0; - int32_t code = 0; - int32_t aspectNum = -1; - int32_t aspectDom = -1; - int32_t *target = NULL; - char *aspectString = NULL; - char *canvasString = NULL; - char *sindenString = NULL; - char *temp = NULL; - const char *arg = NULL; - ConfigT *conf = NULL; - struct Arg_parser parser; - struct ap_Option options[OPTION_COUNT + 1]; +// A bad option. From the command line that is the usage text; from a settings file it names the +// file and the key instead, since the usage text would not say which line was wrong. +static void _optionFail(const char *exeName, const char *source, const char *message) { + if (source == NULL) { + _showUsage(exeName, message); + } + utilDie("%s: %s", source, message); +} + + +// Where an option code sits in the table, or -1 for the non-option argument. +static int32_t _optionIndex(int32_t code) { + int32_t x = 0; - // Build the parser table from our option list. for (x = 0; x < (int32_t)OPTION_COUNT; x++) { - options[x].code = _options[x].code; - options[x].long_name = _options[x].name; - options[x].has_arg = _options[x].hasArgument; + if (_options[x].code == code) { + return x; + } } - options[OPTION_COUNT].code = 0; - options[OPTION_COUNT].long_name = NULL; - options[OPTION_COUNT].has_arg = ap_no; - if (!ap_init(&parser, argc, (const char **)argv, options, 0)) { - utilDie("Out of memory parsing arguments."); - } - if (ap_error(&parser)) { - utilDie("%s", ap_error(&parser)); + return -1; +} + + +// Where a long option name sits in the table, or -1 when there is no such option. +static int32_t _optionNamed(const char *name) { + int32_t x = 0; + + for (x = 0; x < (int32_t)OPTION_COUNT; x++) { + if (strcmp(_options[x].name, name) == 0) { + return x; + } } + return -1; +} + + +static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]) { + ConfigT *conf = (ConfigT *)calloc(1, sizeof(ConfigT)); + // Default configuration values - conf = (ConfigT *)calloc(1, sizeof(ConfigT)); if (!conf) { utilDie("Out of memory creating config."); } @@ -534,276 +1341,83 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]) conf->canvasWidth = CANVAS_DEFAULT_WIDTH; conf->canvasHeight = CANVAS_DEFAULT_HEIGHT; conf->entry = 1; + conf->haptic = RUMBLE_LEVEL_MAX; + conf->linearScale = true; + conf->mapJoysticks = true; + conf->joyMouseRange = JOY_MOUSE_RANGE_DEFAULT; - // Parse command line - for (argIndex = 0; argIndex < ap_arguments(&parser); argIndex++) { - code = ap_code(&parser, argIndex); - arg = ap_argument(&parser, argIndex); - target = NULL; - - switch (code) { - - // Non-option: the script file. - case 0: - if (conf->scriptFile) { - _showUsage(exeName, "Only one game may be specified."); - } - conf->scriptFile = strdup(arg); - break; - - // Aspect - case 'a': - free(aspectString); - aspectString = strdup(arg); - conf->resolutionWasCalculated = false; - break; - - // Overscan Zoom - case 'b': - target = &conf->scaleFactor; - break; - - // Show Calculated Frame File Values - case 'c': - conf->showCalculated = true; - break; - - // Canvas size - case 'C': - conf->given |= GIVEN_CANVAS; - free(canvasString); - canvasString = strdup(arg); - break; - - // Laserdisc - case 'D': - conf->given |= GIVEN_VIDEO; - conf->disc = true; - break; - - // Data Dir - case 'd': - free(conf->dataDir); - conf->dataDir = strdup(arg); - break; - - // Effects Volume - case 'e': - target = &conf->volumeNonVldp; - break; - - // Full Screen - case 'f': - conf->fullScreen = true; - break; - - // Audio Delay - case 'A': - conf->given |= GIVEN_AUDIO_DELAY; - target = &conf->audioDelayMs; - break; - - // Sinden Light Gun - case 'G': - free(conf->gameDir); - conf->gameDir = strdup(arg); - break; - case 'g': - conf->given |= GIVEN_SINDEN; - free(sindenString); - sindenString = strdup(arg); - break; - - // Which entry of a game database to run - case 'E': - if (!_parseInteger(arg, &conf->entry) || (conf->entry < 1)) { - _showUsage(exeName, "--entry needs a number from 1."); - } - break; - - // Software video decoding - case 'H': - conf->softwareVideo = true; - break; - - // Packing tools: the second name comes from the script argument - case 'P': - case 'T': - case 'U': - conf->toolMode = (code == 'P') ? TOOL_PACK : (code == 'T') ? TOOL_PATCH : TOOL_UNPACK; - free(conf->toolSource); - conf->toolSource = strdup(arg); - break; - - // Help - case 'h': - _showUsage(exeName, NULL); - break; - - // No Logos - case 'k': - conf->noLogos = true; - break; - - // Video Volume - case 'l': - target = &conf->volumeVldp; - break; - - // No Mouse - case 'm': - conf->given |= GIVEN_NO_MOUSE; - conf->noMouse = true; - break; - - // No Crosshairs - case 'n': - conf->noCrosshair = true; - break; - - // Audio Track Output - case 'o': - conf->given |= GIVEN_AUDIO_TRACK; - target = &conf->audioOutputTrack; - break; - - // Program Tracing - case 'p': - conf->programTracing = true; - break; - - case 'R': - conf->reload = true; - break; - - // No Sound - case 's': - conf->noSound = true; - break; - - // Script Tracing - case 't': - conf->scriptTracing = true; - break; - - // Ugly Stretched Video - case 'u': - conf->given |= GIVEN_STRETCH; - conf->stretchVideo = true; - break; - - // Video File - a video means a disc. - case 'v': - conf->given |= GIVEN_VIDEO; - free(conf->videoFile); - conf->videoFile = strdup(arg); - conf->disc = true; - break; - - // Full Screen Windowed - case 'w': - conf->fullScreenWindow = true; - break; - - // X Resolution - case 'x': - conf->given |= GIVEN_RESOLUTION; - target = &conf->xResolution; - conf->resolutionWasCalculated = false; - break; - - // Y Resolution - case 'y': - conf->given |= GIVEN_RESOLUTION; - target = &conf->yResolution; - conf->resolutionWasCalculated = false; - break; - - // No console output - case 'z': - conf->noConsole = true; - utilEnableConsole(false); - break; - - default: - utilDie("Unknown option code %d.", code); - } - - // Numeric options all validate the same way. - if ((target != NULL) && !_parseInteger(arg, target)) { - temp = utilCreateString("Bad value for option -%c: %s", code, arg); - _showUsage(exeName, temp); - } - } - ap_free(&parser); - - // A missing script is reported by main() after the support files have - // been dealt with: running with no arguments is the documented way to - // set up a fresh install. - - // Do the full screen options make sense? - if (conf->fullScreen && conf->fullScreenWindow) { - _showUsage(exeName, "Full Screen or Full Screen Windowed. Pick one."); - } - - // Sane volume, delay and scale values? - _requireRange(exeName, conf->volumeVldp, VOLUME_MIN, VOLUME_MAX, "Laserdisc volume", "percent"); - _requireRange(exeName, conf->volumeNonVldp, VOLUME_MIN, VOLUME_MAX, "Effects volume", "percent"); - _requireRange(exeName, conf->audioDelayMs, -VIDEO_AUDIO_DELAY_MAX, VIDEO_AUDIO_DELAY_MAX, "Audio delay", "milliseconds"); - _requireRange(exeName, conf->scaleFactor, SCALE_FACTOR_MIN, SCALE_FACTOR_MAX, "Display scale", "percent"); - - // Sinden light gun? - if (sindenString) { - if (conf->scaleFactor != SCALE_FACTOR_MAX) { - _showUsage(exeName, "Cannot use --sindengun and --scalefactor together."); - } - if (!parseSindenString(sindenString, conf)) { - _showUsage(exeName, "Bad argument count to --sindengun."); - } - free(sindenString); - } - - // Did they specify an aspect ratio? - if (aspectString) { - temp = strchr(aspectString, ':'); - if (temp != NULL) { - *temp = 0; - if (!_parseInteger(aspectString, &aspectNum) || !_parseInteger(temp + 1, &aspectDom)) { - aspectNum = -1; - } - } - if ((aspectNum > 0) && (aspectDom > 0)) { - // Do we understand what they asked for? - for (x = 0; _modes[x].ratio.aspectNum != 0; x++) { - if ((_modes[x].ratio.aspectNum == aspectNum) && (_modes[x].ratio.aspectDom == aspectDom)) { - conf->bestRatioIndex = x; - break; - } - } - } - if (conf->bestRatioIndex < 0) { - _showUsage(exeName, "Unknown aspect ratio."); - } - free(aspectString); - } - - // Did they specify a canvas size? - if (canvasString) { - temp = strchr(canvasString, 'x'); - if (temp == NULL) { - _showUsage(exeName, "Canvas size must be WIDTHxHEIGHT, for example 640x480."); - } - *temp = 0; - if (!_parseInteger(canvasString, &conf->canvasWidth) || !_parseInteger(temp + 1, &conf->canvasHeight) || (conf->canvasWidth <= 0) || (conf->canvasHeight <= 0)) { - _showUsage(exeName, "Canvas size must be WIDTHxHEIGHT, for example 640x480."); - } - free(canvasString); - } + _applyOptions(exeName, conf, argc, (const char **)argv, NULL); return conf; } +// --gamepad_reorder is a list of enumeration positions, one per gamepad slot, written as Hypseus +// writes it: bare digits (3210) or separated by commas or spaces (3, 2, 1, 0). A repeated position +// is the user contradicting themselves and is refused; anything but a digit or a separator is too. +static bool _parseGamepadOrder(const char *text) { + bool seen[GAMEPAD_ORDER_DIGITS]; + int32_t digits = 0; + int32_t x = 0; + + memset(seen, 0, sizeof(seen)); + for (x = 0; text[x] != '\0'; x++) { + if ((text[x] == ',') || (text[x] == ' ')) { + continue; + } + if ((text[x] < '0') || (text[x] > '9')) { + return false; + } + if (seen[text[x] - '0']) { + return false; + } + seen[text[x] - '0'] = true; + digits++; + } + + return (digits > 0); +} + + +// The words a settings file (and --linearscale) may use for a switch. +static bool _parseBoolean(const char *text, bool *value) { + static const char *yes[] = { "true", "yes", "on", "1" }; + static const char *no[] = { "false", "no", "off", "0" }; + int32_t x = 0; + + for (x = 0; x < (int32_t)SDL_arraysize(yes); x++) { + if (utilStricmp(text, yes[x]) == 0) { + *value = true; + return true; + } + if (utilStricmp(text, no[x]) == 0) { + *value = false; + return true; + } + } + + return false; +} + + +static bool _parseFloat(const char *text, double *value) { + char *end = NULL; + double parsed = 0.0; + + if ((text == NULL) || (*text == 0)) { + return false; + } + errno = 0; + parsed = strtod(text, &end); + if ((*end != 0) || (errno == ERANGE)) { + return false; + } + *value = parsed; + + return true; +} + + static bool _parseInteger(const char *text, int32_t *value) { char *end = NULL; long parsed = 0; @@ -823,9 +1437,17 @@ static bool _parseInteger(const char *text, int32_t *value) { // Usage error naming the limits, so the message cannot drift from the constants. -static void _requireRange(const char *exeName, int32_t value, int32_t min, int32_t max, const char *what, const char *unit) { +static void _requireRange(const char *exeName, const char *source, int32_t value, int32_t min, int32_t max, const char *what, const char *unit) { if ((value < min) || (value > max)) { - _showUsage(exeName, utilCreateString("%s must be between %d and %d %s.", what, min, max, unit)); + _optionFail(exeName, source, utilCreateString("%s must be between %d and %d %s.", what, min, max, unit)); + } +} + + +// The same for the three options that carry a fraction. +static void _requireRangeFloat(const char *exeName, const char *source, double value, double min, double max, const char *what) { + if ((value < min) || (value > max)) { + _optionFail(exeName, source, utilCreateString("%s must be between %g and %g.", what, min, max)); } } @@ -858,6 +1480,14 @@ static void _resolveFiles(const char *exeName, ConfigT *conf) { _showUsage(exeName, "Unable to locate the game."); } + // The data directory names the last two places the settings file is looked for, so it is worked + // out here; the entry below may move it, and it is worked out again at the end. + conf->dataDir = resolveDataDir(conf); + if (!conf->dataDir) { + _showUsage(exeName, "Unable to create data directory."); + } + _applySettings(exeName, conf); + // A loose script runs with the settings of its games.dat entry, as the menu would run it. if (conf->container == NULL) { replacement = confFromGamesDat(conf); @@ -932,6 +1562,18 @@ static bool _runTool(const ConfigT *conf) { } +// One line a front end can read, and nothing else on stdout. The contract, which the manual +// repeats: the first field is always "singe", the rest are key=value pairs separated by one +// space, "protocol" says which version of this line it is, and a reader ignores keys it does not +// know so that later versions may add them. +static void _showApiVersion(void) { + // VERSION_STRING is "vX.YY" for people; a machine wants the number on its own. + printf("singe version=%s protocol=%d\n", VERSION_STRING + 1, API_VERSION_PROTOCOL); + fflush(stdout); + exit(EXIT_SUCCESS); +} + + static void _showHeader(void) { static bool shown = false; @@ -985,7 +1627,12 @@ static void _showUsage(const char *name, const char *message) { } } if (help == _options[x].help) { - utilSay(" -%c, %-*s%.*s", _options[x].code, USAGE_OPTION_WIDTH, longForm, (int)(end - help), help); + // An option with no short letter starts where the long form does. + if (_options[x].code > UINT8_MAX) { + utilSay(" %-*s%.*s", USAGE_OPTION_WIDTH, longForm, (int)(end - help), help); + } else { + utilSay(" -%c, %-*s%.*s", _options[x].code, USAGE_OPTION_WIDTH, longForm, (int)(end - help), help); + } } else { utilSay("%*s%.*s", helpColumn, "", (int)(end - help), help); } @@ -1035,10 +1682,47 @@ static void _stopSDL(void) { } +// What a bug report needs, at the top of trace.txt and nowhere else: the build, the command that +// started it, the machine, and what the engine picked to run on. main.c's own trace lines wait +// behind it so the block is always first and can be pasted whole. +static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device) { + char *os = _describeOs(); + char *cpu = _describeCpu(); + char *audio = _describeAudioDecoders(); + int32_t built = SDL_VERSION; + int32_t linked = SDL_GetVersion(); + + utilTrace("%s", TRACE_HEADER_RULE); + utilTrace("Singe: %s", VERSION_STRING); + utilTrace("Command: %s", (_commandLine != NULL) ? _commandLine : ""); + utilTrace("OS: %s", os); + utilTrace("CPU: %s", cpu); + utilTrace("Renderer: %s%s%s", SDL_GetRendererName(renderer), (device != NULL) ? ", GPU driver " : " (no GPU device; 3D is unavailable)", (device != NULL) ? SDL_GetGPUDeviceDriver(device) : ""); + utilTrace("Decoder: %s", videoGetDecoderDescription()); + utilTrace("Audio: %s", audio); + utilTrace("SDL: built %d.%d.%d, linked %d.%d.%d", SDL_VERSIONNUM_MAJOR(built), SDL_VERSIONNUM_MINOR(built), SDL_VERSIONNUM_MICRO(built), SDL_VERSIONNUM_MAJOR(linked), SDL_VERSIONNUM_MINOR(linked), SDL_VERSIONNUM_MICRO(linked)); + utilTrace("Settings: %s", (_settingsSummary != NULL) ? _settingsSummary : "none"); + utilTrace("Game: %s%s%s", conf->scriptFile, (conf->container != NULL) ? " in " : "", (conf->container != NULL) ? conf->container : ""); + utilTrace("%s", TRACE_HEADER_RULE); + free(os); + free(cpu); + free(audio); + + // Everything main.c held back while the header was still being assembled. + _traceHeaderDone = true; + if (_tracePending != NULL) { + utilTrace("%s", _tracePending); + free(_tracePending); + _tracePending = NULL; + } +} + + static void _unpackData(const char *exePath, bool absolute) { const EmbeddedFileT files[] = { { "Framework.singe", Framework_singe, Framework_singe_len }, { "controls.cfg.example", controls_cfg, controls_cfg_len }, + { "settings.cfg.example", settings_cfg, settings_cfg_len }, { "Menu.singe", Menu_singe, Menu_singe_len }, { "MenuClassic.singe", MenuClassic_singe, MenuClassic_singe_len }, { "Menu.rml", Menu_rml, Menu_rml_len }, @@ -1047,7 +1731,11 @@ static void _unpackData(const char *exePath, bool absolute) { { "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len }, { "click.wav", click_wav, click_wav_len }, { "Manual.pdf", Manual_pdf, Manual_pdf_len }, - { "gui.rcss", gui_rcss, gui_rcss_len } + { "gui.rcss", gui_rcss, gui_rcss_len }, + { "scoreBezel.rml", scoreBezel_rml, scoreBezel_rml_len }, + { "scoreBezel.rcss", scoreBezel_rcss, scoreBezel_rcss_len }, + { "subtitle.rml", subtitle_rml, subtitle_rml_len }, + { "subtitle.rcss", subtitle_rcss, subtitle_rcss_len } }; int32_t x = 0; char *temp = NULL; @@ -1065,24 +1753,25 @@ static void _unpackData(const char *exePath, bool absolute) { free(temp); } - // Script to start menu system + // Script to start menu system. The launcher finds the binary beside itself rather than naming + // it, because the binary carries its version and a named one stops working at the next release; + // the game launchers in the wild do the same. With --gamedir the binary is elsewhere, so there + // is nothing to search for and its full path is written instead. if (utilGetPathSeparator() == '/') { // Unix-ish - // The script runs from the game directory; the binary is named relative to it, or by its - // full path when --gamedir put the games somewhere else. temp = strdup("Menu.sh"); if (absolute) { - data = utilCreateString("#!/bin/sh\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); } else { - data = utilCreateString("#!/bin/sh\n\ncd \"$(dirname \"$0\")\"\n./%s %s %s/menuBackground.mkv %s/Menu.singe\n", utilGetLastPathComponent(exePath), MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\nSINGE=\nfor f in ./%s*; do\n if [[ -x \"$f\" ]] && [[ -f \"$f\" ]]; then\n SINGE=\"$f\" && break\n fi\ndone\nif [[ -z \"$SINGE\" ]]; then\n echo \"Cannot find the %s program beside this script.\"\n exit 1\nfi\n\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); } } else { // Winders temp = strdup("Menu.bat"); if (absolute) { - data = utilCreateString("@cd /d \"%%~dp0\"\n@start \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); } else { - data = utilCreateString("@start %s %s %s\\menuBackground.mkv %s\\Menu.singe\n", utilGetLastPathComponent(exePath), MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nset \"SINGE=\"\r\nfor /f \"tokens=* usebackq\" %%%%f in (`dir /b %s*.exe`) do (set \"SINGE=%%%%f\" & goto :next)\r\n:next\r\nif not defined SINGE (\r\n echo Cannot find the %s program beside this script.\r\n pause\r\n exit /b 1\r\n)\r\nstart \"\" \"%%SINGE%%\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); } } created |= _extractFile(temp, (const uint8_t *)data, strlen(data)); @@ -1104,14 +1793,19 @@ ConfigT *cloneConf(const ConfigT *conf) { } // Copy everything, then give the clone its own strings. - *c = *conf; - c->scriptFile = _cloneString(conf->scriptFile); - c->container = _cloneString(conf->container); - c->toolSource = _cloneString(conf->toolSource); - c->gameDir = _cloneString(conf->gameDir); - c->videoFile = _cloneString(conf->videoFile); - c->dataDirBase = _cloneString(conf->dataDirBase); - c->dataDir = _cloneString(conf->dataDir); + *c = *conf; + c->scriptFile = _cloneString(conf->scriptFile); + c->container = _cloneString(conf->container); + c->toolSource = _cloneString(conf->toolSource); + c->gameDir = _cloneString(conf->gameDir); + c->bezelFile = _cloneString(conf->bezelFile); + c->bezelDir = _cloneString(conf->bezelDir); + c->keymapFile = _cloneString(conf->keymapFile); + c->audioSuffix = _cloneString(conf->audioSuffix); + c->gamepadOrder = _cloneString(conf->gamepadOrder); + c->videoFile = _cloneString(conf->videoFile); + c->dataDirBase = _cloneString(conf->dataDirBase); + c->dataDir = _cloneString(conf->dataDir); return c; } @@ -1187,6 +1881,11 @@ void destroyConf(ConfigT **confPointer) { free(conf->container); free(conf->toolSource); free(conf->gameDir); + free(conf->bezelFile); + free(conf->bezelDir); + free(conf->keymapFile); + free(conf->audioSuffix); + free(conf->gamepadOrder); free(conf); *confPointer = NULL; } @@ -1262,6 +1961,7 @@ char *resolveDataDir(const ConfigT *conf) { int main(int argc, char *argv[]) { const char *exeName = argv[0]; + int32_t x = 0; char *temp = NULL; char *exePath = NULL; ConfigT *conf = NULL; @@ -1276,6 +1976,15 @@ int main(int argc, char *argv[]) { signal(SIGFPE, _crashHandler); #endif + // Kept whole for the trace header, because a bug report that does not say how Singe was + // started says very little. + for (x = 0; x < argc; x++) { + temp = _commandLine; + _commandLine = utilCreateString("%s%s%s", (temp != NULL) ? temp : "", (temp != NULL) ? " " : "", argv[x]); + free(temp); + } + temp = NULL; + // Options first so --help and --noconsole take effect before anything is written. conf = _parseArguments(exeName, argc, argv); @@ -1359,6 +2068,8 @@ int main(int argc, char *argv[]) { _stopSDL(); vfsQuit(); + free(_commandLine); + free(_settingsSummary); if (utilGetConsoleEnabled()) { utilWaitForKeyOnWindows(); diff --git a/src/main.h b/src/main.h index d24459831..0ae325cce 100644 --- a/src/main.h +++ b/src/main.h @@ -30,8 +30,22 @@ #define VOLUME_MIN 0 #define VOLUME_MAX 100 -#define SCALE_FACTOR_MIN 50 +#define SCALE_FACTOR_MIN 25 #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 SCRIPT_MODE 0755 // Menu.sh: chmod ignores the umask, so no world write #define CANVAS_DEFAULT_WIDTH 720 diff --git a/src/scene.c b/src/scene.c index 42780da78..457c03b2b 100644 --- a/src/scene.c +++ b/src/scene.c @@ -6125,7 +6125,8 @@ void sceneUpdateTransforms(void) { // copies are queued ahead of the scene's own command buffer. void sceneUpdateVideo(SceneVideoSourceFn source) { int32_t x; - bool any = false; + bool any = false; + SDL_Texture *previous = SDL_GetRenderTarget(_scene.renderer); SDL_Texture *frame; float width; float height; @@ -6154,7 +6155,8 @@ void sceneUpdateVideo(SceneVideoSourceFn source) { any = true; } 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); } } diff --git a/src/singe.c b/src/singe.c index d271c642f..f6c1dd1a2 100644 --- a/src/singe.c +++ b/src/singe.c @@ -77,6 +77,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L); // soundSetVolume/soundGetVolume use this legacy scale; the mixer uses MIX_MAX_VOLUME. #define AUDIO_MAX_VOLUME 63 +#define MUSIC_MAX_VOLUME 128 // musicSetVolume keeps Hypseus's own scale, which is twice the one above #define GAMES_DAT_LEVELS 4 // Directories, the script's and above, searched for its games.dat #define GUI_DRAW_MAX 16 // guiDraw calls honoured per frame #define GUI_VALUE_MAX 4096 // Longest value guiGetValue hands back @@ -87,6 +88,14 @@ LSEC_API int luaopen_ssl_config(lua_State *L); #define CONTROLLER_AXIS_COUNT 6 #define CONTROLLER_BUTTON_COUNT 15 #define CONTROLLER_DEAD_ZONE_DEFAULT 15000 +#define CONTROLLER_TRIGGER_FIRST 4 // Gamepad axes 4 and 5 are the two analogue triggers +#define AXIS_VALUE_MAX 32767 // What SDL reports at full deflection, and the unit DEAD_ZONE counts in +#define PERCENT_FULL 100.0 // TRIGGER_THRESHOLD is a per cent of that full deflection, as Hypseus writes it +#define GAMEPAD_MAPPING_MAX 640 // Longest synthetic mapping string a device can need +#define GAMEPAD_GUID_MAX 33 // SDL writes a joystick GUID as 32 hex digits and a terminator +#define GAMEPAD_NAME_MAX 96 // As much of a device's name as a mapping carries +#define GAMEPAD_NAME_UNKNOWN "Unknown Device" // What a device that will not say gets called +#define GAMEPAD_HAT_DIRECTIONS 4 // Up, right, down and left, in the order of SDL's hat bits #define AXIS_CONTROLLER_COUNT (MAX_CONTROLLERS * CONTROLLER_AXIS_COUNT) // Controller axis slots first, then the mice #define AXIS_COUNT (AXIS_CONTROLLER_COUNT + MAX_MICE * MOUSE_AXIS_COUNT) #define AXIS_INDEX_CONTROLLER(c, a) ((c) * CONTROLLER_AXIS_COUNT + (a)) @@ -115,10 +124,56 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define DEGREES_PER_CIRCLE 360.0 #define ANIMATION_MIN_DELAY_MS 10 // GIFs often carry a zero delay #define SCREENSHOT_MAX 10000 +#define VIDEO_SCALE_THROTTLE_MS 15 // vldpSetScale accepts one change this often, as Hypseus does +#define VIDEO_SHIFT_MAX 100 // --shiftx and --shifty at their extreme fill the room --scalefactor leaves +#define BEZEL_DIRECTORY "bezels" // Where --bezel looks for its artwork unless --bezeldir names another +#define BEZEL_SIDECAR_EXTENSION ".cfg" // The cutout beside the artwork, named after it +#define BEZEL_CUTOUT_TABLE "CUTOUT" // The one table the sidecar sets +#define BEZEL_CUTOUT_FIELDS 4 // x, y, width and height, in the artwork's own pixels +#define BEZEL_HASH_BASIS 14695981039346656037ULL // FNV-1a, so mainBezelLoaded answers Hypseus's own number +#define BEZEL_HASH_PRIME 1099511628211ULL +#define BEZEL_HASH_NONE "0" // What Hypseus reports with no bezel loaded +#define SCORE_PANEL_RML "Singe/scoreBezel.rml" +#define SCORE_PANEL_PLAYER2 "player2" // The element holding the second player's readouts +#define SCORE_PANEL_WIDTH 960 // The score panel document's own pixels; the draw scales it +#define SCORE_PANEL_HEIGHT 220 +#define SCORE_PANEL_BAND_MIN 10 // It needs a band this fraction of the window deep to sit outside the picture +#define SCORE_PANEL_BAND_MAX 4 // ... and never takes more than this fraction of it +#define SCORE_PANEL_PLAYERS 2 +#define SCORE_PANEL_SCORE_DIGITS 6 // The readout sizes Hypseus's scoreboard has: six for a score ... +#define SCORE_PANEL_CREDIT_DIGITS 2 // ... two for the credits ... +#define SCORE_PANEL_LIVES_DIGITS 1 // ... and one for a life count +#define SCORE_PANEL_TEXT_MAX 16 // Longest readout, with room to spare +#define SCORE_CODE_OFF -1 // Hypseus's negative values, shown on every digit of the readout +#define SCORE_CODE_DASH -2 +#define SCORE_CODE_BLANK -3 +#define SCORE_CODE_H -4 +#define SCORE_CODE_A -5 +#define SUBTITLE_RML "Singe/subtitle.rml" +#define SUBTITLE_SLOT "slot" // The element the engine writes a cue or a banner into +#define SUBTITLE_WIDTH 960 // The subtitle document's own pixels; the draw scales it over the picture +#define SUBTITLE_HEIGHT 540 +#define SUBTITLE_NONE -1 // No cue is on screen +#define SUBTITLE_POSITION_MIN 1 // srtPosition is a per cent down the picture, as Hypseus takes it +#define SUBTITLE_POSITION_MAX 95 +#define SUBTITLE_POSITION_DEFAULT 80 // Hypseus's own starting height, 0x50 +#define SUBTITLE_MARKUP_MAX 4096 // Longest cue the document is handed, once escaped +#define SUBTITLE_WRAPPER_MAX 128 // Room for the element and the inline height around it +#define SUBTITLE_TIME_FIELDS 4 // hours, minutes, seconds and milliseconds either side of the arrow +#define SUBTITLE_ARROW "-->" // What separates a cue's two timestamps +#define SUBTITLE_CUES_FIRST 64 // Cues the parser makes room for before it doubles +#define SUBTITLE_CUES_MAX 65536 // A film's worth of dialogue, and a guard against a runaway file +#define SECONDS_PER_HOUR 3600.0 +#define SECONDS_PER_MINUTE 60.0 +#define BANNER_TEXT_MAX 60 // overlayBanner shows no more than this, as Hypseus refuses more +#define BANNER_FRAMES 30 // ... for about this many drawn frames, as Hypseus times it +#define BANNER_POSITION_DEFAULT 47 // Hypseus's own default height, 0x2f +#define SINDEN_MIN_VIDEO_PIXELS 2 // The border is squeezed rather than swallow the picture #define COLOR_BYTE_MAX 255 #define NAV_DRAW_VERTICES 4096 // Starting room for navDraw, doubled until the mesh fits #define SOUND_QUEUE_SIZE 64 #define MS_PER_SECOND_NUMBER 1000.0 +#define MS_PER_SECOND 1000 // --idleexit counts in whole seconds #define INPUT_GRACE_MS 1000 // Presses this soon after a script starts or focus arrives were held over from before #define EFFECT_TRACKS 16 // Sound effect "channels" scripts can play at once #define SOUND_DEFAULT_NEAR 1.0f // A positioned sound is at full volume within this distance ... @@ -130,6 +185,9 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define WATCH_INTERVAL_MS 1000 // How often --reload checks the script files #define AUDIO_CALIBRATION_FILE "audio.cfg" // Per-machine audio delay, in the data root #define CONTROLS_FILE "controls.cfg" // Input mappings, built in and overridden per game +#define SETTINGS_FILE "settings.cfg" // User options, found the same four ways controls.cfg is +#define SETTINGS_PLACES 4 // The four places both of those are looked for +#define GAMEPAD_DATABASE_FILE "gamecontrollerdb.txt" // Extra SDL controller mappings, found the same four ways #define SOUND_CHANNEL_NONE -1 // soundPlay's answer when every channel is busy (SOUND_ERROR_INVALID) #define COLOR_COMPONENTS 4 // r, g, b, a #define BYTES_PER_KIB 1024 @@ -144,6 +202,8 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define WATER_DEFAULT_ANGULAR_DRAG 0.1f #define LISTENER_CAMERA -1 // soundSetListener's default: the scene camera #define EFFECT_TAG "effects" +#define MUSIC_TAG "music" // Music carries its own gain, so musicSetVolume never moves a sound effect +#define MUSIC_LOOP_MAX 64 // Hypseus clamps musicPlay's loop count to this #define HELD_KEYS_MAX 64 // Keys physically down at once #define PAUSE_TEXT "PAUSED" #define PAUSE_TEXT_SCALE 3 // Console font is small; scale the indicator up @@ -151,6 +211,17 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define QUAD_INDICES 6 // ... as two triangles #define TRAIL_POINT_FLOATS 3 // x, y, z per recorded trail point #define COLOR_KEY_VALUE 0 +#define SPRITE_SHEET_FRAMES_MIN 2 // spriteLoadFrames strip bounds, as Hypseus has them +#define SPRITE_SHEET_FRAMES_MAX 512 +#define RUMBLE_BASE_MS 150 // Duration step 1; every step doubles it +#define LUMA_WEIGHT_RED 77 // BT.601 luma weights in 1/256ths, for overlaySetMonochrome +#define LUMA_WEIGHT_GREEN 151 +#define LUMA_WEIGHT_BLUE 28 +#define LUMA_WEIGHT_SHIFT 8 +#define BGRA_BLUE 0 // Byte order of the overlay's pixels +#define BGRA_GREEN 1 +#define BGRA_RED 2 +#define BGRA_ALPHA 3 #define BLUE_SCREEN_BLUE 255 #define PLAYER_DISC -2 // materialSetVideo's "the disc": resolved every frame, since a frame file swaps players per segment #define PACKED_PERMISSIONS "r--r--r--" // lfs.attributes on a packed entry: read only @@ -240,6 +311,8 @@ typedef enum InputE { INPUT_ACTION_4, INPUT_TILT, INPUT_GRAB, + // Added in Singe 3.00. A new switch goes on the end so the numbers a game already knows do not move. + INPUT_MOUSE_DISCONNECT, INPUT_COUNT } InputE; @@ -266,6 +339,8 @@ typedef struct HeldKeyS { typedef struct MouseS { int32_t x; int32_t y; + float absX; // An absolute device's last reported position, in presentation coordinates + float absY; char name[64]; } MouseT; @@ -284,7 +359,10 @@ typedef struct SpriteS { IMG_Animation *animation; // NULL for still images SDL_Surface *originalSurface; // Owned unless it points into animation->frames SDL_Surface *surface; // What gets drawn: originalSurface, or a transformed copy + SDL_Surface *rotatedFrame; // spriteRotateFrame's turned copy of one sheet frame, drawn by spriteDrawRotatedFrame bool surfaceOwned; // True when surface is a transformed copy to free + int32_t frameWidth; // Unscaled size of one frame (the image, for a still) + int32_t frameHeight; double angle; double scaleX; double scaleY; @@ -310,6 +388,22 @@ typedef struct SoundS { UT_hash_handle hh; } SoundT; +// A music track. Music keeps its own track and its own tag so its gain is independent of the +// sound effects', which is what Hypseus gets from running music on a mixer of its own. +typedef struct MusicS { + int32_t id; + MIX_Audio *audio; + MIX_Track *track; + UT_hash_handle hh; +} MusicT; + +// One cue of a loaded .srt, its timestamps already turned into disc frames. +typedef struct SubtitleCueS { + int64_t startFrame; + int64_t endFrame; + char *text; +} SubtitleCueT; + // Where a playing effect channel sits: in the scene (following a node or placed by hand), or // panned across the speakers, or neither. typedef struct EffectS { @@ -349,11 +443,14 @@ typedef struct MappingS { int32_t *input; } MappingT; -// One guiDraw this frame: a GUI composited over the overlay, in overlay coordinates or covering it. +// One guiDraw this frame: a GUI composited over the overlay, in overlay coordinates or covering +// it. A screen space one takes the same coordinates across the whole window instead, so it can sit +// on the bezel artwork beside the picture rather than inside it. typedef struct GuiDrawS { int32_t gui; SDL_FRect dest; bool whole; + bool screen; } GuiDrawT; typedef struct GlobalS { @@ -362,7 +459,13 @@ typedef struct GlobalS { SDL_Color colorForeground; SDL_Color colorBackground; SDL_Surface *overlay; + SDL_Surface *overlayGrey; // The overlay in luma only, for overlaySetMonochrome SDL_Texture *overlayTexture; + SDL_FRect focusArea; // vldpFocusArea's part of the disc frame, in disc pixels + bool focused; + bool overlayMonochrome; + bool overlayLinearScale; // setOverlayLinearScale's filter for the overlay texture; linear is Singe's long-standing look + char *discAudioSuffix; // discAudioSuffix's last accepted suffix, NULL until one is GuiDrawT guiDraws[GUI_DRAW_MAX]; // This frame's guiDraw calls int32_t guiDrawCount; GuiDrawT guiShown[GUI_DRAW_MAX]; // Last frame's, for pointing at them @@ -379,15 +482,25 @@ typedef struct GlobalS { SDL_Texture *videoTexture; SDL_Surface *consoleFontSurface; SDL_Gamepad *controllers[MAX_CONTROLLERS]; - int32_t controllerDeadZone; + int32_t controllerDeadZone; // DEAD_ZONE: the fallback for every axis + int32_t triggerThreshold; // --trigger_threshold in raw units; 0 leaves the triggers on the dead zone + int32_t axisDeadZone[AXIS_COUNT]; // What each axis actually uses, DEAD_ZONES having had its say + bool joyMouseEnabled; // A stick drives the mouse cursor + int32_t joyMouseX; // Where it has driven it, in video coordinates + int32_t joyMouseY; + uint64_t joyMouseClock; // Next tick it may move on + bool mouseAbsoluteSeen[MAX_MICE]; // --absolutes_only: this device has reported an absolute position + bool fullScreen; // The mode Alt-Enter last put the window in int32_t consoleFontWidth; int32_t consoleFontHeight; int32_t nextSpriteId; int32_t nextSoundId; + int32_t nextMusicId; int32_t nextFontId; int32_t nextVideoId; int32_t nextScreenshot; // First index worth checking; lower ones are taken int32_t effectsVolume; + int32_t musicVolume; // 0 to MUSIC_MAX_VOLUME, Hypseus's scale, over the music tag alone int32_t listenerNode; // For positioned sounds; LISTENER_CAMERA for the scene camera KeyboardModeE keyboardMode; bool keyboardState[SDL_SCANCODE_COUNT]; @@ -418,17 +531,66 @@ typedef struct GlobalS { int32_t pauseTextureHeight; double overlayScaleX; // Overlay size / video size double overlayScaleY; - SDL_FRect drawTarget; // Where the game is drawn, in video coordinates (Sinden border, overscan) - double drawScaleX; // Video size / drawTarget size: mouse positions map through it + SDL_FRect videoRect; // Where the game is drawn, in video coordinates: _computeVideoRect() builds it + double drawScaleX; // Video size / videoRect size: mouse positions map through it double drawScaleY; + SDL_FRect sindenBlackRect; // The Sinden rings, built by the same routine so a runtime change moves them + SDL_FRect sindenWhiteRect; + SDL_FRect sindenInnerRect; // What the two rings enclose, which is the picture unless they are at the window's edge + bool sindenAtWindow; // The rings are at the window's edge, around the bezel artwork rather than the picture + SDL_Texture *bezelTexture; // --bezel's artwork, drawn over the whole logical rectangle + char *bezelHash; // Its identifier for mainBezelLoaded: the file's FNV-1a hash as decimal + SDL_FRect bezelCutout; // The hole the picture goes in, in video coordinates + bool bezelHasCutout; // A sidecar declared one; without it the picture keeps the window + bool bezelInFront; // --bezelflip: the artwork draws over the picture, not behind it + bool overlayOnTop; // setOverlayOnTop: the overlay and the GUIs draw above the artwork + int32_t subtitleGui; // The subtitle document, GUI_NO_HANDLE until a cue or a banner needs it + int32_t subtitleDocument; + SubtitleCueT *subtitleCues; // srtLoad's parsed cues, in file order + int32_t subtitleCueCount; + int32_t subtitleCue; // The cue whose text is in the document, SUBTITLE_NONE for none + int32_t subtitlePosition; // srtPosition, a per cent down the picture + bool subtitleEnabled; // srtEnable + char *bannerText; // overlayBanner's message while it is up, NULL otherwise + int32_t bannerFrames; // Drawn frames it has left + int32_t bannerPosition; + int32_t scoreGui; // The score panel, GUI_NO_HANDLE until a game asks for it + int32_t scoreDocument; + char *scoreTwinMarkup; // The second player's cell, kept while it is switched off + bool scoreEnabled; // scoreBezelEnable + bool scoreTwin; // scoreBezelTwinScoreOn + bool scoreTwinShown; // Whether the document is carrying that cell right now + bool scoreBlank; // scoreBezelClear, until the next value arrives + int32_t scoreCredits; + int32_t scoreValue[SCORE_PANEL_PLAYERS]; + int32_t scoreLives[SCORE_PANEL_PLAYERS]; + SDL_Color sindenBlackColor; + SDL_Color sindenWhiteColor; + int32_t sindenBlackWidth; // In video pixels, as the user gave them + int32_t sindenWhiteWidth; + bool sindenBorder; + int32_t videoScale; // Live --scalefactor per cent; vldpSetScale moves it + int32_t videoShiftX; // Live --shiftx and --shifty per cent + int32_t videoShiftY; + int32_t videoRotate; // Live --rotate degrees; vldpSetRotate moves it + SDL_Texture *rotateTexture; // The frame, drawn unrotated, when videoRotate is not zero + uint64_t videoScaleClock; // vldpSetScale is throttled to one change per VIDEO_SCALE_THROTTLE_MS + bool discMonochrome; // vldpSetMonochrome: the disc picture is shown in luma only + bool discBlend; // vldpSetBlend: the disc picture is smoothed down its rows + bool discLumaOn; // vldpSetLuma, and the level it was given + int32_t discLumaLevel; + uint8_t overlayOpacity; // setOverlayOpacity, over the whole overlay bool overlayDirty; bool pauseState; // by RDG2010 bool pauseEnabled; // by RDG2010 + bool quitEnabled; // The engine quits on the quit switch bool refreshDisplay; bool running; bool discStopped; bool mouseEnabled; bool mouseGrabbed; + bool audioMuted; // --nosound, or --startsilent until the first input + uint64_t idleClock; // Ticks of the last input, for --idleexit bool requestScreenShot; bool wasPlayingBeforePause; VideoT *videoList; @@ -442,6 +604,7 @@ typedef struct GlobalS { int32_t *frameStarts; // Per emitter frame, where its particles' quads begin in the index scratch int32_t frameStartCapacity; SoundT *soundList; + MusicT *musicList; FontT *fontList; FontT *fontCurrent; MappingT controlMappings[INPUT_COUNT]; @@ -478,7 +641,8 @@ static const InputNameT _inputNames[INPUT_COUNT] = { { "INPUT_CONSOLE", "SWITCH_CONSOLE" }, { "INPUT_ACTION_4", "SWITCH_BUTTON4" }, { "INPUT_TILT", "SWITCH_TILT" }, - { "INPUT_GRAB", "SWITCH_GRAB" } + { "INPUT_GRAB", "SWITCH_GRAB" }, + { "INPUT_MOUSE_DISCONNECT", "SWITCH_MOUSE_DISCONNECT" } }; // SDL numbers mouse buttons left, middle, right; scripts number them left, right, middle. @@ -493,6 +657,7 @@ static bool _argBoolean(lua_State *L, const char *method, int32_t index) static int32_t _argChannel(lua_State *L, const char *method, int32_t index); static void _argCheck(lua_State *L, const char *method, int32_t minimum, int32_t maximum); static uint8_t _argColorByte(lua_State *L, const char *method, int32_t index); +static const char *_argData(lua_State *L, const char *method, int32_t index, size_t *length); static int32_t _argEmitter(lua_State *L, const char *method, int32_t index); static QuatT _argEuler(lua_State *L, const char *method, int32_t index); static float *_argFloatTable(lua_State *L, const char *method, int32_t index, int32_t *count); @@ -505,6 +670,7 @@ static bool _argMapImage(lua_State *L, const char *method, int32_t index static int32_t _argMaterial(lua_State *L, const char *method, int32_t index); static int32_t _argMesh(lua_State *L, const char *method, int32_t index); static int32_t _argMorph(lua_State *L, const char *method, int32_t node, int32_t index); +static MusicT *_argMusic(lua_State *L, const char *method, int32_t index); static int32_t _argNav(lua_State *L, const char *method, int32_t index); static int32_t _argNavAgent(lua_State *L, const char *method, int32_t index); static int32_t _argNode(lua_State *L, const char *method, int32_t index); @@ -521,21 +687,32 @@ static Vec3T _argVec3(lua_State *L, const char *method, int32_t index); static int32_t _argVehicle(lua_State *L, const char *method, int32_t index); static VideoT *_argVideo(lua_State *L, const char *method, int32_t index); static int32_t _argView(lua_State *L, const char *method, int32_t index); +static int32_t _axisIndexForCode(int32_t code); +static void _bezelDraw(void); +static void _bezelLoad(void); +static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout); static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base); static void _callLua(const char *func, const char *sig, ...); static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2); +static void _computeVideoRect(void); +static void _configPlaces(const char *file, const char *dataDir, const char *scriptFile, char **places); static int32_t _controllerSlot(SDL_JoystickID which); -static int32_t _defaultEffectsVolume(void); +static int32_t _defaultVolume(int32_t maximum); static bool _delayAndPump(uint32_t ms); static void _deliverKey(bool down, int32_t keysym, int32_t scancode); +static void _deliverSwitch(InputE move, bool down); +static void _discApplyPicture(void); static int64_t _discGetFrame(void); static void _discPark(void); static void _discSeek(int64_t frame); +static bool _discSetAudioSuffix(const char *suffix); static void _doLogos(void); -static void _drawGuis(const SDL_FRect *target); +static void _drawGuis(const SDL_FRect *target, bool screen); static void _drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, uint32_t pixel); static void _drawParticles2D(ParticleLayerE layer, const SDL_FRect *target); static void _drawPauseIndicator(const SDL_FRect *target); +static void _drawRing(const SDL_FRect *outer, const SDL_FRect *inner, const SDL_Color *color); +static void _drawSindenBorder(void); static void _drawTrails2D(const EmitterViewT *view, const ParticleTexturesT *cache, const SDL_FRect *target, float scaleX, float scaleY); static float _effectGain(const EffectT *effect, float distance); static void _effectReset(int32_t channel); @@ -546,6 +723,8 @@ static void _fireMouseMoved(int32_t device, int32_t x, int32_t y, int32_ static void _fitRect(int32_t width, int32_t height, int32_t spaceWidth, int32_t spaceHeight, int32_t margin, SDL_FRect *rect); static void _fontDestroy(FontT *font); static void _freezeGame(bool freeze); +static bool _gamepadMappingFor(SDL_JoystickID id, char *mapping, size_t size); +static void _gamepadOrder(int32_t *order, int32_t count); static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value); static void _guiHandlerKey(char *buffer, size_t size, int32_t gui, int32_t document, const char *id, const char *event); static bool _guiKey(SDL_Keycode key, SDL_Keymod modifiers, bool down); @@ -553,11 +732,15 @@ static bool _guiMouseButton(int32_t button, bool down); static void _guiMouseMove(int32_t x, int32_t y); static bool _guiMouseWheel(float delta); static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32_t *guiY); +static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen); +static void _guisShown(void); static bool _guiSwitch(InputE move, bool down); static bool _guiText(const char *text); static void _guiTextInput(void); static void _heldListUpdate(HeldKeyT *list, int32_t *count, bool down, int32_t keysym, int32_t scancode); static void _installFileHooks(lua_State *L); +static bool _isInputEvent(uint32_t type); +static void _joyMouseUpdate(void); static int32_t _lfsAttributes(lua_State *L); static int32_t _lfsDir(lua_State *L); static int32_t _lfsDirIterator(lua_State *L); @@ -569,6 +752,8 @@ static void _loadControlMappings(void); static void _loadControlsFile(const char *path); static SDL_Surface *_loadEmbeddedPng(const uint8_t *data, size_t length); static SDL_Texture *_loadEmbeddedTexture(const uint8_t *data, size_t length, SDL_Surface **surface); +static void _loadGamepadDatabase(void); +static void _logicalRect(SDL_FRect *rect); static int32_t _luaCallOriginal(lua_State *L); static void _luaDie(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))) __attribute__((noreturn)); static int32_t _luaDofile(lua_State *L); @@ -582,11 +767,19 @@ static int32_t _luaPanic(lua_State *L); static int32_t _luaSearcher(lua_State *L); static void _luaTrace(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))); static int32_t _luaTraceback(lua_State *L); +static void _mapPointer(float px, float py, int32_t *x, int32_t *y); +static void _mapPointerDelta(float dx, float dy, int32_t *xr, int32_t *yr); +static void _mapUnknownJoysticks(void); static int32_t _materialSetMap(lua_State *L, const char *method, MaterialMapE map, bool hasStrength); -static float _mixerGain(int32_t effectsVolume); +static float _mixerGain(int32_t volume, int32_t maximum); static int32_t _mouseCode(int32_t device, int32_t button); +static void _musicDestroy(MusicT *music); static void _navCallbacks(void); +static void _noteInput(void); +static void _overlayApplyOpacity(void); static uint32_t _overlayColor(const SDL_Color *color); +static SDL_Surface *_overlayGrey(void); +static void _overlayApplyScaleMode(void); static void _overlayResize(int32_t width, int32_t height); static void _overlayTouched(void); static void _particleTexturesDestroy(ParticleTexturesT *cache); @@ -595,6 +788,7 @@ static void _particleTexturesFreeAll(void); static ParticleTexturesT *_particleTexturesGet(const EmitterViewT *view); static void _pauseAllVideos(bool pause); static void _physicsCallbacks(void); +static void _presentSize(int32_t *width, int32_t *height); static void _processKey(bool down, int32_t keysym, int32_t scancode); static void _progTrace(const char *fmt, ...) __attribute__((format(printf, 1, 2))); static void _pushConstants(lua_State *L); @@ -608,30 +802,62 @@ static void _releaseAxis(int32_t axisIndex); static void _reloadScript(void); static SDL_Surface *_renderText(lua_State *L, const char *method, const char *message); static void _resetScriptState(void); +static void _rmlEscape(char *out, size_t size, const char *text); static void _runScript(bool fatal); static bool _sameLoosePath(const char *a, const char *b); static void _saveAudioCalibration(int32_t milliseconds); static SDL_Texture *_sceneVideoSource(int32_t player); +static void _scorePanelDigits(char *text, size_t size, int32_t value, int32_t digits, bool blank); +static bool _scorePanelEnable(bool enabled); +static int32_t _scorePanelPlayer(int32_t player); +static void _scorePanelQueue(void); +static void _scorePanelRect(SDL_FRect *dest); +static void _scorePanelReset(void); +static void _scorePanelUpdate(void); static ConfigT *_scriptConfFromTable(lua_State *L, const char *method); static void _selectDefaultAudioTrack(int32_t handle); +static void _setAudioMuted(bool muted); static void _setMouseCaptured(bool captured); static void _setPause(bool paused, bool fromKey); +static void _settingsAdd(SettingT **list, int32_t *count, const char *key, const char *value, const char *source); +static int32_t _settingsCompare(const void *a, const void *b); +static void _settingsReadFile(const char *path, SettingT **list, int32_t *count); +static bool _setVideoRotate(int32_t degrees); static const char *_skipDotSlash(const char *path); static void _soundDestroy(SoundT *sound); +static SoundT *_soundLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io); static int32_t _soundQueueDrain(int32_t *finished); +static void _spriteAdoptAnimation(SpriteT *sprite); static void _spriteDestroy(SpriteT *sprite); static void _spriteFreeSurface(SpriteT *sprite); +static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io); +static SpriteT *_spriteNew(lua_State *L, const char *method); static void _spriteRebuildSurface(SpriteT *sprite); +static void _spriteRegister(SpriteT *sprite); +static const char *_startAudioSuffix(void); static void _startControllers(void); static void _startLuaContext(lua_State *L); static void _stopControllers(void); static void _subsystemsInit(void); static void _subsystemsQuit(void); +static void _subtitleClearCues(void); +static int32_t _subtitleCueNow(void); +static bool _subtitleEnsureGui(void); +static char *_subtitleNextLine(char **cursor); +static bool _subtitleParse(const char *text); +static bool _subtitleParseTime(const char *text, int64_t *frame); +static void _subtitleQueue(void); +static void _subtitleReset(void); +static void _subtitleShow(const char *text, int32_t position); +static void _subtitleUpdate(void); static void _suppressHeldInput(void); static uint32_t _surfaceCornerKey(SDL_Surface *surface); static void _surfaceUnpack(SDL_Surface **surface); static void _takeScreenshot(void); +static void _toggleFullScreen(void); static void _unloadScriptResources(void); +static void _unrotateDelta(float dx, float dy, float *vx, float *vy); +static void _unrotatePoint(float px, float py, float *vx, float *vy); static void _updatePauseState(void); static void _updateSounds(void); static void _videoDestroy(VideoT *video); @@ -670,10 +896,15 @@ static int32_t apiCameraSetOrthographic(lua_State *L); static int32_t apiCameraSetPerspective(lua_State *L); static int32_t apiColorBackground(lua_State *L); static int32_t apiColorForeground(lua_State *L); +static int32_t apiControllerDoRumble(lua_State *L); static int32_t apiControllerGetAxis(lua_State *L); static int32_t apiControllerGetButton(lua_State *L); +static int32_t apiControllerGetName(lua_State *L); +static int32_t apiControllerHowMany(lua_State *L); +static int32_t apiControllerIsValid(lua_State *L); static int32_t apiDebugPrint(lua_State *L); static int32_t apiDiscAudio(lua_State *L); +static int32_t apiDiscAudioSuffix(lua_State *L); static int32_t apiDiscChangeSpeed(lua_State *L); static int32_t apiDiscGetAudioTrack(lua_State *L); static int32_t apiDiscGetAudioTracks(lua_State *L); @@ -732,9 +963,11 @@ static int32_t apiFontQuality(lua_State *L); static int32_t apiFontSelect(lua_State *L); static int32_t apiFontToSprite(lua_State *L); static int32_t apiFontUnload(lua_State *L); +static int32_t apiGetFValue(lua_State *L); static int32_t apiGuiClose(lua_State *L); static int32_t apiGuiDelete(lua_State *L); static int32_t apiGuiDraw(lua_State *L); +static int32_t apiGuiDrawScreen(lua_State *L); static int32_t apiGuiGetHeight(lua_State *L); static int32_t apiGuiGetStats(lua_State *L); static int32_t apiGuiGetValue(lua_State *L); @@ -752,6 +985,8 @@ static int32_t apiJointDelete(lua_State *L); static int32_t apiJointHinge(lua_State *L); static int32_t apiJointSetLimits(lua_State *L); static int32_t apiJointSlider(lua_State *L); +static int32_t apiJoyMouseEnable(lua_State *L); +static int32_t apiJoyMouseIsEnabled(lua_State *L); static int32_t apiKeyboardGetLastDown(lua_State *L); static int32_t apiKeyboardGetLastUp(lua_State *L); static int32_t apiKeyboardGetMode(lua_State *L); @@ -765,6 +1000,7 @@ static int32_t apiLightSetIntensity(lua_State *L); static int32_t apiLightSetRange(lua_State *L); static int32_t apiLightSetShadow(lua_State *L); static int32_t apiLineDraw(lua_State *L); +static int32_t apiMainBezelLoaded(lua_State *L); static int32_t apiMaterialDelete(lua_State *L); static int32_t apiMaterialNew(lua_State *L); static int32_t apiMaterialSetBlend(lua_State *L); @@ -798,6 +1034,15 @@ static int32_t apiModelDelete(lua_State *L); static int32_t apiModelGetAnimations(lua_State *L); static int32_t apiModelInstance(lua_State *L); static int32_t apiModelLoad(lua_State *L); +static int32_t apiMusicIsPlaying(lua_State *L); +static int32_t apiMusicLoad(lua_State *L); +static int32_t apiMusicPause(lua_State *L); +static int32_t apiMusicPlay(lua_State *L); +static int32_t apiMusicResume(lua_State *L); +static int32_t apiMusicSetVolume(lua_State *L); +static int32_t apiMusicStop(lua_State *L); +static int32_t apiMusicUnload(lua_State *L); +static int32_t apiMouseGetName(lua_State *L); static int32_t apiMouseGetPosition(lua_State *L); static int32_t apiMouseHowMany(lua_State *L); static int32_t apiMouseSetCaptured(lua_State *L); @@ -858,10 +1103,12 @@ static int32_t apiOverlayCircle(lua_State *L); static int32_t apiOverlayClear(lua_State *L); static int32_t apiOverlayEllipse(lua_State *L); static int32_t apiOverlayGetHeight(lua_State *L); +static int32_t apiOverlayBanner(lua_State *L); static int32_t apiOverlayGetWidth(lua_State *L); static int32_t apiOverlayLine(lua_State *L); static int32_t apiOverlayPlot(lua_State *L); static int32_t apiOverlayPrint(lua_State *L); +static int32_t apiOverlaySetMonochrome(lua_State *L); static int32_t apiOverlaySetResolution(lua_State *L); static int32_t apiPhysicsRaycast(lua_State *L); static int32_t apiPhysicsSet2D(lua_State *L); @@ -894,6 +1141,8 @@ static int32_t apiRagdollIsResting(lua_State *L); static int32_t apiRagdollNew(lua_State *L); static int32_t apiRagdollSetJoint(lua_State *L); static int32_t apiRagdollSetStrength(lua_State *L); +static int32_t apiRatioGetX(lua_State *L); +static int32_t apiRatioGetY(lua_State *L); static int32_t apiSceneEnable(lua_State *L); static int32_t apiSceneGetSize(lua_State *L); static int32_t apiSceneGetStats(lua_State *L); @@ -913,8 +1162,18 @@ static int32_t apiSceneSetSky(lua_State *L); static int32_t apiSceneSetSkyIntensity(lua_State *L); static int32_t apiSceneSetTonemap(lua_State *L); static int32_t apiSceneUnproject(lua_State *L); +static int32_t apiScoreBezelClear(lua_State *L); +static int32_t apiScoreBezelCredits(lua_State *L); +static int32_t apiScoreBezelEnable(lua_State *L); +static int32_t apiScoreBezelGetState(lua_State *L); +static int32_t apiScoreBezelLives(lua_State *L); +static int32_t apiScoreBezelScore(lua_State *L); +static int32_t apiScoreBezelTwinScoreOn(lua_State *L); static int32_t apiScriptExecute(lua_State *L); static int32_t apiScriptPush(lua_State *L); +static int32_t apiSetOverlayLinearScale(lua_State *L); +static int32_t apiSetOverlayOnTop(lua_State *L); +static int32_t apiSetOverlayOpacity(lua_State *L); static int32_t apiSingeGetAudioCalibration(lua_State *L); static int32_t apiSingeGetAudioDelay(lua_State *L); static int32_t apiSingeGetAudioLatency(lua_State *L); @@ -932,6 +1191,7 @@ static int32_t apiSingeSetAudioDelay(lua_State *L); static int32_t apiSingeSetGameName(lua_State *L); static int32_t apiSingeSetPauseFlag(lua_State *L); static int32_t apiSingeSetPauseKeyEnabled(lua_State *L); +static int32_t apiSingeSetQuitKeyEnabled(lua_State *L); static int32_t apiSingeVersion(lua_State *L); static int32_t apiSingeWantsCrosshairs(lua_State *L); static int32_t apiSoftDelete(lua_State *L); @@ -947,6 +1207,7 @@ static int32_t apiSoundGetPosition(lua_State *L); static int32_t apiSoundGetVolume(lua_State *L); static int32_t apiSoundIsPlaying(lua_State *L); static int32_t apiSoundLoad(lua_State *L); +static int32_t apiSoundLoadData(lua_State *L); static int32_t apiSoundPause(lua_State *L); static int32_t apiSoundPlay(lua_State *L); static int32_t apiSoundResume(lua_State *L); @@ -959,20 +1220,34 @@ static int32_t apiSoundSetVolume(lua_State *L); static int32_t apiSoundStop(lua_State *L); static int32_t apiSoundUnload(lua_State *L); static int32_t apiSpriteDraw(lua_State *L); +static int32_t apiSpriteDrawFrame(lua_State *L); +static int32_t apiSpriteDrawGrid(lua_State *L); +static int32_t apiSpriteDrawRotatedFrame(lua_State *L); +static int32_t apiSpriteFrameHeight(lua_State *L); +static int32_t apiSpriteFrameWidth(lua_State *L); static int32_t apiSpriteGetFrame(lua_State *L); +static int32_t apiSpriteGetFrames(lua_State *L); static int32_t apiSpriteGetHeight(lua_State *L); static int32_t apiSpriteGetWidth(lua_State *L); static int32_t apiSpriteIsPlaying(lua_State *L); static int32_t apiSpriteLoad(lua_State *L); +static int32_t apiSpriteLoadData(lua_State *L); +static int32_t apiSpriteLoadFrames(lua_State *L); static int32_t apiSpriteLoop(lua_State *L); static int32_t apiSpritePause(lua_State *L); static int32_t apiSpritePlay(lua_State *L); static int32_t apiSpriteQuality(lua_State *L); +static int32_t apiSpriteResetColorKey(lua_State *L); static int32_t apiSpriteRotate(lua_State *L); static int32_t apiSpriteRotateAndScale(lua_State *L); +static int32_t apiSpriteRotateFrame(lua_State *L); static int32_t apiSpriteScale(lua_State *L); static int32_t apiSpriteSetFrame(lua_State *L); static int32_t apiSpriteUnload(lua_State *L); +static int32_t apiSrtClear(lua_State *L); +static int32_t apiSrtEnable(lua_State *L); +static int32_t apiSrtLoad(lua_State *L); +static int32_t apiSrtPosition(lua_State *L); static int32_t apiTerrainGetHeight(lua_State *L); static int32_t apiVehicleAddWheel(lua_State *L); static int32_t apiVehicleDelete(lua_State *L); @@ -1017,7 +1292,18 @@ static int32_t apiVideoUnload(lua_State *L); static int32_t apiViewDelete(lua_State *L); static int32_t apiViewNew(lua_State *L); static int32_t apiViewSetCamera(lua_State *L); +static int32_t apiVldpFlash(lua_State *L); +static int32_t apiVldpFocusArea(lua_State *L); static int32_t apiVldpGetPixel(lua_State *L); +static int32_t apiVldpGetRotate(lua_State *L); +static int32_t apiVldpGetScale(lua_State *L); +static int32_t apiVldpGetYUVPixel(lua_State *L); +static int32_t apiVldpResetFocus(lua_State *L); +static int32_t apiVldpSetBlend(lua_State *L); +static int32_t apiVldpSetLuma(lua_State *L); +static int32_t apiVldpSetMonochrome(lua_State *L); +static int32_t apiVldpSetRotate(lua_State *L); +static int32_t apiVldpSetScale(lua_State *L); static int32_t apiVldpSetVerbose(lua_State *L); @@ -1161,6 +1447,23 @@ static uint8_t _argColorByte(lua_State *L, const char *method, int32_t index) { } +// A block of bytes held in a Lua string, for the calls that build an asset from memory. A Lua +// string may hold a zero byte, so the length comes back beside the pointer. +static const char *_argData(lua_State *L, const char *method, int32_t index, size_t *length) { + const char *data = NULL; + + if (lua_type(L, index) != LUA_TSTRING) { + _luaDie(L, method, "Argument %d must be a string of bytes.", index); + } + data = lua_tolstring(L, index, length); + if (*length == 0) { + _luaDie(L, method, "Argument %d is empty.", index); + } + + return data; +} + + // An emitter handle argument, checked. static int32_t _argEmitter(lua_State *L, const char *method, int32_t index) { return _argHandle(L, method, index, emitterValid, "emitter"); @@ -1305,6 +1608,20 @@ static int32_t _argMorph(lua_State *L, const char *method, int32_t node, int32_t } +// A music handle argument, checked. +static MusicT *_argMusic(lua_State *L, const char *method, int32_t index) { + int32_t id = _argInteger(L, method, index); + MusicT *music = NULL; + + HASH_FIND_INT(_global.musicList, &id, music); + if (!music) { + _luaDie(L, method, "No music at index %d.", id); + } + + return music; +} + + // A navigation mesh handle from navNew or navLoad. static int32_t _argNav(lua_State *L, const char *method, int32_t index) { return _argHandle(L, method, index, navValid, "navigation mesh"); @@ -1438,6 +1755,179 @@ static int32_t _argView(lua_State *L, const char *method, int32_t index) { } +// The axis cache slot a controls.cfg code names, or -1 when the code is not an axis. An axis and +// its two direction codes all answer the same slot, so a DEAD_ZONES entry may be written either way. +static int32_t _axisIndexForCode(int32_t code) { + int32_t device = 0; + int32_t offset = 0; + + if ((code >= CODE_GAMEPAD_BASE) && (code < CODE_GAMEPAD_BASE + MAX_CONTROLLERS * CODE_GAMEPAD_STRIDE)) { + device = (code - CODE_GAMEPAD_BASE) / CODE_GAMEPAD_STRIDE; + offset = (code - CODE_GAMEPAD_BASE) % CODE_GAMEPAD_STRIDE; + if (offset < CODE_GAMEPAD_BUTTON_OFFSET) { + return AXIS_INDEX_CONTROLLER(device, offset / CODE_AXIS_STRIDE); + } + } + + return -1; +} + + +// The bezel artwork: always the whole logical rectangle, so a picture of a cabinet lands where a +// cabinet would be whatever shape the window is. It draws behind everything else, or in front of +// the picture with --bezelflip, in which case its own transparency is the hole the game shows +// through. +static void _bezelDraw(void) { + SDL_FRect rect; + + _logicalRect(&rect); + SDL_RenderTexture(_global.renderer, _global.bezelTexture, NULL, &rect); +} + + +// Loads --bezel's artwork and the cutout beside it. The name is looked for in the bezels folder of +// the game directory (a packed game may carry its own) and then in the same folder of the data +// directory; --bezeldir replaces both with the one folder it names. Artwork the user asked for and +// Singe cannot find is fatal, since a missing frame would silently misplace the picture. +static void _bezelLoad(void) { + const char *directory = BEZEL_DIRECTORY; + const char separator = utilGetPathSeparator(); + char *candidate[2]; + char *sidecar = NULL; + char *stem = NULL; + char *extension = NULL; + char *data = NULL; + size_t bytes = 0; + size_t i = 0; + int32_t c = 0; + int32_t found = -1; + uint64_t hash = BEZEL_HASH_BASIS; + SDL_Surface *artwork = NULL; + + if (_global.conf->bezelFile == NULL) { + return; + } + if (_global.conf->bezelDir != NULL) { + directory = _global.conf->bezelDir; + } + candidate[0] = utilCreateString("%s%c%s", directory, separator, _global.conf->bezelFile); + candidate[1] = (_global.conf->bezelDir != NULL) ? NULL : utilCreateString("%s%s%c%s", _global.conf->dataDir, directory, separator, _global.conf->bezelFile); + for (c = 0; (found < 0) && (c < (int32_t)SDL_arraysize(candidate)); c++) { + if ((candidate[c] != NULL) && vfsExists(candidate[c])) { + found = c; + } + } + if (found < 0) { + utilDie("Unable to find the bezel %s in %s.", _global.conf->bezelFile, directory); + } + data = vfsRead(candidate[found], &bytes); + if (data == NULL) { + utilDie("Unable to read the bezel %s.", candidate[found]); + } + artwork = IMG_Load_IO(SDL_IOFromConstMem(data, bytes), true); + if (artwork == NULL) { + utilDie("%s: %s", candidate[found], SDL_GetError()); + } + _global.bezelTexture = SDL_CreateTextureFromSurface(_global.renderer, artwork); + if (_global.bezelTexture == NULL) { + utilDie("%s: %s", candidate[found], SDL_GetError()); + } + + // Hypseus identifies a bezel to the game by the hash of its bytes, and games compare that + // against numbers they were written with, so mainBezelLoaded has to answer the same number. + for (i = 0; i < bytes; i++) { + hash ^= (uint8_t)data[i]; + hash *= BEZEL_HASH_PRIME; + } + free(data); + _global.bezelHash = utilCreateString("%" PRIu64, hash); + _global.bezelInFront = _global.conf->bezelFlip; + _progTrace("Bezel %s is %dx%d, id %s", candidate[found], artwork->w, artwork->h, _global.bezelHash); + + // The cutout is declared in the artwork's own pixels, so it survives any window size; the + // artwork fills the logical rectangle, so the same ratio carries the hole into it. + stem = strdup(candidate[found]); + extension = strrchr(stem, '.'); + if ((extension != NULL) && (strchr(extension, separator) == NULL)) { + *extension = 0; + } + sidecar = utilCreateString("%s%s", stem, BEZEL_SIDECAR_EXTENSION); + free(stem); + if (_bezelReadCutout(sidecar, artwork->w, artwork->h, &_global.bezelCutout)) { + _global.bezelCutout.x *= (float)_global.canvasWidth / (float)artwork->w; + _global.bezelCutout.w *= (float)_global.canvasWidth / (float)artwork->w; + _global.bezelCutout.y *= (float)_global.canvasHeight / (float)artwork->h; + _global.bezelCutout.h *= (float)_global.canvasHeight / (float)artwork->h; + _global.bezelHasCutout = true; + _progTrace("Bezel cutout is %.1f,%.1f %.1fx%.1f", _global.bezelCutout.x, _global.bezelCutout.y, _global.bezelCutout.w, _global.bezelCutout.h); + } + free(sidecar); + SDL_DestroySurface(artwork); + for (c = 0; c < (int32_t)SDL_arraysize(candidate); c++) { + free(candidate[c]); + } +} + + +// Reads the cutout a bezel declares beside its artwork: a Lua file, as controls.cfg is, setting +// CUTOUT = { x = , y = , width = , height = } in the artwork's own pixels, run in a throwaway state +// with no libraries because it is data and not a program. False when there is no sidecar or it +// declares no cutout; one that will not run, or that names a rectangle outside the artwork, is +// fatal. +static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout) { + static const char *fields[BEZEL_CUTOUT_FIELDS] = { "x", "y", "width", "height" }; + lua_State *L = NULL; + char *source = NULL; + size_t bytes = 0; + int32_t status = 0; + int32_t i = 0; + bool found = false; + double value[BEZEL_CUTOUT_FIELDS]; + + if (!vfsExists(path)) { + return false; + } + source = vfsRead(path, &bytes); + if (source == NULL) { + utilDie("Unable to read the bezel cutout %s.", path); + } + L = luaL_newstate(); + if (L == NULL) { + utilDie("Out of memory reading the bezel cutout %s.", path); + } + status = luaL_loadbuffer(L, source, bytes, path); + free(source); + if (status || lua_pcall(L, 0, 0, 0)) { + utilDie("%s", lua_tostring(L, -1)); + } + lua_getglobal(L, BEZEL_CUTOUT_TABLE); + if (lua_istable(L, -1)) { + found = true; + for (i = 0; i < BEZEL_CUTOUT_FIELDS; i++) { + lua_getfield(L, -1, fields[i]); + if (!lua_isnumber(L, -1)) { + utilDie("The bezel cutout %s needs a numeric %s.", path, fields[i]); + } + value[i] = lua_tonumber(L, -1); + lua_pop(L, 1); + } + } + lua_close(L); + if (!found) { + return false; + } + cutout->x = (float)value[0]; + cutout->y = (float)value[1]; + cutout->w = (float)value[2]; + cutout->h = (float)value[3]; + if ((cutout->w < 1.0f) || (cutout->h < 1.0f) || (cutout->x < 0.0f) || (cutout->y < 0.0f) || ((cutout->x + cutout->w) > (float)width) || ((cutout->y + cutout->h) > (float)height)) { + utilDie("The bezel cutout %s is not inside the %dx%d artwork.", path, width, height); + } + + return true; +} + + // Builds a config for scriptExecute/scriptPush from the games.dat style table at stack index 1. static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) { const char *confKey = NULL; @@ -1529,6 +2019,12 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) { c->audioOutputTrack = (int32_t)valueNumber; } else if (strcmp(confKey, "AUDIO_DELAY") == 0) { c->audioDelayMs = (int32_t)valueNumber; + } else if (strcmp(confKey, "AUDIO_SUFFIX") == 0) { + if (valueString == NULL) { + utilDie("AUDIO_SUFFIX must be a string."); + } + free(c->audioSuffix); + c->audioSuffix = strdup(valueString); } else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) { c->legacySpriteArgs = valueBoolean; } else if (strcmp(confKey, "CANVAS_X") == 0) { @@ -1702,7 +2198,118 @@ static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2) { } +// Builds the rectangle the game is drawn into and the Sinden rings around it, in video +// coordinates. The inputs apply in order: the whole logical rectangle, or the cutout when a bezel +// declares one; then --scalefactor, which sets the size, and --shiftx and --shifty, which place it +// in the room the scale left, exactly as Hypseus does, so at full scale there is no room and a +// shift does nothing; and last the Sinden border, so that a border is a border whatever the shift +// and the scale did. Rotation is not an input: the frame is drawn unrotated and turned as a whole +// when it is shown. Mouse positions map back into the rectangle through drawScaleX and drawScaleY. +static void _computeVideoRect(void) { + SDL_FRect rect; + SDL_FRect border; + float scaledW = 0.0f; + float scaledH = 0.0f; + float roomX = 0.0f; + float roomY = 0.0f; + int32_t black = _global.sindenBlackWidth; + int32_t white = _global.sindenWhiteWidth; + int32_t total = 0; + int32_t room = 0; + bool atWindow = false; + + // The whole logical rectangle to start with, or the hole in the bezel artwork when one is + // declared: either way, everything after this places the picture inside it. + if (_global.bezelHasCutout) { + rect = _global.bezelCutout; + } else { + _logicalRect(&rect); + } + + // Overscan compensation shrinks it, and the shift then moves it inside the room that leaves: + // at the extreme the picture is flush against one side of that room and can never be pushed + // off, and with no shift it is centred exactly where the scale factor alone would put it. + scaledW = rect.w * (float)_global.videoScale / (float)SCALE_FACTOR_MAX; + scaledH = rect.h * (float)_global.videoScale / (float)SCALE_FACTOR_MAX; + roomX = (rect.w - scaledW) / 2.0f; + roomY = (rect.h - scaledH) / 2.0f; + rect.x += roomX + roomX * (float)_global.videoShiftX / (float)VIDEO_SHIFT_MAX; + rect.y += roomY + roomY * (float)_global.videoShiftY / (float)VIDEO_SHIFT_MAX; + rect.w = scaledW; + rect.h = scaledH; + + // The Sinden border is the outermost thing: the black ring takes the whole rectangle, the + // white ring sits inside it, and the game goes inside both. A scale small enough to leave no + // room squeezes the two rings rather than swallowing the picture. With bezel artwork around + // the picture the rings belong at the window's edge instead, because the gun's camera sees the + // whole screen and not just the picture; there they are painted over the artwork and leave the + // picture the size it already is. --sindenedge decides it either way. + if (_global.sindenBorder) { + atWindow = (_global.conf->sindenEdge == SINDEN_EDGE_WINDOW) || ((_global.conf->sindenEdge == SINDEN_EDGE_AUTO) && (_global.bezelTexture != NULL)); + if (atWindow) { + _logicalRect(&border); + } else { + border = rect; + } + total = black + white; + room = (int32_t)(SDL_min(border.w, border.h) - SINDEN_MIN_VIDEO_PIXELS) / 2; + if (room < 0) { + room = 0; + } + if (total > room) { + black = (total > 0) ? (black * room / total) : 0; + white = room - black; + } + _global.sindenBlackRect = border; + _global.sindenWhiteRect.x = border.x + (float)black; + _global.sindenWhiteRect.y = border.y + (float)black; + _global.sindenWhiteRect.w = border.w - (float)(black * 2); + _global.sindenWhiteRect.h = border.h - (float)(black * 2); + _global.sindenInnerRect.x = border.x + (float)(black + white); + _global.sindenInnerRect.y = border.y + (float)(black + white); + _global.sindenInnerRect.w = border.w - (float)((black + white) * 2); + _global.sindenInnerRect.h = border.h - (float)((black + white) * 2); + _global.sindenAtWindow = atWindow; + if (!atWindow) { + rect = _global.sindenInnerRect; + } + } + + _global.videoRect = rect; + _global.drawScaleX = (double)_global.canvasWidth / (double)rect.w; + _global.drawScaleY = (double)_global.canvasHeight / (double)rect.h; + _global.refreshDisplay = true; + _progTrace("Video rectangle is %.1f,%.1f %.1fx%.1f (shift %d,%d scale %d rotate %d)", rect.x, rect.y, rect.w, rect.h, _global.videoShiftX, _global.videoShiftY, _global.videoScale, _global.videoRotate); +} + + // Maps an SDL joystick instance ID to our controller slot, or -1. +// The four places a user configuration file is read from, in the order they are applied: the +// working directory, above the data directory, inside it, and beside the game's script. A place +// that repeats an earlier one comes back NULL, because without -d the data directory is the +// script's own and the same file would otherwise be read twice. The caller frees the strings. +static void _configPlaces(const char *file, const char *dataDir, const char *scriptFile, char **places) { + char *scriptDir = utilGetUpToLastPathComponent(scriptFile); + int32_t c = 0; + int32_t x = 0; + + places[0] = strdup(file); + places[1] = utilCreateString("%s..%c%s", dataDir, utilGetPathSeparator(), file); + places[2] = utilCreateString("%s%s", dataDir, file); + places[3] = utilCreateString("%s%s", scriptDir, file); + free(scriptDir); + for (c = 0; c < SETTINGS_PLACES; c++) { + for (x = 0; x < c; x++) { + if ((places[x] != NULL) && (strcmp(places[x], places[c]) == 0)) { + free(places[c]); + places[c] = NULL; + break; + } + } + } +} + + static int32_t _controllerSlot(SDL_JoystickID which) { int32_t x = 0; @@ -1725,9 +2332,11 @@ static void _createScriptContext(void) { } -// The sound effect volume a script starts with, from the non-disc volume setting. -static int32_t _defaultEffectsVolume(void) { - return AUDIO_MAX_VOLUME * _global.conf->volumeNonVldp / VOLUME_MAX; +// The volume a script starts a family of sounds at, from the non-disc volume setting, on that +// family's own scale. Sound effects and music therefore begin equally loud, which is what a user +// who set --volume_nonvldp meant; Hypseus instead starts its music mixer a third of the way up. +static int32_t _defaultVolume(int32_t maximum) { + return maximum * _global.conf->volumeNonVldp / VOLUME_MAX; } @@ -1780,6 +2389,30 @@ static void _deliverKey(bool down, int32_t keysym, int32_t scancode) { } +// A mapped switch nothing pressed. SWITCH_MOUSE_DISCONNECT has no key, button or axis to arrive +// on, so it is handed to the script directly rather than through a code. +static void _deliverSwitch(InputE move, bool down) { + if (_global.frozen) { + return; + } + _global.switchHeld[move] = down; + _callLua(down ? "onInputPressed" : "onInputReleased", "i", (int32_t)move); +} + + +// Puts the disc picture controls on whichever player is showing the disc. A frame file crosses into +// another player as it plays, so this runs whenever a control changes and again once a frame, which +// is what keeps monochrome, the blend and the luma level on a multi segment disc. +static void _discApplyPicture(void) { + if (_global.videoHandle < 0) { + return; + } + videoSetMonochrome(_global.videoHandle, _global.discMonochrome); + videoSetBlend(_global.videoHandle, _global.discBlend); + videoSetLuma(_global.videoHandle, _global.discLumaOn, _global.discLumaLevel); +} + + // The disc's current frame, whichever kind it is: a frame file counts across all its segments. // Only with a disc (videoHandle >= 0). static int64_t _discGetFrame(void) { @@ -1819,6 +2452,35 @@ static void _discSeek(int64_t frame) { } +// Points the disc's audio at ".ogg" beside its video (every segment of a frame +// file), reopened at the current frame, and remembers the suffix so a script reset can undo it. +// An empty suffix restores the default audio. False, with the audio unchanged, when the file is +// missing. Only with a disc. +static bool _discSetAudioSuffix(const char *suffix) { + bool opened = false; + char *audio = NULL; + char *copy = NULL; + + if (_global.conf->isFrameFile) { + opened = frameFileSetAudioSuffix(_global.frameFileHandle, suffix); + } else { + audio = frameFileAudioName(_global.conf->videoFile, suffix); + opened = ((suffix[0] == 0) || (audio != NULL)) && videoReopenAudio(_global.videoHandle, audio); + free(audio); + } + if (opened) { + copy = strdup(suffix); + if (!copy) { + utilDie("Unable to allocate the audio suffix."); + } + free(_global.discAudioSuffix); + _global.discAudioSuffix = copy; + } + + return opened; +} + + // Splash screens: fade in the Kangaroo Punch logo, cross fade to the Singe logo, fade out. static void _doLogos(void) { int32_t i = 0; @@ -1886,8 +2548,10 @@ static void _doLogos(void) { // Bresenham line into the overlay, clipped to it first so a line from far off screen costs only // its visible part. The overlay must be locked by the caller. // This frame's guiDraw calls, over the overlay: each GUI's texture scaled into the target the way -// the overlay is (overlay coordinates map through the same ratio), or covering it. -static void _drawGuis(const SDL_FRect *target) { +// the overlay is (overlay coordinates map through the same ratio), or covering it. The frame makes +// two passes, one for the GUIs over the picture and one for the screen space ones over the whole +// window, and _guisShown() closes the queue after the second. +static void _drawGuis(const SDL_FRect *target, bool screen) { float scaleX = target->w / (float)_global.overlay->w; float scaleY = target->h / (float)_global.overlay->h; SDL_FRect dest; @@ -1896,7 +2560,7 @@ static void _drawGuis(const SDL_FRect *target) { for (x = 0; x < _global.guiDrawCount; x++) { SDL_Texture *texture = guiTexture2D(_global.guiDraws[x].gui); - if (texture == NULL) { + if ((texture == NULL) || (_global.guiDraws[x].screen != screen)) { continue; } if (_global.guiDraws[x].whole) { @@ -1909,9 +2573,6 @@ static void _drawGuis(const SDL_FRect *target) { } SDL_RenderTexture(_global.renderer, texture, NULL, &dest); } - memcpy(_global.guiShown, _global.guiDraws, sizeof(GuiDrawT) * (size_t)_global.guiDrawCount); - _global.guiShownCount = _global.guiDrawCount; - _global.guiDrawCount = 0; } @@ -2093,6 +2754,52 @@ static void _drawPauseIndicator(const SDL_FRect *target) { } +// The four bands between two nested rectangles: a ring, with whatever is inside it left alone. +static void _drawRing(const SDL_FRect *outer, const SDL_FRect *inner, const SDL_Color *color) { + SDL_FRect band[4]; + + band[0].x = outer->x; + band[0].y = outer->y; + band[0].w = outer->w; + band[0].h = inner->y - outer->y; + band[1].x = outer->x; + band[1].y = inner->y + inner->h; + band[1].w = outer->w; + band[1].h = (outer->y + outer->h) - (inner->y + inner->h); + band[2].x = outer->x; + band[2].y = inner->y; + band[2].w = inner->x - outer->x; + band[2].h = inner->h; + band[3].x = inner->x + inner->w; + band[3].y = inner->y; + band[3].w = (outer->x + outer->w) - (inner->x + inner->w); + band[3].h = inner->h; + SDL_SetRenderDrawColor(_global.renderer, color->r, color->g, color->b, color->a); + SDL_RenderFillRects(_global.renderer, band, (int32_t)SDL_arraysize(band)); +} + + +// The Sinden light gun border. Around the picture the two rings are filled rectangles the picture +// then covers, which is how Singe has always drawn them. At the window's edge, where bezel artwork +// puts them, they are drawn as rings instead and last of all, so the artwork between the border and +// the picture stays visible and nothing paints over the white ring the gun's camera looks for. +static void _drawSindenBorder(void) { + if (_global.sindenAtWindow) { + if (_global.sindenBlackWidth > 0) { + _drawRing(&_global.sindenBlackRect, &_global.sindenWhiteRect, &_global.sindenBlackColor); + } + _drawRing(&_global.sindenWhiteRect, &_global.sindenInnerRect, &_global.sindenWhiteColor); + return; + } + if (_global.sindenBlackWidth > 0) { + SDL_SetRenderDrawColor(_global.renderer, _global.sindenBlackColor.r, _global.sindenBlackColor.g, _global.sindenBlackColor.b, _global.sindenBlackColor.a); + SDL_RenderFillRect(_global.renderer, &_global.sindenBlackRect); + } + SDL_SetRenderDrawColor(_global.renderer, _global.sindenWhiteColor.r, _global.sindenWhiteColor.g, _global.sindenWhiteColor.b, _global.sindenWhiteColor.a); + SDL_RenderFillRect(_global.renderer, &_global.sindenWhiteRect); +} + + // The 2D emitter's trails: a ribbon of quads through each particle's recorded points, fading // toward the tail, textured by the middle column of the first frame so the disc's edge softens it. static void _drawTrails2D(const EmitterViewT *view, const ParticleTexturesT *cache, const SDL_FRect *target, float scaleX, float scaleY) { @@ -2301,6 +3008,111 @@ static void _freezeGame(bool freeze) { // Maintains a list of keys that are down. +// A gamepad mapping for a device SDL does not recognise. Its axes in order become the two sticks +// and then the two triggers, its buttons in order become the standard gamepad buttons, and its +// first hat becomes the D-pad (a device with no hat spends its last four buttons on the D-pad +// instead). A device with more controls than the gamepad model can name loses the extras, which is +// the price of every device reaching a game through one family rather than two, and the mapping is +// traced so a user can paste it into their own gamecontrollerdb.txt and rearrange it. +static bool _gamepadMappingFor(SDL_JoystickID id, char *mapping, size_t size) { + static const char *axisNames[] = { "leftx", "lefty", "rightx", "righty", "lefttrigger", "righttrigger" }; + static const char *buttonNames[] = { "a", "b", "x", "y", "back", "guide", "start", "leftstick", "rightstick", "leftshoulder", "rightshoulder", "dpup", "dpdown", "dpleft", "dpright" }; + static const char *hatNames[] = { "dpup", "dpright", "dpdown", "dpleft" }; // In the order of SDL's hat bits 1, 2, 4 and 8 + SDL_Joystick *joystick = SDL_OpenJoystick(id); + char guid[GAMEPAD_GUID_MAX]; + char name[GAMEPAD_NAME_MAX]; + const char *reported = NULL; + size_t used = 0; + int32_t axes = 0; + int32_t buttons = 0; + int32_t hats = 0; + int32_t names = 0; + int32_t x = 0; + + // The counts need the device open; the name and the GUID are copied out before it is closed + // again, since the name belongs to the joystick and goes with it. + if (joystick == NULL) { + return false; + } + axes = SDL_GetNumJoystickAxes(joystick); + buttons = SDL_GetNumJoystickButtons(joystick); + hats = SDL_GetNumJoystickHats(joystick); + reported = SDL_GetJoystickName(joystick); + SDL_strlcpy(name, (reported != NULL) ? reported : GAMEPAD_NAME_UNKNOWN, sizeof(name)); + SDL_GUIDToString(SDL_GetJoystickGUID(joystick), guid, sizeof(guid)); + SDL_CloseJoystick(joystick); + if ((axes <= 0) && (buttons <= 0) && (hats <= 0)) { + return false; + } + + // A comma in the name would end the field, so any comma becomes a space. + for (x = 0; name[x] != '\0'; x++) { + if (name[x] == ',') { + name[x] = ' '; + } + } + + used = (size_t)SDL_snprintf(mapping, size, "%s,%s,platform:%s", guid, name, SDL_GetPlatform()); + names = (int32_t)(sizeof(buttonNames) / sizeof(buttonNames[0])); + if (hats > 0) { + names -= GAMEPAD_HAT_DIRECTIONS; + } + for (x = 0; (x < buttons) && (x < names) && (used < size); x++) { + used += (size_t)SDL_snprintf(mapping + used, size - used, ",%s:b%d", buttonNames[x], x); + } + names = (int32_t)(sizeof(axisNames) / sizeof(axisNames[0])); + for (x = 0; (x < axes) && (x < names) && (used < size); x++) { + used += (size_t)SDL_snprintf(mapping + used, size - used, ",%s:a%d", axisNames[x], x); + } + for (x = 0; (hats > 0) && (x < GAMEPAD_HAT_DIRECTIONS) && (used < size); x++) { + used += (size_t)SDL_snprintf(mapping + used, size - used, ",%s:h0.%d", hatNames[x], 1 << x); + } + if (used >= size - 1) { + utilSay("Warning: %s has too many controls to describe in one mapping; it is not opened.", name); + return false; + } + SDL_snprintf(mapping + used, size - used, ","); + + return true; +} + + +// --gamepad_reorder decides which physical pad is player one. Its digits are enumeration positions +// counted from zero, as Hypseus counts them, one for each slot in turn; a position it does not name +// fills whichever slot is left, in the order SDL found the pads. Without the option every slot is +// SDL's own position, which is what Singe has always done. +static void _gamepadOrder(int32_t *order, int32_t count) { + const char *text = _global.conf->gamepadOrder; + bool used[GAMEPAD_ORDER_DIGITS]; + int32_t slot = 0; + int32_t x = 0; + + memset(used, 0, sizeof(used)); + for (slot = 0; slot < MAX_CONTROLLERS; slot++) { + order[slot] = -1; + } + slot = 0; + while ((text != NULL) && (*text != '\0') && (slot < MAX_CONTROLLERS)) { + if ((*text >= '0') && (*text <= '9')) { + x = *text - '0'; + if (!used[x]) { + used[x] = true; + order[slot] = x; + slot++; + } + } + text++; + } + for (x = 0; (x < count) && (slot < MAX_CONTROLLERS); x++) { + if ((x < GAMEPAD_ORDER_DIGITS) && used[x]) { + continue; + } + order[slot] = x; + slot++; + } +} + + // A GUI element's event, from RmlUi through gui.cpp: the function guiSetHandler registered for it. static void _guiEvent(int32_t gui, int32_t document, const char *id, const char *event, const char *value) { lua_State *L = _global.luaContext; @@ -2404,6 +3216,12 @@ static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32 int32_t i; float u; float v; + float pointX; + float pointY; + // A screen space GUI was drawn across the whole window instead of into the picture, so the + // pointer, which arrives in overlay coordinates over the picture, is carried the same way. + float screenX = (_global.videoRect.x + (float)x * _global.videoRect.w / (float)_global.overlay->w) * (float)_global.overlay->w / (float)_global.canvasWidth; + float screenY = (_global.videoRect.y + (float)y * _global.videoRect.h / (float)_global.overlay->h) * (float)_global.overlay->h / (float)_global.canvasHeight; for (i = _global.guiShownCount - 1; i >= 0; i--) { const GuiDrawT *shown = &_global.guiShown[i]; @@ -2411,6 +3229,8 @@ static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32 if (!guiValid(shown->gui) || !guiGetInput(shown->gui)) { continue; } + pointX = shown->screen ? screenX : (float)x; + pointY = shown->screen ? screenY : (float)y; if (shown->whole) { dest.x = 0.0f; dest.y = 0.0f; @@ -2419,12 +3239,12 @@ static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32 } else { dest = shown->dest; } - if (((float)x < dest.x) || ((float)y < dest.y) || ((float)x >= dest.x + dest.w) || ((float)y >= dest.y + dest.h) || (dest.w <= 0.0f) || (dest.h <= 0.0f)) { + if ((pointX < dest.x) || (pointY < dest.y) || (pointX >= dest.x + dest.w) || (pointY >= dest.y + dest.h) || (dest.w <= 0.0f) || (dest.h <= 0.0f)) { continue; } *gui = shown->gui; - *guiX = (int32_t)(((float)x - dest.x) * (float)guiGetWidth(shown->gui) / dest.w); - *guiY = (int32_t)(((float)y - dest.y) * (float)guiGetHeight(shown->gui) / dest.h); + *guiX = (int32_t)((pointX - dest.x) * (float)guiGetWidth(shown->gui) / dest.w); + *guiY = (int32_t)((pointY - dest.y) * (float)guiGetHeight(shown->gui) / dest.h); return true; } // Nothing drawn flat under it: a surface in the scene showing a GUI that takes input. @@ -2438,6 +3258,44 @@ static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32 } +// guiDraw and guiDrawScreen queue the same record; only the rectangle it lands in differs. +static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen) { + GuiDrawT *draw; + int32_t top = lua_gettop(L); + + _argCheck(L, method, 1, 5); + if ((top == 2) || (top == 4)) { + _luaDie(L, method, "Expected 1, 3 or 5 arguments, got %d.", top); + } + if (_global.guiDrawCount >= GUI_DRAW_MAX) { + return 0; + } + draw = &_global.guiDraws[_global.guiDrawCount]; + draw->gui = _argGui(L, method, 1); + draw->whole = (top == 1); + draw->screen = screen; + if (!draw->whole) { + draw->dest.x = (float)_argNumber(L, method, 2); + draw->dest.y = (float)_argNumber(L, method, 3); + draw->dest.w = (top == 5) ? (float)_argNumber(L, method, 4) : (float)guiGetWidth(draw->gui); + draw->dest.h = (top == 5) ? (float)_argNumber(L, method, 5) : (float)guiGetHeight(draw->gui); + } + _global.guiDrawCount++; + _overlayTouched(); + + return 0; +} + + +// Both passes of _drawGuis are done: this frame's GUIs become the ones a pointer may land on, and +// the queue is empty for the next. +static void _guisShown(void) { + memcpy(_global.guiShown, _global.guiDraws, sizeof(GuiDrawT) * (size_t)_global.guiDrawCount); + _global.guiShownCount = _global.guiDrawCount; + _global.guiDrawCount = 0; +} + + // A framework switch from a pad or mouse, as the navigation key a document understands: the // directions, ACTION_1 as Return (activate) and ACTION_2 as Escape. True when a GUI used it. static bool _guiSwitch(InputE move, bool down) { @@ -2552,6 +3410,76 @@ static void _installFileHooks(lua_State *L) { } +// Whether an SDL event is somebody touching a control, which is what --idleexit counts and what +// --startsilent waits for. Devices arriving and leaving are not. +static bool _isInputEvent(uint32_t type) { + switch (type) { + case SDL_EVENT_KEY_DOWN: + case SDL_EVENT_KEY_UP: + case SDL_EVENT_TEXT_INPUT: + case SDL_EVENT_MOUSE_MOTION: + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + case SDL_EVENT_MOUSE_WHEEL: + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + case SDL_EVENT_GAMEPAD_BUTTON_UP: + return true; + + default: + return false; + } +} + + +// --joymouse: a stick drives the mouse cursor, so a light gun game is playable on a cabinet that +// has a stick and no mouse. The left stick of gamepad slot 0 drives it -- every device Singe opens +// is a gamepad, an unrecognised one through a mapping Singe writes for it -- and --js_range is how +// many video pixels a frame full deflection is worth. The movement is turned back through the +// presentation exactly as a real mouse's is, so a rotated cabinet moves the cursor the way the +// player sees the stick pushed. +static void _joyMouseUpdate(void) { + int32_t xAxis = 0; + int32_t yAxis = 0; + int32_t deadX = 0; + int32_t deadY = 0; + int32_t index = 0; + float moveX = 0.0f; + float moveY = 0.0f; + + if (!_global.joyMouseEnabled || !_global.mouseEnabled || (_global.mouseMode != MOUSE_SINGLE) || (_global.canvasWidth <= 0)) { + return; + } + if (SDL_GetTicks() < _global.joyMouseClock) { + return; + } + _global.joyMouseClock = SDL_GetTicks() + FRAME_TICK_MS; + if (_global.controllers[0] == NULL) { + return; + } + index = AXIS_INDEX_CONTROLLER(0, 0); + xAxis = _global.axisCache[index]; + yAxis = _global.axisCache[index + 1]; + deadX = _global.axisDeadZone[index]; + deadY = _global.axisDeadZone[index + 1]; + if (abs(xAxis) > deadX) { + moveX = (float)xAxis * (float)_global.conf->joyMouseRange / (float)AXIS_VALUE_MAX; + } + if (abs(yAxis) > deadY) { + moveY = (float)yAxis * (float)_global.conf->joyMouseRange / (float)AXIS_VALUE_MAX; + } + if ((moveX == 0.0f) && (moveY == 0.0f)) { + return; + } + _unrotateDelta(moveX, moveY, &moveX, &moveY); + _global.joyMouseX += (int32_t)moveX; + _global.joyMouseY += (int32_t)moveY; + _global.joyMouseX = SDL_clamp(_global.joyMouseX, 0, _global.canvasWidth - 1); + _global.joyMouseY = SDL_clamp(_global.joyMouseY, 0, _global.canvasHeight - 1); + _fireMouseMoved(0, (int32_t)(_global.joyMouseX * _global.overlayScaleX), (int32_t)(_global.joyMouseY * _global.overlayScaleY), (int32_t)moveX, (int32_t)moveY); +} + + // lfs.attributes through the vfs, with lfs's own forms: attributes(path) makes a table, // attributes(path, name) answers one attribute (an unknown name raises), attributes(path, table) // fills the table. A packed entry answers as a read only file; a plain filesystem path goes to @@ -2752,9 +3680,10 @@ static int32_t _loadAudioCalibration(void) { // the framework but not the API. Leaves the dead zone and the switch mappings behind. static void _loadControlMappings(void) { lua_State *L = NULL; - char *scriptDir = utilGetUpToLastPathComponent(_global.conf->scriptFile); - char *candidates[4]; - bool seen = false; + char *places[SETTINGS_PLACES]; + double threshold = 0.0; + int32_t code = 0; + int32_t zone = 0; int32_t c = 0; int32_t x = 0; int32_t y = 0; @@ -2773,26 +3702,22 @@ static void _loadControlMappings(void) { if (luaL_loadbuffer(L, (const char *)controls_cfg, controls_cfg_len, CONTROLS_FILE) || lua_pcall(L, 0, 0, 0)) { utilDie("%s", lua_tostring(L, -1)); } - candidates[0] = strdup(CONTROLS_FILE); - candidates[1] = utilCreateString("%s..%c%s", _global.conf->dataDir, utilGetPathSeparator(), CONTROLS_FILE); - candidates[2] = utilCreateString("%s%s", _global.conf->dataDir, CONTROLS_FILE); - candidates[3] = utilCreateString("%s%s", scriptDir, CONTROLS_FILE); - for (c = 0; c < (int32_t)SDL_arraysize(candidates); c++) { - // Without -d the data directory is the script's, so the same file would run twice. - seen = false; - for (x = 0; x < c; x++) { - if (strcmp(candidates[x], candidates[c]) == 0) { - seen = true; + // --keymapfile names one file and the search is not made, so a front end can be certain + // which mappings a game got. + if (_global.conf->keymapFile != NULL) { + if (!vfsExists(_global.conf->keymapFile)) { + utilDie("Unable to find the control mappings %s.", _global.conf->keymapFile); + } + _loadControlsFile(_global.conf->keymapFile); + } else { + _configPlaces(CONTROLS_FILE, _global.conf->dataDir, _global.conf->scriptFile, places); + for (c = 0; c < SETTINGS_PLACES; c++) { + if (places[c] != NULL) { + _loadControlsFile(places[c]); } - } - if (!seen) { - _loadControlsFile(candidates[c]); + free(places[c]); } } - for (c = 0; c < (int32_t)SDL_arraysize(candidates); c++) { - free(candidates[c]); - } - free(scriptDir); // Parse results lua_getglobal(L, "DEAD_ZONE"); if (lua_isnumber(L, -1)) { @@ -2800,6 +3725,60 @@ static void _loadControlMappings(void) { } lua_pop(L, 1); _progTrace("Controller dead zone is %d", _global.controllerDeadZone); + + // TRIGGER_THRESHOLD is Hypseus's unit, per cent of full travel, and --trigger_threshold sets + // the same number from the command line, where it wins. Zero leaves the triggers on DEAD_ZONE, + // which is what every release so far has given them, so nothing moves for anyone not asking. + threshold = _global.conf->triggerThreshold; + if (threshold <= 0.0) { + lua_getglobal(L, "TRIGGER_THRESHOLD"); + if (lua_isnumber(L, -1)) { + threshold = lua_tonumber(L, -1); + } + lua_pop(L, 1); + } + _global.triggerThreshold = (int32_t)(threshold * (double)AXIS_VALUE_MAX / PERCENT_FULL); + if (_global.triggerThreshold > 0) { + _progTrace("Trigger threshold is %.1f per cent (%d)", threshold, _global.triggerThreshold); + } + + // Every axis starts on the global dead zone, the triggers take the threshold when there is one, + // and DEAD_ZONES then names the individual axes a stiff stick or a worn trigger needs moved. + for (x = 0; x < AXIS_COUNT; x++) { + _global.axisDeadZone[x] = _global.controllerDeadZone; + } + if (_global.triggerThreshold > 0) { + for (x = 0; x < MAX_CONTROLLERS; x++) { + for (y = CONTROLLER_TRIGGER_FIRST; y < CONTROLLER_AXIS_COUNT; y++) { + _global.axisDeadZone[AXIS_INDEX_CONTROLLER(x, y)] = _global.triggerThreshold; + } + } + } + lua_getglobal(L, "DEAD_ZONES"); + if (lua_istable(L, -1)) { + y = (int32_t)lua_rawlen(L, -1); + for (x = 1; x <= y; x++) { + lua_rawgeti(L, -1, x); + if (lua_istable(L, -1)) { + lua_rawgeti(L, -1, 1); + lua_getfield(L, -1, "value"); + code = lua_isnumber(L, -1) ? (int32_t)lua_tonumber(L, -1) : -1; + lua_pop(L, 2); + lua_rawgeti(L, -1, 2); + zone = lua_isnumber(L, -1) ? (int32_t)lua_tonumber(L, -1) : -1; + lua_pop(L, 1); + c = _axisIndexForCode(code); + if ((c < 0) || (zone < 0) || (zone > AXIS_VALUE_MAX)) { + utilSay("Warning: DEAD_ZONES entry %d names no axis, or a dead zone outside 0 to %d.", x, AXIS_VALUE_MAX); + } else { + _global.axisDeadZone[c] = zone; + _progTrace("Dead zone for input code %d is %d", code, zone); + } + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); for (x = 0; x < INPUT_COUNT; x++) { // Each INPUT_* table holds { name = ..., value = ... } entries; collect the values. lua_getglobal(L, _inputNames[x].configName); @@ -2868,6 +3847,44 @@ static SDL_Texture *_loadEmbeddedTexture(const uint8_t *data, size_t length, SDL } +// SDL knows a great many pads already; a gamecontrollerdb.txt adds the rest. It is looked for in +// the four places controls.cfg is looked for and every one found is added, so a file beside the +// game wins over a machine wide one. +static void _loadGamepadDatabase(void) { + char *places[SETTINGS_PLACES]; + char *data = NULL; + size_t bytes = 0; + int32_t added = 0; + int32_t c = 0; + + _configPlaces(GAMEPAD_DATABASE_FILE, _global.conf->dataDir, _global.conf->scriptFile, places); + for (c = 0; c < SETTINGS_PLACES; c++) { + if ((places[c] != NULL) && vfsExists(places[c])) { + data = vfsRead(places[c], &bytes); + if (data != NULL) { + added = SDL_AddGamepadMappingsFromIO(SDL_IOFromConstMem(data, bytes), true); + if (added < 0) { + utilSay("Warning: %s could not be read: %s", places[c], SDL_GetError()); + } else { + _progTrace("Added %d controller mappings from %s", added, places[c]); + } + free(data); + } + } + free(places[c]); + } +} + + +// The whole logical rectangle: the window, in the coordinates everything is drawn in. +static void _logicalRect(SDL_FRect *rect) { + rect->x = 0.0f; + rect->y = 0.0f; + rect->w = (float)_global.canvasWidth; + rect->h = (float)_global.canvasHeight; +} + + // Hands a hooked call to the original function kept as upvalue 1, returning all it returns. static int32_t _luaCallOriginal(lua_State *L) { lua_pushvalue(L, lua_upvalueindex(1)); @@ -3159,6 +4176,56 @@ static int32_t _luaTraceback(lua_State *L) { } +// A pointer position in presentation coordinates to the overlay coordinates the game works in: +// the rotation comes off first, then the position maps into whatever the shift, the scale factor +// and the Sinden border left of the picture. +static void _mapPointer(float px, float py, int32_t *x, int32_t *y) { + float vx = 0; + float vy = 0; + + _unrotatePoint(px, py, &vx, &vy); + *x = (int32_t)((vx - _global.videoRect.x) * _global.drawScaleX * _global.overlayScaleX); + *y = (int32_t)((vy - _global.videoRect.y) * _global.drawScaleY * _global.overlayScaleY); +} + + +// The same for a relative movement, which turns with the picture but does not translate. +static void _mapPointerDelta(float dx, float dy, int32_t *xr, int32_t *yr) { + float vx = 0; + float vy = 0; + + _unrotateDelta(dx, dy, &vx, &vy); + *xr = (int32_t)(vx * _global.drawScaleX * _global.overlayScaleX); + *yr = (int32_t)(vy * _global.drawScaleY * _global.overlayScaleY); +} + + +// Every joystick SDL has no gamepad mapping for gets one written for it, so an arcade encoder, a +// spinner, a wheel or a flight stick reaches a game through GAMEPAD_0 to GAMEPAD_3 like anything +// else, and nothing else in the engine, in controls.cfg or in a script has to know the difference. +// The order of precedence is SDL's own database first, then whatever a gamecontrollerdb.txt +// supplied, and only then this: the synthetic mapping is the last resort, and it is how a device +// nobody has ever written a mapping for still works. +static void _mapUnknownJoysticks(void) { + char mapping[GAMEPAD_MAPPING_MAX]; + int32_t count = 0; + int32_t x = 0; + SDL_JoystickID *ids = SDL_GetJoysticks(&count); + + for (x = 0; (ids != NULL) && (x < count); x++) { + if (SDL_IsGamepad(ids[x]) || !_gamepadMappingFor(ids[x], mapping, sizeof(mapping))) { + continue; + } + if (SDL_AddGamepadMapping(mapping) < 0) { + utilSay("Warning: no mapping could be made for %s: %s", SDL_GetJoystickNameForID(ids[x]), SDL_GetError()); + } else { + _progTrace("No gamepad mapping for \"%s\"; using %s", SDL_GetJoystickNameForID(ids[x]), mapping); + } + } + SDL_free(ids); +} + + // materialSetXxxMap(material[, image[, strength]]): a loaded sprite's surface or a KTX2 file as one // of the material's maps; nil (or nothing) clears it. Strength is only for the normal and // occlusion maps. @@ -3189,9 +4256,10 @@ static int32_t _materialSetMap(lua_State *L, const char *method, MaterialMapE ma } -// Converts the script visible 0..AUDIO_MAX_VOLUME scale to the mixer's scale. -static float _mixerGain(int32_t effectsVolume) { - return (float)effectsVolume / (float)AUDIO_MAX_VOLUME; +// Converts a script visible volume to the mixer's 0 to 1 gain. The two families keep different +// scales: sound effects run to AUDIO_MAX_VOLUME and music to MUSIC_MAX_VOLUME, which is Hypseus's. +static float _mixerGain(int32_t volume, int32_t maximum) { + return (float)volume / (float)maximum; } @@ -3213,14 +4281,82 @@ static void _navCallbacks(void) { } +// Any input of any kind: it restarts --idleexit's clock and lifts a --startsilent mute. +static void _noteInput(void) { + _global.idleClock = SDL_GetTicks(); + // Only --startsilent leaves the mixer muted with sound wanted, so nothing else can be lifted here. + if (_global.audioMuted && !_global.conf->noSound) { + _setAudioMuted(false); + } +} + + +// Stops a music track, frees it and forgets it. +static void _musicDestroy(MusicT *music) { + HASH_DEL(_global.musicList, music); + MIX_StopTrack(music->track, 0); + MIX_SetTrackAudio(music->track, NULL); + MIX_DestroyTrack(music->track); + MIX_DestroyAudio(music->audio); + free(music); +} + + +// Applies setOverlayOpacity's level to the overlay texture. Called again whenever the texture is made. +static void _overlayApplyOpacity(void) { + SDL_SetTextureAlphaMod(_global.overlayTexture, _global.overlayOpacity); +} + + // A colour as the overlay surface stores it. static uint32_t _overlayColor(const SDL_Color *color) { return SDL_MapRGBA(SDL_GetPixelFormatDetails(_global.overlay->format), NULL, color->r, color->g, color->b, color->a); } +// The overlay in luma only (BT.601 weights, alpha as it is) in a scratch surface its size, for +// overlaySetMonochrome. The overlay's own pixels stay in colour. +static SDL_Surface *_overlayGrey(void) { + const uint8_t *in = NULL; + uint8_t *out = NULL; + int32_t x = 0; + int32_t y = 0; + uint8_t grey = 0; + + if (_global.overlayGrey == NULL) { + _global.overlayGrey = SDL_CreateSurface(_global.overlay->w, _global.overlay->h, _global.overlay->format); + if (_global.overlayGrey == NULL) { + utilDie("%s", SDL_GetError()); + } + } + for (y = 0; y < _global.overlay->h; y++) { + in = (const uint8_t *)_global.overlay->pixels + (size_t)y * (size_t)_global.overlay->pitch; + out = (uint8_t *)_global.overlayGrey->pixels + (size_t)y * (size_t)_global.overlayGrey->pitch; + for (x = 0; x < _global.overlay->w; x++) { + grey = (uint8_t)((LUMA_WEIGHT_RED * in[BGRA_RED] + LUMA_WEIGHT_GREEN * in[BGRA_GREEN] + LUMA_WEIGHT_BLUE * in[BGRA_BLUE]) >> LUMA_WEIGHT_SHIFT); + out[BGRA_BLUE] = grey; + out[BGRA_GREEN] = grey; + out[BGRA_RED] = grey; + out[BGRA_ALPHA] = in[BGRA_ALPHA]; + in += COLOR_COMPONENTS; + out += COLOR_COMPONENTS; + } + } + + return _global.overlayGrey; +} + + +// Applies setOverlayLinearScale's choice to the overlay texture. Called again whenever the texture is made. +static void _overlayApplyScaleMode(void) { + SDL_SetTextureScaleMode(_global.overlayTexture, _global.overlayLinearScale ? SDL_SCALEMODE_LINEAR : SDL_SCALEMODE_NEAREST); +} + + // Replaces the overlay surface and texture (and the scene's targets) at a new size; its contents are lost. static void _overlayResize(int32_t width, int32_t height) { + SDL_DestroySurface(_global.overlayGrey); + _global.overlayGrey = NULL; SDL_DestroySurface(_global.overlay); _global.overlay = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_BGRA32); if (_global.overlay == NULL) { @@ -3234,6 +4370,8 @@ static void _overlayResize(int32_t width, int32_t height) { } sceneResize(width, height); SDL_SetTextureBlendMode(_global.overlayTexture, SDL_BLENDMODE_BLEND); + _overlayApplyScaleMode(); + _overlayApplyOpacity(); _global.overlayScaleX = (double)width / (double)_global.canvasWidth; _global.overlayScaleY = (double)height / (double)_global.canvasHeight; _overlayTouched(); @@ -3361,6 +4499,18 @@ static void _physicsCallbacks(void) { } +// The logical size the renderer presents: the video size, with the axes swapped by a quarter turn. +static void _presentSize(int32_t *width, int32_t *height) { + if ((_global.videoRotate == 90) || (_global.videoRotate == 270)) { + *width = _global.canvasHeight; + *height = _global.canvasWidth; + } else { + *width = _global.canvasWidth; + *height = _global.canvasHeight; + } +} + + // Routes a key, button, or axis direction code: engine switches first, then the script. static void _processKey(bool down, int32_t keysym, int32_t scancode) { InputE engine = INPUT_COUNT; @@ -3399,7 +4549,9 @@ static void _processKey(bool down, int32_t keysym, int32_t scancode) { break; case INPUT_QUIT: - _global.running = false; + if (_global.quitEnabled) { + _global.running = false; + } break; case INPUT_SCREENSHOT: @@ -3612,6 +4764,8 @@ static void _pushConstants(lua_State *L) { lua_pushinteger(L, _global.controllerDeadZone); lua_setglobal(L, "SINGE_DEAD_ZONE"); + lua_pushinteger(L, _global.triggerThreshold); + lua_setglobal(L, "SINGE_TRIGGER_THRESHOLD"); lua_pushboolean(L, _global.conf->legacySpriteArgs); lua_setglobal(L, "SINGE_LEGACY_SPRITE_ARGS"); lua_pushboolean(L, _global.conf->disc); @@ -3729,12 +4883,17 @@ static void _registerApi(lua_State *L) { lua_register(L, "cameraSetPerspective", apiCameraSetPerspective); // 3.00 lua_register(L, "colorBackground", apiColorBackground); // 1.xx lua_register(L, "colorForeground", apiColorForeground); // 1.xx + lua_register(L, "controllerDoRumble", apiControllerDoRumble); // Hypseus lua_register(L, "controllerGetAxis", apiControllerGetAxis); // 2.00 lua_register(L, "controllerGetButton", apiControllerGetButton); // 2.10 + lua_register(L, "controllerGetName", apiControllerGetName); // 3.00 + lua_register(L, "controllerHowMany", apiControllerHowMany); // Hypseus + lua_register(L, "controllerIsValid", apiControllerIsValid); // Hypseus lua_register(L, "debugPrint", apiDebugPrint); // 1.xx lua_register(L, "discAudio", apiDiscAudio); // 1.xx + lua_register(L, "discAudioSuffix", apiDiscAudioSuffix); // Hypseus lua_register(L, "discChangeSpeed", apiDiscChangeSpeed); // 1.xx lua_register(L, "discGetAudioTrack", apiDiscGetAudioTrack); // 2.10 lua_register(L, "discGetAudioTracks", apiDiscGetAudioTracks); // 2.10 @@ -3795,9 +4954,11 @@ static void _registerApi(lua_State *L) { lua_register(L, "fontSelect", apiFontSelect); // 1.xx lua_register(L, "fontToSprite", apiFontToSprite); // 1.xx lua_register(L, "fontUnload", apiFontUnload); // 2.00 + lua_register(L, "getFValue", apiGetFValue); // Hypseus lua_register(L, "guiClose", apiGuiClose); // 3.00 lua_register(L, "guiDelete", apiGuiDelete); // 3.00 lua_register(L, "guiDraw", apiGuiDraw); // 3.00 + lua_register(L, "guiDrawScreen", apiGuiDrawScreen); // 3.00 lua_register(L, "guiGetHeight", apiGuiGetHeight); // 3.00 lua_register(L, "guiGetStats", apiGuiGetStats); // 3.00 lua_register(L, "guiGetValue", apiGuiGetValue); // 3.00 @@ -3816,6 +4977,8 @@ static void _registerApi(lua_State *L) { lua_register(L, "jointHinge", apiJointHinge); // 3.00 lua_register(L, "jointSetLimits", apiJointSetLimits); // 3.00 lua_register(L, "jointSlider", apiJointSlider); // 3.00 + lua_register(L, "joyMouseEnable", apiJoyMouseEnable); // Hypseus + lua_register(L, "joyMouseIsEnabled", apiJoyMouseIsEnabled); // 3.00 lua_register(L, "keyboardGetLastDown", apiKeyboardGetLastDown); // 2.10 lua_register(L, "keyboardGetLastUp", apiKeyboardGetLastUp); // 2.10 lua_register(L, "keyboardGetMode", apiKeyboardGetMode); // 1.xx RDG @@ -3830,6 +4993,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "lightSetRange", apiLightSetRange); // 3.00 lua_register(L, "lightSetShadow", apiLightSetShadow); // 3.00 lua_register(L, "lineDraw", apiLineDraw); // 3.00 + lua_register(L, "mainBezelLoaded", apiMainBezelLoaded); // Hypseus lua_register(L, "materialDelete", apiMaterialDelete); // 3.00 lua_register(L, "materialNew", apiMaterialNew); // 3.00 lua_register(L, "materialSetBlend", apiMaterialSetBlend); // 3.00 @@ -3862,12 +5026,22 @@ static void _registerApi(lua_State *L) { lua_register(L, "modelGetAnimations", apiModelGetAnimations); // 3.00 lua_register(L, "modelInstance", apiModelInstance); // 3.00 lua_register(L, "modelLoad", apiModelLoad); // 3.00 + lua_register(L, "mouseGetName", apiMouseGetName); // 3.00 lua_register(L, "mouseGetPosition", apiMouseGetPosition); // 2.00 lua_register(L, "mouseHowMany", apiMouseHowMany); // 1.18 RDG lua_register(L, "mouseSetCaptured", apiMouseSetCaptured); // 2.00 lua_register(L, "mouseSetEnabled", apiMouseSetEnabled); // 3.00 mouseEnable/mouseDisable are framework aliases. lua_register(L, "mouseSetMode", apiMouseSetMode); // 1.18 RDG + lua_register(L, "musicIsPlaying", apiMusicIsPlaying); // Hypseus + lua_register(L, "musicLoad", apiMusicLoad); // Hypseus + lua_register(L, "musicPause", apiMusicPause); // Hypseus + lua_register(L, "musicPlay", apiMusicPlay); // Hypseus + lua_register(L, "musicResume", apiMusicResume); // Hypseus + lua_register(L, "musicSetVolume", apiMusicSetVolume); // Hypseus + lua_register(L, "musicStop", apiMusicStop); // Hypseus + lua_register(L, "musicUnload", apiMusicUnload); // Hypseus + lua_register(L, "navAddNode", apiNavAddNode); // 3.00 lua_register(L, "navAgentDelete", apiNavAgentDelete); // 3.00 lua_register(L, "navAgentGetVelocity", apiNavAgentGetVelocity); // 3.00 @@ -3917,6 +5091,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "nodeSetSpriteFrame", apiNodeSetSpriteFrame); // 3.00 lua_register(L, "nodeSetText", apiNodeSetText); // 3.00 lua_register(L, "nodeSetVisible", apiNodeSetVisible); // 3.00 + lua_register(L, "overlayBanner", apiOverlayBanner); // Hypseus lua_register(L, "overlayBox", apiOverlayBox); // 2.00 lua_register(L, "overlayCircle", apiOverlayCircle); // 2.00 lua_register(L, "overlayClear", apiOverlayClear); // 1.xx @@ -3926,6 +5101,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "overlayLine", apiOverlayLine); // 2.00 lua_register(L, "overlayPlot", apiOverlayPlot); // 2.00 lua_register(L, "overlayPrint", apiOverlayPrint); // 1.xx + lua_register(L, "overlaySetMonochrome", apiOverlaySetMonochrome); // Hypseus lua_register(L, "overlaySetResolution", apiOverlaySetResolution); // 2.00 lua_register(L, "physicsRaycast", apiPhysicsRaycast); // 3.00 @@ -3959,6 +5135,8 @@ static void _registerApi(lua_State *L) { lua_register(L, "ragdollNew", apiRagdollNew); // 3.00 lua_register(L, "ragdollSetJoint", apiRagdollSetJoint); // 3.00 lua_register(L, "ragdollSetStrength", apiRagdollSetStrength); // 3.00 + lua_register(L, "ratioGetX", apiRatioGetX); // Hypseus + lua_register(L, "ratioGetY", apiRatioGetY); // Hypseus lua_register(L, "sceneEnable", apiSceneEnable); // 3.00 lua_register(L, "sceneGetSize", apiSceneGetSize); // 3.00 lua_register(L, "sceneGetStats", apiSceneGetStats); // 3.00 @@ -3978,8 +5156,18 @@ static void _registerApi(lua_State *L) { lua_register(L, "sceneSetSkyIntensity", apiSceneSetSkyIntensity); // 3.00 lua_register(L, "sceneSetTonemap", apiSceneSetTonemap); // 3.00 lua_register(L, "sceneUnproject", apiSceneUnproject); // 3.00 + lua_register(L, "scoreBezelClear", apiScoreBezelClear); // Hypseus + lua_register(L, "scoreBezelCredits", apiScoreBezelCredits); // Hypseus + lua_register(L, "scoreBezelEnable", apiScoreBezelEnable); // Hypseus + lua_register(L, "scoreBezelGetState", apiScoreBezelGetState); // Hypseus + lua_register(L, "scoreBezelLives", apiScoreBezelLives); // Hypseus + lua_register(L, "scoreBezelScore", apiScoreBezelScore); // Hypseus + lua_register(L, "scoreBezelTwinScoreOn", apiScoreBezelTwinScoreOn); // Hypseus lua_register(L, "scriptExecute", apiScriptExecute); // 2.00 lua_register(L, "scriptPush", apiScriptPush); // 2.00 + lua_register(L, "setOverlayLinearScale", apiSetOverlayLinearScale); // Hypseus + lua_register(L, "setOverlayOnTop", apiSetOverlayOnTop); // Hypseus + lua_register(L, "setOverlayOpacity", apiSetOverlayOpacity); // Hypseus lua_register(L, "singeGetAudioCalibration", apiSingeGetAudioCalibration); // 3.00 lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00 @@ -3998,6 +5186,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeSetGameName", apiSingeSetGameName); // 1.15 RDG lua_register(L, "singeSetPauseFlag", apiSingeSetPauseFlag); // 1.xx RDG lua_register(L, "singeSetPauseKeyEnabled", apiSingeSetPauseKeyEnabled); // 3.00 singeEnablePauseKey/singeDisablePauseKey are framework aliases. + lua_register(L, "singeSetQuitKeyEnabled", apiSingeSetQuitKeyEnabled); // Hypseus keyboardCatchQuit is a framework alias with the opposite sense. lua_register(L, "singeVersion", apiSingeVersion); // 1.xx RDG lua_register(L, "singeWantsCrosshairs", apiSingeWantsCrosshairs); // 2.00 @@ -4014,6 +5203,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "soundGetVolume", apiSoundGetVolume); // 1.16 lua_register(L, "soundIsPlaying", apiSoundIsPlaying); // 1.16 RDG lua_register(L, "soundLoad", apiSoundLoad); // 1.xx + lua_register(L, "soundLoadData", apiSoundLoadData); // Hypseus lua_register(L, "soundPause", apiSoundPause); // 1.16 RDG lua_register(L, "soundPlay", apiSoundPlay); // 1.xx lua_register(L, "soundResume", apiSoundResume); // 1.16 RDG @@ -4027,21 +5217,36 @@ static void _registerApi(lua_State *L) { lua_register(L, "soundUnload", apiSoundUnload); // 2.00 lua_register(L, "spriteDraw", apiSpriteDraw); // 1.xx Handle first since 3.00. + lua_register(L, "spriteDrawFrame", apiSpriteDrawFrame); // Hypseus + lua_register(L, "spriteDrawGrid", apiSpriteDrawGrid); // Hypseus + lua_register(L, "spriteDrawRotatedFrame", apiSpriteDrawRotatedFrame); // Hypseus Handle first since 3.00. + lua_register(L, "spriteFrameHeight", apiSpriteFrameHeight); // Hypseus + lua_register(L, "spriteFrameWidth", apiSpriteFrameWidth); // Hypseus lua_register(L, "spriteGetFrame", apiSpriteGetFrame); // 2.10 + lua_register(L, "spriteGetFrames", apiSpriteGetFrames); // Hypseus lua_register(L, "spriteGetHeight", apiSpriteGetHeight); // 2.00 lua_register(L, "spriteGetWidth", apiSpriteGetWidth); // 2.00 lua_register(L, "spriteIsPlaying", apiSpriteIsPlaying); // 2.10 lua_register(L, "spriteLoad", apiSpriteLoad); // 1.xx + lua_register(L, "spriteLoadData", apiSpriteLoadData); // Hypseus + lua_register(L, "spriteLoadFrames", apiSpriteLoadFrames); // Hypseus lua_register(L, "spriteLoop", apiSpriteLoop); // 2.10 Handle first since 3.00. lua_register(L, "spritePause", apiSpritePause); // 2.10 lua_register(L, "spritePlay", apiSpritePlay); // 2.10 lua_register(L, "spriteQuality", apiSpriteQuality); // 2.10 Handle first since 3.00. + lua_register(L, "spriteResetColorKey", apiSpriteResetColorKey); // Hypseus lua_register(L, "spriteRotate", apiSpriteRotate); // 2.10 Handle first since 3.00. lua_register(L, "spriteRotateAndScale", apiSpriteRotateAndScale); // 2.10 Handle first since 3.00. + lua_register(L, "spriteRotateFrame", apiSpriteRotateFrame); // Hypseus Handle first since 3.00. lua_register(L, "spriteScale", apiSpriteScale); // 2.10 Handle first since 3.00. lua_register(L, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 3.00. lua_register(L, "spriteUnload", apiSpriteUnload); // 2.00 + lua_register(L, "srtClear", apiSrtClear); // Hypseus + lua_register(L, "srtEnable", apiSrtEnable); // Hypseus + lua_register(L, "srtLoad", apiSrtLoad); // Hypseus + lua_register(L, "srtPosition", apiSrtPosition); // Hypseus + lua_register(L, "terrainGetHeight", apiTerrainGetHeight); // 3.00 lua_register(L, "vehicleAddWheel", apiVehicleAddWheel); // 3.00 lua_register(L, "vehicleDelete", apiVehicleDelete); // 3.00 @@ -4087,9 +5292,20 @@ static void _registerApi(lua_State *L) { lua_register(L, "viewNew", apiViewNew); // 3.00 lua_register(L, "viewSetCamera", apiViewSetCamera); // 3.00 + lua_register(L, "vldpFlash", apiVldpFlash); // Hypseus + lua_register(L, "vldpFocusArea", apiVldpFocusArea); // Hypseus lua_register(L, "vldpGetHeight", apiDiscGetHeight); // 1.xx Same as discGetHeight. lua_register(L, "vldpGetPixel", apiVldpGetPixel); // 1.xx + lua_register(L, "vldpGetRotate", apiVldpGetRotate); // Hypseus + lua_register(L, "vldpGetScale", apiVldpGetScale); // Hypseus lua_register(L, "vldpGetWidth", apiDiscGetWidth); // 1.xx Same as discGetWidth. + lua_register(L, "vldpGetYUVPixel", apiVldpGetYUVPixel); // Hypseus + lua_register(L, "vldpResetFocus", apiVldpResetFocus); // Hypseus + lua_register(L, "vldpSetBlend", apiVldpSetBlend); // Hypseus + lua_register(L, "vldpSetLuma", apiVldpSetLuma); // Hypseus + lua_register(L, "vldpSetMonochrome", apiVldpSetMonochrome); // Hypseus + lua_register(L, "vldpSetRotate", apiVldpSetRotate); // Hypseus + lua_register(L, "vldpSetScale", apiVldpSetScale); // Hypseus lua_register(L, "vldpSetVerbose", apiVldpSetVerbose); // 1.xx } @@ -4111,6 +5327,7 @@ static void _reloadScript(void) { _progTrace("Reloading %s", _global.conf->scriptFile); _global.reloadRequested = false; MIX_StopTag(videoGetMixer(), EFFECT_TAG, 0); + MIX_StopTag(videoGetMixer(), MUSIC_TAG, 0); guiDetachLua(); lua_close(_global.luaContext); _global.luaContext = NULL; @@ -4166,12 +5383,15 @@ static SDL_Surface *_renderText(lua_State *L, const char *method, const char *me // over from before this script, so they are ignored until released. Nothing here calls into Lua, // so it is safe between one state closing and the next opening. static void _resetScriptState(void) { - int32_t finished[SOUND_QUEUE_SIZE]; - int32_t x = 0; + int32_t finished[SOUND_QUEUE_SIZE]; + const char *suffix = NULL; + int32_t x = 0; _global.guiDrawCount = 0; _global.guiShownCount = 0; _global.guiHover = GUI_NO_HANDLE; + _scorePanelReset(); + _subtitleReset(); if (_global.guiTextActive) { SDL_StopTextInput(_global.window); _global.guiTextActive = false; @@ -4180,10 +5400,20 @@ static void _resetScriptState(void) { _effectReset(x); } _soundQueueDrain(finished); - _global.effectsVolume = _defaultEffectsVolume(); - MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume)); - _global.mouseMode = MOUSE_SINGLE; + _global.effectsVolume = _defaultVolume(AUDIO_MAX_VOLUME); + MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); + // Music starts as loud as the sound effects, on its own scale and with its own gain, so + // musicSetVolume and soundSetVolume never move each other. + _global.musicVolume = _defaultVolume(MUSIC_MAX_VOLUME); + MIX_SetTagGain(videoGetMixer(), MUSIC_TAG, _mixerGain(_global.musicVolume, MUSIC_MAX_VOLUME)); + _global.mouseMode = (_global.conf->manyMouse) ? MOUSE_MANY : MOUSE_SINGLE; _global.mouseEnabled = !_global.conf->noMouse && (_global.mouseCount > 0); + // --joymouse and the cursor it drives are the command line's again, as the scale and the + // rotation are, so a reload starts where the user put them. + _global.joyMouseEnabled = _global.conf->joyMouse; + _global.joyMouseX = _global.canvasWidth / 2; + _global.joyMouseY = _global.canvasHeight / 2; + _global.joyMouseClock = 0; _setMouseCaptured(true); if (_global.videoHandle >= 0) { _discPark(); @@ -4193,11 +5423,46 @@ static void _resetScriptState(void) { _global.colorForeground.b = SDL_ALPHA_OPAQUE; _global.colorForeground.a = SDL_ALPHA_OPAQUE; memset(&_global.colorBackground, 0, sizeof(_global.colorBackground)); - _global.fontQuality = FONT_QUALITY_SOLID; - _global.keyboardMode = KEYBOARD_NORMAL; - _global.listenerNode = LISTENER_CAMERA; - _global.pauseEnabled = true; - _global.frozen = false; + _global.fontQuality = FONT_QUALITY_SOLID; + _global.keyboardMode = KEYBOARD_NORMAL; + _global.listenerNode = LISTENER_CAMERA; + _global.pauseEnabled = true; + _global.quitEnabled = true; + _global.frozen = false; + _global.focused = false; + _global.overlayMonochrome = false; + _global.overlayOnTop = false; + _global.overlayLinearScale = _global.conf->linearScale; + _global.overlayOpacity = COLOR_BYTE_MAX; + _overlayApplyScaleMode(); + _overlayApplyOpacity(); + // vldpSetScale and vldpSetRotate are the script's, so the next script starts from the command line again. + _global.videoScale = _global.conf->scaleFactor; + _global.videoShiftX = _global.conf->shiftX; + _global.videoShiftY = _global.conf->shiftY; + _global.videoScaleClock = 0; + if (_global.canvasWidth > 0) { + _setVideoRotate(_global.conf->rotate); + _computeVideoRect(); + } + // The disc picture controls are the script's too, so --monochrome is where a reload starts. + _global.discMonochrome = _global.conf->monochrome; + _global.discBlend = false; + _global.discLumaOn = false; + _global.discLumaLevel = LUMA_LEVEL_NEUTRAL; + _discApplyPicture(); + if (_global.videoHandle >= 0) { + // The suffix goes back to --altaudio's, not to nothing, so a reload starts where the + // command line put it just as the scale and the rotation do. + suffix = (_global.discAudioSuffix != NULL) ? _global.discAudioSuffix : ""; + if (strcmp(suffix, _startAudioSuffix()) != 0) { + if (_discSetAudioSuffix(_startAudioSuffix())) { + _progTrace("Disc audio suffix is \"%s\"", _startAudioSuffix()); + } else { + utilSay("Warning: there is no disc audio named for --altaudio %s; the game's own audio is playing.", _startAudioSuffix()); + } + } + } if (_global.pauseState) { _global.pauseState = false; _updatePauseState(); @@ -4218,6 +5483,47 @@ static void _resetScriptState(void) { } +// Text into the document as text and not as markup: the three characters RML would take for markup +// are escaped, and a line break becomes one. Truncates rather than overrun the caller's buffer. +static void _rmlEscape(char *out, size_t size, const char *text) { + size_t room = 0; + size_t used = 0; + size_t x = 0; + + for (x = 0; (text != NULL) && (text[x] != '\0'); x++) { + const char *piece = NULL; + char one[2]; + + switch (text[x]) { + case '&': + piece = "&"; + break; + case '<': + piece = "<"; + break; + case '>': + piece = ">"; + break; + case '\n': + piece = "
"; + break; + default: + one[0] = text[x]; + one[1] = '\0'; + piece = one; + break; + } + room = strlen(piece); + if ((used + room + 1) > size) { + break; + } + memcpy(out + used, piece, room); + used += room; + } + out[used] = '\0'; +} + + // Loads and runs the game script under the traceback handler. A failure is fatal when the game // starts; on a reload it is reported and the game sits empty until the next one. static void _runScript(bool fatal) { @@ -4281,6 +5587,210 @@ static SDL_Texture *_sceneVideoSource(int32_t player) { } +// One readout's digits: the value right justified in the places that readout has, with leading +// zeros the way an arcade board shows them, the symbol Hypseus puts on every digit for each of its +// negative codes, or nothing at all while the panel is blanked. +static void _scorePanelDigits(char *text, size_t size, int32_t value, int32_t digits, bool blank) { + int32_t limit = 1; + int32_t i = 0; + char symbol = 0; + + text[0] = 0; + if (blank) { + return; + } + if (value < 0) { + switch (value) { + case SCORE_CODE_DASH: + symbol = '-'; + break; + + case SCORE_CODE_H: + symbol = 'H'; + break; + + case SCORE_CODE_A: + symbol = 'A'; + break; + + // SCORE_CODE_OFF and SCORE_CODE_BLANK light no segments at all, and so does any other + // negative number, which Hypseus shows as a zero it never lights either. + default: + return; + } + for (i = 0; (i < digits) && ((size_t)i < size - 1); i++) { + text[i] = symbol; + } + text[i] = 0; + return; + } + for (i = 0; i < digits; i++) { + limit *= 10; + } + snprintf(text, size, "%0*d", digits, value % limit); +} + + +// scoreBezelEnable: builds the panel from the shipped document the first time a game asks for it, +// and puts it away when a game asks for none. A machine with no GPU has no GUIs at all, so the +// panel stays off and scoreBezelGetState answers false, which sends the game down the branch it +// already has for a scoreboard it did not get. +static bool _scorePanelEnable(bool enabled) { + char markup[GUI_VALUE_MAX]; + + _global.scoreEnabled = false; + if (!enabled) { + _global.refreshDisplay = true; + return false; + } + if (!guiValid(_global.scoreGui)) { + _global.scoreGui = guiNew(SCORE_PANEL_WIDTH, SCORE_PANEL_HEIGHT); + if (_global.scoreGui == GUI_NO_HANDLE) { + utilSay("Warning: No score panel: %s", guiLastError()); + return false; + } + _global.scoreDocument = guiLoad(_global.scoreGui, SCORE_PANEL_RML); + if (_global.scoreDocument == GUI_NO_HANDLE) { + utilSay("Warning: Unable to load %s: %s", SCORE_PANEL_RML, guiLastError()); + guiDelete(_global.scoreGui); + _global.scoreGui = GUI_NO_HANDLE; + return false; + } + // The document ships showing the second player, so its markup is kept here and put back + // exactly as it was written when a game asks for a second score. The panel's whole + // appearance stays in the document and its style sheet. + if (guiGetValue(_global.scoreGui, _global.scoreDocument, SCORE_PANEL_PLAYER2, markup, sizeof(markup))) { + free(_global.scoreTwinMarkup); + _global.scoreTwinMarkup = strdup(markup); + } + _global.scoreTwinShown = true; + } + _global.scoreEnabled = true; + _scorePanelUpdate(); + + return true; +} + + +// The readout a player number addresses, or -1 for one the panel does not have. Hypseus ignores +// every number but 1 and 2, and ignores 2 as well until a game asks for the second score. +static int32_t _scorePanelPlayer(int32_t player) { + if ((player == 1) || ((player == 2) && _global.scoreTwin)) { + return player - 1; + } + + return -1; +} + + +// Queues the score panel for this frame as a screen space GUI, so it draws over the whole window +// and can sit outside the picture. +static void _scorePanelQueue(void) { + GuiDrawT *draw = NULL; + + if (!_global.scoreEnabled || !guiValid(_global.scoreGui) || (_global.guiDrawCount >= GUI_DRAW_MAX)) { + return; + } + draw = &_global.guiDraws[_global.guiDrawCount]; + draw->gui = _global.scoreGui; + draw->whole = false; + draw->screen = true; + _scorePanelRect(&draw->dest); + _global.guiDrawCount++; +} + + +// Where the score panel goes, in the overlay coordinates a screen space GUI is drawn in. It takes +// the deeper of the bands the picture leaves above and below it -- the room a bezel's cutout +// usually leaves -- centred on the picture, and lies along the bottom of the picture instead when +// neither band is deep enough to hold it. +static void _scorePanelRect(SDL_FRect *dest) { + float above = _global.videoRect.y; + float below = (float)_global.canvasHeight - (_global.videoRect.y + _global.videoRect.h); + float least = (float)_global.canvasHeight / (float)SCORE_PANEL_BAND_MIN; + float most = (float)_global.canvasHeight / (float)SCORE_PANEL_BAND_MAX; + float band = 0.0f; + float top = 0.0f; + float width = 0.0f; + float height = 0.0f; + + if (below >= above) { + band = below; + top = _global.videoRect.y + _global.videoRect.h; + } else { + band = above; + top = 0.0f; + } + if (band < least) { + band = SDL_min(most, _global.videoRect.h); + top = _global.videoRect.y + _global.videoRect.h - band; + } + height = SDL_min(band, most); + width = height * (float)SCORE_PANEL_WIDTH / (float)SCORE_PANEL_HEIGHT; + if (width > (float)_global.canvasWidth) { + width = (float)_global.canvasWidth; + height = width * (float)SCORE_PANEL_HEIGHT / (float)SCORE_PANEL_WIDTH; + } + dest->x = _global.videoRect.x + (_global.videoRect.w - width) / 2.0f; + dest->y = top + (band - height) / 2.0f; + dest->x = SDL_clamp(dest->x, 0.0f, (float)_global.canvasWidth - width); + dest->w = width; + dest->h = height; + + // Into overlay coordinates, which is what a draw record carries. + dest->x *= (float)_global.overlay->w / (float)_global.canvasWidth; + dest->w *= (float)_global.overlay->w / (float)_global.canvasWidth; + dest->y *= (float)_global.overlay->h / (float)_global.canvasHeight; + dest->h *= (float)_global.overlay->h / (float)_global.canvasHeight; +} + + +// A reload takes every GUI with it, so the panel goes back to off with nothing on it and the next +// script asks for it again, exactly as it would on a fresh start. +static void _scorePanelReset(void) { + free(_global.scoreTwinMarkup); + _global.scoreTwinMarkup = NULL; + _global.scoreGui = GUI_NO_HANDLE; + _global.scoreDocument = GUI_NO_HANDLE; + _global.scoreEnabled = false; + _global.scoreTwin = false; + _global.scoreTwinShown = false; + _global.scoreBlank = false; + _global.scoreCredits = 0; + memset(_global.scoreValue, 0, sizeof(_global.scoreValue)); + memset(_global.scoreLives, 0, sizeof(_global.scoreLives)); +} + + +// The panel's numbers into its document, each padded to the digits its readout has so the dim +// "every segment lit" digits behind it line up. The second player's cell is put in or taken out +// only when that changes, since it costs a reparse of its markup. +static void _scorePanelUpdate(void) { + char text[SCORE_PANEL_TEXT_MAX]; + + if (!guiValid(_global.scoreGui)) { + return; + } + if (_global.scoreTwin != _global.scoreTwinShown) { + guiSetValue(_global.scoreGui, _global.scoreDocument, SCORE_PANEL_PLAYER2, (_global.scoreTwin && (_global.scoreTwinMarkup != NULL)) ? _global.scoreTwinMarkup : ""); + _global.scoreTwinShown = _global.scoreTwin; + } + _scorePanelDigits(text, sizeof(text), _global.scoreCredits, SCORE_PANEL_CREDIT_DIGITS, _global.scoreBlank); + guiSetValue(_global.scoreGui, _global.scoreDocument, "credits", text); + _scorePanelDigits(text, sizeof(text), _global.scoreValue[0], SCORE_PANEL_SCORE_DIGITS, _global.scoreBlank); + guiSetValue(_global.scoreGui, _global.scoreDocument, "score1", text); + _scorePanelDigits(text, sizeof(text), _global.scoreLives[0], SCORE_PANEL_LIVES_DIGITS, _global.scoreBlank); + guiSetValue(_global.scoreGui, _global.scoreDocument, "lives1", text); + if (_global.scoreTwin) { + _scorePanelDigits(text, sizeof(text), _global.scoreValue[1], SCORE_PANEL_SCORE_DIGITS, _global.scoreBlank); + guiSetValue(_global.scoreGui, _global.scoreDocument, "score2", text); + _scorePanelDigits(text, sizeof(text), _global.scoreLives[1], SCORE_PANEL_LIVES_DIGITS, _global.scoreBlank); + guiSetValue(_global.scoreGui, _global.scoreDocument, "lives2", text); + } + _global.refreshDisplay = true; +} + + // scriptExecute and scriptPush: the games.dat style table at argument 1 as a config, with the data // directory the launched game will write to. Caller destroys it. static ConfigT *_scriptConfFromTable(lua_State *L, const char *method) { @@ -4308,6 +5818,15 @@ static void _selectDefaultAudioTrack(int32_t handle) { } +// Silence, and back again, without touching a single configured volume: the mixer's own gain +// carries every disc, effect and video track. --nosound holds it down for the whole run and +// --startsilent until the first input. +static void _setAudioMuted(bool muted) { + _global.audioMuted = muted; + MIX_SetMixerGain(videoGetMixer(), muted ? 0.0f : 1.0f); +} + + static void _setMouseCaptured(bool captured) { _global.mouseGrabbed = captured; SDL_SetWindowMouseGrab(_global.window, captured); @@ -4333,6 +5852,117 @@ static void _setPause(bool paused, bool fromKey) { } +// Records one settings file key, replacing whatever an earlier file in the search order set. +static void _settingsAdd(SettingT **list, int32_t *count, const char *key, const char *value, const char *source) { + SettingT *grown = NULL; + int32_t x = 0; + + for (x = 0; x < *count; x++) { + if (strcmp((*list)[x].key, key) == 0) { + free((*list)[x].value); + free((*list)[x].source); + (*list)[x].value = strdup(value); + (*list)[x].source = strdup(source); + return; + } + } + grown = (SettingT *)realloc(*list, (size_t)(*count + 1) * sizeof(SettingT)); + if (grown == NULL) { + utilDie("Out of memory reading %s.", source); + } + *list = grown; + grown[*count].key = strdup(key); + grown[*count].value = strdup(value); + grown[*count].source = strdup(source); + (*count)++; +} + + +// Settings come back in a fixed order whatever order Lua walked the files in, so a trace header +// reads the same twice running. +static int32_t _settingsCompare(const void *a, const void *b) { + return strcmp(((const SettingT *)a)->key, ((const SettingT *)b)->key); +} + + +// Runs one settings file and collects the globals it left. It runs in a state with no libraries, +// because a settings file is data and not a program, and every value is handed on as a string: +// what the option would have been given on the command line. +static void _settingsReadFile(const char *path, SettingT **list, int32_t *count) { + lua_State *L = NULL; + char *source = NULL; + const char *key = NULL; + const char *value = NULL; + size_t bytes = 0; + int32_t status = 0; + + if (!vfsExists(path)) { + return; + } + source = vfsRead(path, &bytes); + if (source == NULL) { + utilDie("Unable to read the settings file %s.", path); + } + L = luaL_newstate(); + if (L == NULL) { + utilDie("Out of memory reading the settings file %s.", path); + } + status = luaL_loadbuffer(L, source, bytes, path); + free(source); + if (status || lua_pcall(L, 0, 0, 0)) { + utilDie("%s", lua_tostring(L, -1)); + } + lua_pushglobaltable(L); + lua_pushnil(L); + while (lua_next(L, -2)) { + // Keys must be strings; converting another kind in place would confuse lua_next. A value + // may be converted, since lua_next only needs the key back. + if (lua_type(L, -2) == LUA_TSTRING) { + key = lua_tostring(L, -2); + value = (lua_type(L, -1) == LUA_TBOOLEAN) ? (lua_toboolean(L, -1) ? "true" : "false") : lua_tostring(L, -1); + if (value == NULL) { + utilDie("%s: %s must be a number, a string, true or false.", path, key); + } + _settingsAdd(list, count, key, value, path); + } + lua_pop(L, 1); + } + lua_pop(L, 1); + lua_close(L); +} + + +// Turns the whole presentation clockwise by 0, 90, 180 or 270 degrees, and returns whether it took. +// The frame is drawn unrotated into a render target and blitted turned, so the overlay, the GUIs, +// the 3D scene and the particles all turn with the video; a quarter turn swaps the logical size so +// a portrait window is filled. Rotation costs one screen sized texture and one extra blit a frame, +// and none of it exists while the rotation is zero. +static bool _setVideoRotate(int32_t degrees) { + int32_t width = 0; + int32_t height = 0; + + if ((degrees < ROTATE_MIN) || (degrees > ROTATE_MAX) || ((degrees % ROTATE_STEP) != 0)) { + return false; + } + _global.videoRotate = degrees; + if (degrees == ROTATE_MIN) { + SDL_DestroyTexture(_global.rotateTexture); + _global.rotateTexture = NULL; + } else if (_global.rotateTexture == NULL) { + _global.rotateTexture = SDL_CreateTexture(_global.renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_TARGET, _global.canvasWidth, _global.canvasHeight); + if (_global.rotateTexture == NULL) { + utilDie("%s", SDL_GetError()); + } + SDL_SetTextureScaleMode(_global.rotateTexture, SDL_SCALEMODE_LINEAR); + } + _presentSize(&width, &height); + SDL_SetRenderLogicalPresentation(_global.renderer, width, height, _global.conf->stretchVideo ? SDL_LOGICAL_PRESENTATION_STRETCH : SDL_LOGICAL_PRESENTATION_LETTERBOX); + _global.refreshDisplay = true; + + return true; +} + + static const char *_skipDotSlash(const char *path) { while ((path[0] == '.') && ((path[1] == '/') || (path[1] == '\\'))) { path += 2; @@ -4349,6 +5979,27 @@ static void _soundDestroy(SoundT *sound) { } +// The sound both soundLoad and soundLoadData end up making, from a stream either of them opened. +// The stream is closed whatever happens. +static SoundT *_soundLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io) { + SoundT *sound = (SoundT *)calloc(1, sizeof(SoundT)); + + if (!sound) { + SDL_CloseIO(io); + _luaDie(L, method, "Unable to allocate new sound."); + } + sound->audio = MIX_LoadAudio_IO(videoGetMixer(), io, true, true); + if (!sound->audio) { + free(sound); + _luaDie(L, method, "%s", SDL_GetError()); + } + sound->id = _global.nextSoundId++; + HASH_ADD_INT(_global.soundList, id, sound); + + return sound; +} + + // Takes the channels the mixer thread reported finished since the last call, under its lock. static int32_t _soundQueueDrain(int32_t *finished) { int32_t count = 0; @@ -4363,9 +6014,24 @@ static int32_t _soundQueueDrain(int32_t *finished) { } +// Prepares an animation's frames for drawing (unpacked and colour keyed), sums its loop time, and +// parks the sprite on the first frame. +static void _spriteAdoptAnimation(SpriteT *sprite) { + int32_t x = 0; + + for (x = 0; x < sprite->animation->count; x++) { + _surfaceUnpack(&sprite->animation->frames[x]); + SDL_SetSurfaceColorKey(sprite->animation->frames[x], true, COLOR_KEY_VALUE); + sprite->loopMs += (uint64_t)SDL_max(sprite->animation->delays[x], ANIMATION_MIN_DELAY_MS); + } + sprite->originalSurface = sprite->animation->frames[0]; +} + + static void _spriteDestroy(SpriteT *sprite) { HASH_DEL(_global.spriteList, sprite); _spriteFreeSurface(sprite); + SDL_DestroySurface(sprite->rotatedFrame); if (sprite->animation != NULL) { // Frames belong to the animation. IMG_FreeAnimation(sprite->animation); @@ -4386,6 +6052,55 @@ static void _spriteFreeSurface(SpriteT *sprite) { } +// The sprite both spriteLoad and spriteLoadData end up making, from a stream either of them opened. +// An animated GIF or WEBP becomes an animation; anything else becomes a still. The stream is closed +// whatever happens. +static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io) { + SpriteT *sprite = _spriteNew(L, method); + + sprite->animation = IMG_LoadAnimation_IO(io, false); + if ((sprite->animation != NULL) && (sprite->animation->count < 2)) { + // Only one frame: take it over as the still image (no copy, so packed 1-bit PNGs survive). + sprite->originalSurface = sprite->animation->frames[0]; + sprite->animation->frames[0] = NULL; + _surfaceUnpack(&sprite->originalSurface); + IMG_FreeAnimation(sprite->animation); + sprite->animation = NULL; + } else { + if (sprite->animation != NULL) { + _spriteAdoptAnimation(sprite); + } else { + SDL_SeekIO(io, 0, SDL_IO_SEEK_SET); + sprite->originalSurface = IMG_Load_IO(io, false); + _surfaceUnpack(&sprite->originalSurface); + } + } + SDL_CloseIO(io); + if (!sprite->originalSurface) { + _luaDie(L, method, "%s", SDL_GetError()); + } + if (sprite->animation == NULL) { + // An animation's frames were keyed above, this one included. + SDL_SetSurfaceColorKey(sprite->originalSurface, true, COLOR_KEY_VALUE); + } + _spriteRegister(sprite); + + return sprite; +} + + +// An empty sprite record for a loader to fill. +static SpriteT *_spriteNew(lua_State *L, const char *method) { + SpriteT *sprite = (SpriteT *)calloc(1, sizeof(SpriteT)); + + if (!sprite) { + _luaDie(L, method, "Unable to allocate new sprite."); + } + + return sprite; +} + + // Rebuilds the drawn surface after a frame, angle, scale, or quality change. static void _spriteRebuildSurface(SpriteT *sprite) { _spriteFreeSurface(sprite); @@ -4402,23 +6117,59 @@ static void _spriteRebuildSurface(SpriteT *sprite) { } +// Finishes a loaded sprite: drawn untransformed from its original image, whose size is the frame +// size, under a new handle. +static void _spriteRegister(SpriteT *sprite) { + sprite->surface = sprite->originalSurface; + sprite->scaleX = 1.0; + sprite->scaleY = 1.0; + sprite->frameWidth = sprite->originalSurface->w; + sprite->frameHeight = sprite->originalSurface->h; + sprite->id = _global.nextSpriteId++; + HASH_ADD_INT(_global.spriteList, id, sprite); +} + + +// The disc audio suffix a run and a reload start from: --altaudio's, or the game's own audio. +static const char *_startAudioSuffix(void) { + return (_global.conf->audioSuffix != NULL) ? _global.conf->audioSuffix : ""; +} + + static void _startControllers(void) { - int32_t x = 0; + int32_t order[MAX_CONTROLLERS]; + int32_t slot = 0; int32_t count = 0; - SDL_JoystickID *ids = SDL_GetGamepads(&count); + SDL_JoystickID *ids = NULL; _stopControllers(); - // Clamp to the first few controllers found. - if (count > MAX_CONTROLLERS) { - count = MAX_CONTROLLERS; + // --nogamepad ignores every pad, the counterpart of --nomouse: none is opened and no gamepad + // event is delivered, so a stuck arcade encoder cannot press anything. + if (_global.conf->noGamepad) { + SDL_SetGamepadEventsEnabled(false); + return; } - for (x = 0; (ids != NULL) && (x < count); x++) { - _global.controllers[x] = SDL_OpenGamepad(ids[x]); - if (_global.controllers[x]) { - _progTrace("Found %d - %s", x, SDL_GetGamepadName(_global.controllers[x])); + + // A device SDL does not recognise is given a mapping of its own first, so that it is a gamepad + // by the time the pads are enumerated and nothing downstream has to know it was ever anything else. + if (_global.conf->mapJoysticks) { + _mapUnknownJoysticks(); + } + ids = SDL_GetGamepads(&count); + + // Which physical pad lands in which slot. Without --gamepad_reorder that is SDL's own order, + // and the first few pads found fill the slots as they always have. + _gamepadOrder(order, count); + for (slot = 0; (ids != NULL) && (slot < MAX_CONTROLLERS); slot++) { + if ((order[slot] < 0) || (order[slot] >= count)) { + continue; + } + _global.controllers[slot] = SDL_OpenGamepad(ids[order[slot]]); + if (_global.controllers[slot]) { + _progTrace("Found %d - %s", slot, SDL_GetGamepadName(_global.controllers[slot])); } else { - _progTrace("Controller %d not opened", x); + _progTrace("Controller %d not opened", slot); } } SDL_free(ids); @@ -4512,6 +6263,290 @@ static void _subsystemsQuit(void) { } +// Forgets the cues srtLoad parsed. +static void _subtitleClearCues(void) { + int32_t x = 0; + + for (x = 0; x < _global.subtitleCueCount; x++) { + free(_global.subtitleCues[x].text); + } + free(_global.subtitleCues); + _global.subtitleCues = NULL; + _global.subtitleCueCount = 0; + _global.subtitleCue = SUBTITLE_NONE; +} + + +// The cue covering the disc's current frame, or SUBTITLE_NONE. The cues are in file order, so a +// binary search finds the right one wherever the disc has been sent, which is what makes a subtitle +// survive a seek. +static int32_t _subtitleCueNow(void) { + int64_t frame = 0; + int32_t found = SUBTITLE_NONE; + int32_t low = 0; + int32_t high = _global.subtitleCueCount - 1; + int32_t mid = 0; + + if ((_global.subtitleCueCount == 0) || (_global.videoHandle < 0)) { + return SUBTITLE_NONE; + } + frame = _discGetFrame(); + while (low <= high) { + mid = (low + high) / 2; + if (_global.subtitleCues[mid].startFrame <= frame) { + found = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + if ((found != SUBTITLE_NONE) && (frame > _global.subtitleCues[found].endFrame)) { + found = SUBTITLE_NONE; + } + + return found; +} + + +// Builds the subtitle document the first time a cue or a banner has something to say. A machine +// with no GPU has no GUIs at all, so the text is simply not shown and everything else carries on. +static bool _subtitleEnsureGui(void) { + if (guiValid(_global.subtitleGui)) { + return true; + } + _global.subtitleGui = guiNew(SUBTITLE_WIDTH, SUBTITLE_HEIGHT); + if (_global.subtitleGui == GUI_NO_HANDLE) { + utilSay("Warning: No subtitles: %s", guiLastError()); + return false; + } + _global.subtitleDocument = guiLoad(_global.subtitleGui, SUBTITLE_RML); + if (_global.subtitleDocument == GUI_NO_HANDLE) { + utilSay("Warning: Unable to load %s: %s", SUBTITLE_RML, guiLastError()); + guiDelete(_global.subtitleGui); + _global.subtitleGui = GUI_NO_HANDLE; + return false; + } + + return true; +} + + +// The next line of a writable block of text, NUL terminated in place and trimmed of the spaces, +// tabs and carriage returns a Windows written file leaves at either end. Unlike utilReadLine it +// hands back the blank lines, because a blank line is what separates one SubRip cue from the next. +static char *_subtitleNextLine(char **cursor) { + char *line = *cursor; + char *tail = NULL; + + if ((line == NULL) || (*line == '\0')) { + *cursor = NULL; + return NULL; + } + tail = line; + while ((*tail != '\0') && (*tail != '\n')) { + tail++; + } + *cursor = (*tail == '\0') ? tail : (tail + 1); + *tail = '\0'; + while ((*line == ' ') || (*line == '\t')) { + line++; + } + while ((tail > line) && ((tail[-1] == '\r') || (tail[-1] == ' ') || (tail[-1] == '\t'))) { + tail--; + *tail = '\0'; + } + + return line; +} + + +// Parses SubRip: a cue number, a line of "HH:MM:SS,mmm --> HH:MM:SS,mmm", one or more lines of +// text, and a blank line. Timestamps become disc frames at the disc's own frame rate, so a cue is +// found again wherever the disc is sent. Anything that does not parse is skipped, as Hypseus skips +// it, and a file that yields no cue at all is a failure. Works on a writable copy of the text. +static bool _subtitleParse(const char *text) { + SubtitleCueT *cues = NULL; + char *copy = strdup(text); + char *cursor = NULL; + char *line = NULL; + char *arrow = NULL; + char body[SUBTITLE_MARKUP_MAX]; + size_t used = 0; + int32_t count = 0; + int32_t room = 0; + int64_t start = 0; + int64_t end = 0; + + if (copy == NULL) { + utilDie("Unable to copy the subtitle file."); + } + cursor = copy; + while ((line = _subtitleNextLine(&cursor)) != NULL) { + if (*line == '\0') { + continue; + } + // That was the cue number, which nothing needs. The times come next. + line = _subtitleNextLine(&cursor); + if (line == NULL) { + break; + } + arrow = strstr(line, SUBTITLE_ARROW); + if (arrow == NULL) { + continue; + } + *arrow = '\0'; + if (!_subtitleParseTime(line, &start) || !_subtitleParseTime(arrow + strlen(SUBTITLE_ARROW), &end)) { + continue; + } + // Every line up to the next blank one is this cue's text. + body[0] = '\0'; + used = 0; + while ((line = _subtitleNextLine(&cursor)) != NULL) { + if (*line == '\0') { + break; + } + used += (size_t)snprintf(body + used, sizeof(body) - used, "%s%s", (used > 0) ? "\n" : "", line); + if (used >= sizeof(body)) { + used = sizeof(body) - 1; + } + } + if (body[0] == '\0') { + continue; + } + if (count >= SUBTITLE_CUES_MAX) { + break; + } + if (count == room) { + room = (room == 0) ? SUBTITLE_CUES_FIRST : (room * 2); + cues = (SubtitleCueT *)realloc(cues, sizeof(SubtitleCueT) * (size_t)room); + if (cues == NULL) { + utilDie("Unable to allocate the subtitle cues."); + } + } + cues[count].startFrame = start; + cues[count].endFrame = end; + cues[count].text = strdup(body); + count++; + } + free(copy); + _subtitleClearCues(); + if (count == 0) { + free(cues); + return false; + } + _global.subtitleCues = cues; + _global.subtitleCueCount = count; + + return true; +} + + +// One SubRip timestamp, "HH:MM:SS,mmm", into a disc frame at the disc's frame rate. +static bool _subtitleParseTime(const char *text, int64_t *frame) { + double fps = 0.0; + double seconds = 0.0; + int32_t hours = 0; + int32_t minutes = 0; + int32_t second = 0; + int32_t millis = 0; + + if (_global.videoHandle < 0) { + return false; + } + if (sscanf(text, "%d:%d:%d,%d", &hours, &minutes, &second, &millis) != SUBTITLE_TIME_FIELDS) { + return false; + } + fps = videoGetFps(_global.videoHandle); + seconds = (double)hours * SECONDS_PER_HOUR + (double)minutes * SECONDS_PER_MINUTE + (double)second + (double)millis / MS_PER_SECOND_NUMBER; + *frame = (int64_t)(seconds * fps + 0.5); + + return true; +} + + +// Queues the subtitle document over the picture for this frame, when it has anything on it. +static void _subtitleQueue(void) { + GuiDrawT *draw = NULL; + + if ((_global.subtitleCue == SUBTITLE_NONE) && (_global.bannerFrames == 0)) { + return; + } + if (!guiValid(_global.subtitleGui) || (_global.guiDrawCount >= GUI_DRAW_MAX)) { + return; + } + draw = &_global.guiDraws[_global.guiDrawCount]; + draw->gui = _global.subtitleGui; + draw->whole = true; + draw->screen = false; + _global.guiDrawCount++; +} + + +// A reload takes every GUI with it, so the subtitles go back to none loaded and none shown and the +// next script loads its own, exactly as it would on a fresh start. +static void _subtitleReset(void) { + _subtitleClearCues(); + free(_global.bannerText); + _global.bannerText = NULL; + _global.bannerFrames = 0; + _global.bannerPosition = BANNER_POSITION_DEFAULT; + _global.subtitleGui = GUI_NO_HANDLE; + _global.subtitleDocument = GUI_NO_HANDLE; + _global.subtitleEnabled = false; + _global.subtitlePosition = SUBTITLE_POSITION_DEFAULT; +} + + +// Puts one line of text into the document at a height given as a per cent down the picture, or +// takes whatever was there away when the text is NULL. The text is escaped, since the document +// element takes markup, and the height rides in as an inline style so the look stays in the RCSS. +static void _subtitleShow(const char *text, int32_t position) { + char markup[SUBTITLE_MARKUP_MAX + SUBTITLE_WRAPPER_MAX]; + char escaped[SUBTITLE_MARKUP_MAX]; + + if ((text != NULL) && !_subtitleEnsureGui()) { + return; + } + if (!guiValid(_global.subtitleGui)) { + return; + } + if (text == NULL) { + guiSetValue(_global.subtitleGui, _global.subtitleDocument, SUBTITLE_SLOT, ""); + } else { + _rmlEscape(escaped, sizeof(escaped), text); + snprintf(markup, sizeof(markup), "
%s
", position, escaped); + guiSetValue(_global.subtitleGui, _global.subtitleDocument, SUBTITLE_SLOT, markup); + } + _global.refreshDisplay = true; +} + + +// Once a drawn frame: a banner counts down and then makes way, and otherwise the cue covering the +// disc's current frame goes up. Nothing is written into the document unless the cue changed. +static void _subtitleUpdate(void) { + int32_t found = SUBTITLE_NONE; + + if (_global.bannerFrames > 0) { + _global.bannerFrames--; + _global.refreshDisplay = true; + if (_global.bannerFrames == 0) { + free(_global.bannerText); + _global.bannerText = NULL; + _global.subtitleCue = SUBTITLE_NONE; + _subtitleShow(NULL, 0); + } + return; + } + if (_global.subtitleEnabled) { + found = _subtitleCueNow(); + } + if (found != _global.subtitleCue) { + _global.subtitleCue = found; + _subtitleShow((found == SUBTITLE_NONE) ? NULL : _global.subtitleCues[found].text, _global.subtitlePosition); + } +} + + // Keys and buttons that are already down when a script starts, or when the window gains focus, are // not presses meant for this script: SDL reports them as fresh key downs, and the button that // confirmed "exit" in a game would otherwise relaunch it from the menu. They stay ignored until released. @@ -4635,10 +6670,34 @@ static void _takeScreenshot(void) { } +// Alt-Enter. Only the window changes: the renderer presents a logical size in video coordinates +// and scales it itself, _computeVideoRect() works in those same coordinates, the rotation target is +// the canvas's size and not the window's, and the GUIs are drawn into rectangles measured in the +// logical rectangle, so none of them has to be rebuilt -- the frame is only redrawn. A window that +// started in exclusive full screen keeps the display mode it was given; one that started windowed +// gets a borderless desktop window, which is what a player asking for full screen means. +static void _toggleFullScreen(void) { + bool wanted = !_global.fullScreen; + + if (!SDL_SetWindowFullscreen(_global.window, wanted)) { + utilSay("Warning: full screen could not be changed: %s", SDL_GetError()); + return; + } + SDL_SyncWindow(_global.window); + // What Singe last asked for, not what SDL_GetWindowFlags reports: a desktop with no window + // manager never confirms the change, and reading the flag back would leave the key stuck on. + _global.fullScreen = wanted; + _global.refreshDisplay = true; + _progTrace("Full screen is now %s", wanted ? "on" : "off"); +} + + // Everything a script loads: fonts, sounds, sprites and videos, whatever it forgot to unload. static void _unloadScriptResources(void) { FontT *font; FontT *fontTemp; + MusicT *music; + MusicT *musicTemp; SoundT *sound; SoundT *soundTemp; SpriteT *sprite; @@ -4650,6 +6709,10 @@ static void _unloadScriptResources(void) { _progTrace("Unloading font handle %d", font->id); _fontDestroy(font); } + HASH_ITER(hh, _global.musicList, music, musicTemp) { + _progTrace("Unloading music handle %d", music->id); + _musicDestroy(music); + } HASH_ITER(hh, _global.soundList, sound, soundTemp) { _progTrace("Unloading sound handle %d", sound->id); _soundDestroy(sound); @@ -4665,6 +6728,62 @@ static void _unloadScriptResources(void) { } +// Turns a movement in presentation coordinates back into the video's own axes, the inverse of the +// clockwise rotation the finished frame is shown with. +static void _unrotateDelta(float dx, float dy, float *vx, float *vy) { + switch (_global.videoRotate) { + case 90: + *vx = dy; + *vy = -dx; + break; + + case 180: + *vx = -dx; + *vy = -dy; + break; + + case 270: + *vx = -dy; + *vy = dx; + break; + + default: + *vx = dx; + *vy = dy; + break; + } +} + + +// The same for a position: a quarter turn also swaps which side of the picture the axis runs from. +static void _unrotatePoint(float px, float py, float *vx, float *vy) { + float width = (float)_global.canvasWidth; + float height = (float)_global.canvasHeight; + + switch (_global.videoRotate) { + case 90: + *vx = py; + *vy = height - px; + break; + + case 180: + *vx = width - px; + *vy = height - py; + break; + + case 270: + *vx = width - py; + *vy = px; + break; + + default: + *vx = px; + *vy = py; + break; + } +} + + static void _updatePauseState(void) { if (_global.pauseState) { // Pause laserdisc @@ -4674,6 +6793,7 @@ static void _updatePauseState(void) { } _pauseAllVideos(true); MIX_PauseTag(videoGetMixer(), EFFECT_TAG); + MIX_PauseTag(videoGetMixer(), MUSIC_TAG); } else { // Resume laserdisc if ((_global.videoHandle >= 0) && !_global.discStopped && _global.wasPlayingBeforePause) { @@ -4682,6 +6802,7 @@ static void _updatePauseState(void) { } _pauseAllVideos(false); MIX_ResumeTag(videoGetMixer(), EFFECT_TAG); + MIX_ResumeTag(videoGetMixer(), MUSIC_TAG); } } @@ -4699,7 +6820,7 @@ static void _updateSounds(void) { Vec3T relative; MIX_Point3D point; EffectT *effect = NULL; - float baseGain = _mixerGain(_global.effectsVolume); + float baseGain = _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME); float distance = 0.0f; float gain = 0.0f; bool useNode = false; @@ -5225,6 +7346,47 @@ static int32_t apiColorForeground(lua_State *L) { } +// controllerDoRumble([controller,] strength, duration) Hypseus extension. Strength and duration are steps 1 to 4; an empty slot does nothing. +static int32_t apiControllerDoRumble(lua_State *L) { + int32_t n = lua_gettop(L); + int32_t c = 0; + int32_t strength = 0; + int32_t duration = 0; + uint16_t level = 0; + uint32_t ms = 0; + bool rumbled = false; + + _argCheck(L, "controllerDoRumble", 2, 3); + if (n == 3) { + c = _argInteger(L, "controllerDoRumble", 1); + } + strength = _argInteger(L, "controllerDoRumble", n - 1); + duration = _argInteger(L, "controllerDoRumble", n); + if ((c < 0) || (c >= MAX_CONTROLLERS)) { + _luaDie(L, "controllerDoRumble", "Invalid controller index: %d", c); + } + if ((strength < RUMBLE_LEVEL_MIN) || (strength > RUMBLE_LEVEL_MAX)) { + _luaDie(L, "controllerDoRumble", "Invalid rumble strength: %d", strength); + } + if ((duration < RUMBLE_LEVEL_MIN) || (duration > RUMBLE_LEVEL_MAX)) { + _luaDie(L, "controllerDoRumble", "Invalid rumble duration: %d", duration); + } + // --haptic is the strongest step the user will have: 0 stops rumble altogether and 1 to 3 + // cap what a script may ask for, which is what Hypseus's own strength preset does. + if (strength > _global.conf->haptic) { + strength = _global.conf->haptic; + } + level = (uint16_t)(((uint32_t)SDL_MAX_UINT16 * (uint32_t)strength) / RUMBLE_LEVEL_MAX); + ms = (uint32_t)RUMBLE_BASE_MS << (duration - RUMBLE_LEVEL_MIN); + if ((strength > 0) && (_global.controllers[c] != NULL)) { + rumbled = SDL_RumbleGamepad(_global.controllers[c], level, level, ms); + } + _luaTrace(L, "controllerDoRumble", "%d %d %d %d", c, strength, duration, rumbled); + + return 0; +} + + // value = controllerGetAxis(controller, axis) static int32_t apiControllerGetAxis(lua_State *L) { int32_t c = 0; @@ -5276,6 +7438,67 @@ static int32_t apiControllerGetButton(lua_State *L) { } +// name = controllerGetName(controller) The device in the slot, or nil when the slot is empty. A +// device SDL did not recognise reports its own name through the mapping Singe wrote for it. +static int32_t apiControllerGetName(lua_State *L) { + int32_t c = 0; + const char *name = NULL; + + _argCheck(L, "controllerGetName", 1, 1); + c = _argInteger(L, "controllerGetName", 1); + if ((c < 0) || (c >= MAX_CONTROLLERS)) { + _luaDie(L, "controllerGetName", "Invalid controller index: %d", c); + } + if (_global.controllers[c] != NULL) { + name = SDL_GetGamepadName(_global.controllers[c]); + } + _luaTrace(L, "controllerGetName", "%d %s", c, (name != NULL) ? name : "nil"); + if (name == NULL) { + lua_pushnil(L); + } else { + lua_pushstring(L, name); + } + + return 1; +} + + +// count = controllerHowMany() Hypseus extension. Gamepads open in the four slots. +static int32_t apiControllerHowMany(lua_State *L) { + int32_t count = 0; + int32_t x = 0; + + _argCheck(L, "controllerHowMany", 0, 0); + for (x = 0; x < MAX_CONTROLLERS; x++) { + if (_global.controllers[x] != NULL) { + count++; + } + } + _luaTrace(L, "controllerHowMany", "%d", count); + lua_pushinteger(L, count); + + return 1; +} + + +// valid = controllerIsValid(controller) Hypseus extension. Whether a controller is open in the slot. +static int32_t apiControllerIsValid(lua_State *L) { + int32_t c = 0; + bool v = false; + + _argCheck(L, "controllerIsValid", 1, 1); + c = _argInteger(L, "controllerIsValid", 1); + if ((c < 0) || (c >= MAX_CONTROLLERS)) { + _luaDie(L, "controllerIsValid", "Invalid controller index: %d", c); + } + v = (_global.controllers[c] != NULL); + _luaTrace(L, "controllerIsValid", "%d %d", c, v); + lua_pushboolean(L, v); + + return 1; +} + + // debugPrint(message) static int32_t apiDebugPrint(lua_State *L) { const char *message = NULL; @@ -5317,6 +7540,23 @@ static int32_t apiDiscAudio(lua_State *L) { } +// opened = discAudioSuffix(suffix) Hypseus extension. Plays ".ogg" beside the disc's video from now on; "" restores the default audio. +static int32_t apiDiscAudioSuffix(lua_State *L) { + const char *suffix = NULL; + bool opened = false; + + _argCheck(L, "discAudioSuffix", 1, 1); + suffix = _argString(L, "discAudioSuffix", 1); + if (_global.videoHandle >= 0) { + opened = _discSetAudioSuffix(suffix); + } + _luaTrace(L, "discAudioSuffix", "%s %d", suffix, opened); + lua_pushboolean(L, opened); + + return 1; +} + + static int32_t apiDiscChangeSpeed(lua_State *L) { return _apiUnimplemented(L, "discChangeSpeed"); } @@ -6012,17 +8252,10 @@ static int32_t apiFontToSprite(lua_State *L) { SpriteT *sprite = NULL; _argCheck(L, "fontToSprite", 1, 1); - message = _argString(L, "fontToSprite", 1); - sprite = (SpriteT *)calloc(1, sizeof(SpriteT)); - if (!sprite) { - _luaDie(L, "fontToSprite", "Unable to allocate new text sprite."); - } + message = _argString(L, "fontToSprite", 1); + sprite = _spriteNew(L, "fontToSprite"); sprite->originalSurface = _renderText(L, "fontToSprite", message); - sprite->surface = sprite->originalSurface; - sprite->scaleX = 1.0; - sprite->scaleY = 1.0; - sprite->id = _global.nextSpriteId++; - HASH_ADD_INT(_global.spriteList, id, sprite); + _spriteRegister(sprite); _luaTrace(L, "fontToSprite", "%d %s", sprite->id, message); lua_pushinteger(L, sprite->id); @@ -6044,6 +8277,16 @@ static int32_t apiFontUnload(lua_State *L) { // joint = jointBall(nodeA, nodeB, ax, ay, az): nodeB -1 fixes to the world; anchor in world space +// value = getFValue() Hypseus extension. The number --fvalue carried from the launcher, 0 with none. +static int32_t apiGetFValue(lua_State *L) { + _argCheck(L, "getFValue", 0, 0); + lua_pushnumber(L, _global.conf->fValue); + _luaTrace(L, "getFValue", "%f", _global.conf->fValue); + + return 1; +} + + // guiClose(gui, document): unloads a document static int32_t apiGuiClose(lua_State *L) { int32_t gui; @@ -6072,28 +8315,14 @@ static int32_t apiGuiDelete(lua_State *L) { // guiDraw(gui [, x, y [, width, height]]): composites the GUI over the overlay this frame, in overlay // coordinates (its own size when width and height are left out), or covering the overlay static int32_t apiGuiDraw(lua_State *L) { - GuiDrawT *draw; - int32_t top = lua_gettop(L); + return _guiQueueDraw(L, "guiDraw", false); +} - _argCheck(L, "guiDraw", 1, 5); - if ((top == 2) || (top == 4)) { - _luaDie(L, "guiDraw", "Expected 1, 3 or 5 arguments, got %d.", top); - } - if (_global.guiDrawCount >= GUI_DRAW_MAX) { - return 0; - } - draw = &_global.guiDraws[_global.guiDrawCount]; - draw->gui = _argGui(L, "guiDraw", 1); - draw->whole = (top == 1); - if (!draw->whole) { - draw->dest.x = (float)_argNumber(L, "guiDraw", 2); - draw->dest.y = (float)_argNumber(L, "guiDraw", 3); - draw->dest.w = (top == 5) ? (float)_argNumber(L, "guiDraw", 4) : (float)guiGetWidth(draw->gui); - draw->dest.h = (top == 5) ? (float)_argNumber(L, "guiDraw", 5) : (float)guiGetHeight(draw->gui); - } - _global.guiDrawCount++; - _overlayTouched(); - return 0; + +// guiDrawScreen(gui [, x, y [, width, height]]): the same, in overlay coordinates stretched over the +// whole window, so the GUI may sit on the bezel artwork outside the picture +static int32_t apiGuiDrawScreen(lua_State *L) { + return _guiQueueDraw(L, "guiDrawScreen", true); } @@ -6373,6 +8602,26 @@ static int32_t apiJointSlider(lua_State *L) { } +// joyMouseEnable(enabled) Hypseus extension. Whether the left stick of the first gamepad drives +// the mouse cursor. --joymouse sets where it starts, and a reload puts it back there. +static int32_t apiJoyMouseEnable(lua_State *L) { + _argCheck(L, "joyMouseEnable", 1, 1); + _global.joyMouseEnabled = _argBoolean(L, "joyMouseEnable", 1); + _luaTrace(L, "joyMouseEnable", "%d", _global.joyMouseEnabled); + + return 0; +} + + +// enabled = joyMouseIsEnabled() +static int32_t apiJoyMouseIsEnabled(lua_State *L) { + _luaTrace(L, "joyMouseIsEnabled", "%d", _global.joyMouseEnabled); + lua_pushboolean(L, _global.joyMouseEnabled); + + return 1; +} + + // scancode = keyboardGetLastDown() Cleared every frame. static int32_t apiKeyboardGetLastDown(lua_State *L) { _luaTrace(L, "keyboardGetLastDown", "%d", _global.keyboardLastDown); @@ -6547,6 +8796,20 @@ static int32_t apiLineDraw(lua_State *L) { } +// loaded, id = mainBezelLoaded() Hypseus extension. Whether --bezel loaded artwork, and the +// identifier of the file it loaded, which is "0" when none is. +static int32_t apiMainBezelLoaded(lua_State *L) { + const char *id = (_global.bezelHash != NULL) ? _global.bezelHash : BEZEL_HASH_NONE; + + _argCheck(L, "mainBezelLoaded", 0, 0); + _luaTrace(L, "mainBezelLoaded", "%d %s", _global.bezelTexture != NULL, id); + lua_pushboolean(L, _global.bezelTexture != NULL); + lua_pushstring(L, id); + + return 2; +} + + // materialDelete(material): a sprite node's own material (from nodeGetMaterial) stays with its node static int32_t apiMaterialDelete(lua_State *L) { int32_t material = 0; @@ -7082,6 +9345,217 @@ static int32_t apiModelLoad(lua_State *L) { } +// playing = musicIsPlaying([id]) Hypseus extension. With no handle, whether any music is playing. +static int32_t apiMusicIsPlaying(lua_State *L) { + MusicT *music = NULL; + MusicT *next = NULL; + bool playing = false; + + _argCheck(L, "musicIsPlaying", 0, 1); + if (lua_gettop(L) == 1) { + playing = MIX_TrackPlaying(_argMusic(L, "musicIsPlaying", 1)->track); + } else { + HASH_ITER(hh, _global.musicList, music, next) { + playing |= MIX_TrackPlaying(music->track); + } + } + _luaTrace(L, "musicIsPlaying", "%d", playing); + lua_pushboolean(L, playing); + + return 1; +} + + +// id = musicLoad(filename) Hypseus extension. Music is loaded and played apart from the sound +// effects and carries its own volume, which is what Hypseus's second mixer gives it. +static int32_t apiMusicLoad(lua_State *L) { + const char *name = NULL; + MusicT *music = NULL; + SDL_IOStream *io = NULL; + + _argCheck(L, "musicLoad", 1, 1); + name = _argString(L, "musicLoad", 1); + io = vfsOpenIO(name); + if (io == NULL) { + _luaDie(L, "musicLoad", "Unable to open %s", name); + } + music = (MusicT *)calloc(1, sizeof(MusicT)); + if (!music) { + SDL_CloseIO(io); + _luaDie(L, "musicLoad", "Unable to allocate new music."); + } + music->audio = MIX_LoadAudio_IO(videoGetMixer(), io, true, true); + if (!music->audio) { + free(music); + _luaDie(L, "musicLoad", "%s", SDL_GetError()); + } + music->track = MIX_CreateTrack(videoGetMixer()); + if (!music->track) { + MIX_DestroyAudio(music->audio); + free(music); + _luaDie(L, "musicLoad", "%s", SDL_GetError()); + } + MIX_TagTrack(music->track, MUSIC_TAG); + MIX_SetTrackGain(music->track, _mixerGain(_global.musicVolume, MUSIC_MAX_VOLUME)); + music->id = _global.nextMusicId++; + HASH_ADD_INT(_global.musicList, id, music); + _luaTrace(L, "musicLoad", "%d %s", music->id, name); + lua_pushinteger(L, music->id); + + return 1; +} + + +// musicPause([id]) Hypseus extension. With no handle every piece of music pauses. +static int32_t apiMusicPause(lua_State *L) { + MusicT *music = NULL; + MusicT *next = NULL; + + _argCheck(L, "musicPause", 0, 1); + if (lua_gettop(L) == 1) { + music = _argMusic(L, "musicPause", 1); + MIX_PauseTrack(music->track); + } else { + HASH_ITER(hh, _global.musicList, music, next) { + MIX_PauseTrack(music->track); + } + } + _luaTrace(L, "musicPause", "%d", (lua_gettop(L) == 1) ? music->id : -1); + + return 0; +} + + +// musicPlay(id [, loops]) Hypseus extension. loops means what soundPlay means: 0 (the default) +// plays once, -1 forever, N repeats N more times. Hypseus clamps the count to -1 to 64 and so does +// this, so a game asking for more gets what it would have got there. +static int32_t apiMusicPlay(lua_State *L) { + MusicT *music = NULL; + int32_t loops = 0; + SDL_PropertiesID options; + + _argCheck(L, "musicPlay", 1, 2); + music = _argMusic(L, "musicPlay", 1); + if (lua_gettop(L) >= 2) { + loops = SDL_clamp(_argInteger(L, "musicPlay", 2), SOUND_LOOP_FOREVER, MUSIC_LOOP_MAX); + } + MIX_SetTrackAudio(music->track, music->audio); + MIX_SetTrackGain(music->track, _mixerGain(_global.musicVolume, MUSIC_MAX_VOLUME)); + options = SDL_CreateProperties(); + SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops); + if (!MIX_PlayTrack(music->track, options)) { + SDL_DestroyProperties(options); + _luaDie(L, "musicPlay", "%s", SDL_GetError()); + } + SDL_DestroyProperties(options); + _luaTrace(L, "musicPlay", "%d loops %d", music->id, loops); + + return 0; +} + + +// musicResume([id]) Hypseus extension. With no handle every paused piece of music resumes, where +// Hypseus's own zero argument form does nothing at all. +static int32_t apiMusicResume(lua_State *L) { + MusicT *music = NULL; + MusicT *next = NULL; + + _argCheck(L, "musicResume", 0, 1); + if (lua_gettop(L) == 1) { + music = _argMusic(L, "musicResume", 1); + MIX_ResumeTrack(music->track); + } else { + HASH_ITER(hh, _global.musicList, music, next) { + MIX_ResumeTrack(music->track); + } + } + _luaTrace(L, "musicResume", "%d", (lua_gettop(L) == 1) ? music->id : -1); + + return 0; +} + + +// volume = musicSetVolume(volume) Hypseus extension. 0 to MUSIC_MAX_VOLUME, Hypseus's own scale, +// over every piece of music at once and nothing else: soundSetVolume does not move music and this +// does not move a sound effect. Returns the level it took. +static int32_t apiMusicSetVolume(lua_State *L) { + int32_t volume = 0; + + _argCheck(L, "musicSetVolume", 1, 1); + volume = _argInteger(L, "musicSetVolume", 1); + if ((volume < 0) || (volume > MUSIC_MAX_VOLUME)) { + _luaDie(L, "musicSetVolume", "Invalid music volume value: %d", volume); + } + _global.musicVolume = volume; + MIX_SetTagGain(videoGetMixer(), MUSIC_TAG, _mixerGain(_global.musicVolume, MUSIC_MAX_VOLUME)); + _luaTrace(L, "musicSetVolume", "%d", _global.musicVolume); + lua_pushinteger(L, _global.musicVolume); + + return 1; +} + + +// musicStop([id [, fadeMs]]) Hypseus extension. With no handle every piece of music stops, as +// Hypseus stops whatever its music mixer is playing. A fade is in milliseconds. +static int32_t apiMusicStop(lua_State *L) { + MusicT *music = NULL; + MusicT *next = NULL; + int32_t fade = 0; + + _argCheck(L, "musicStop", 0, 2); + if (lua_gettop(L) >= 2) { + fade = _argInteger(L, "musicStop", 2); + if (fade < 0) { + _luaDie(L, "musicStop", "A fade cannot be negative: %d", fade); + } + } + if (lua_gettop(L) >= 1) { + music = _argMusic(L, "musicStop", 1); + MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); + } else { + HASH_ITER(hh, _global.musicList, music, next) { + MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); + } + } + _luaTrace(L, "musicStop", "%d fade %d", (lua_gettop(L) >= 1) ? music->id : -1, fade); + + return 0; +} + + +// musicUnload(id) Hypseus extension. Stops the music first, as Hypseus frees a playing chunk. +static int32_t apiMusicUnload(lua_State *L) { + MusicT *music = NULL; + + _argCheck(L, "musicUnload", 1, 1); + music = _argMusic(L, "musicUnload", 1); + _luaTrace(L, "musicUnload", "%d", music->id); + _musicDestroy(music); + + return 0; +} + + +// name = mouseGetName(mouse) What the driver calls the device, or nil for a device that is not there. +static int32_t apiMouseGetName(lua_State *L) { + int32_t m = 0; + + _argCheck(L, "mouseGetName", 1, 1); + m = _argInteger(L, "mouseGetName", 1); + if ((m < 0) || (m >= MAX_MICE)) { + _luaDie(L, "mouseGetName", "Invalid mouse index: %d", m); + } + _luaTrace(L, "mouseGetName", "%d %s", m, (m < _global.mouseCount) ? _global.mice[m].name : "nil"); + if (m < _global.mouseCount) { + lua_pushstring(L, _global.mice[m].name); + } else { + lua_pushnil(L); + } + + return 1; +} + + // x, y = mouseGetPosition(mouse) static int32_t apiMouseGetPosition(lua_State *L) { int32_t m = 0; @@ -7141,7 +9615,9 @@ static int32_t apiMouseSetMode(lua_State *L) { if ((mode != MOUSE_SINGLE) && (mode != MOUSE_MANY)) { _luaDie(L, "mouseSetMode", "Unknown mouse mode: %d", mode); } - _global.mouseMode = (MouseModeE)mode; + // --manymouse is the user's decision and outranks the game's: a two gun cabinet whose game + // never asks for the many mouse mode can still be made to work from outside. + _global.mouseMode = (_global.conf->manyMouse) ? MOUSE_MANY : (MouseModeE)mode; _luaTrace(L, "mouseSetMode", "%d", _global.mouseMode); return 0; @@ -8020,6 +10496,34 @@ static int32_t apiOverlayEllipse(lua_State *L) { } +// overlayBanner(text [, height]) Hypseus extension. Shows up to 60 characters over the picture for +// about thirty drawn frames, at a height given as a per cent down it, through the subtitle document. +// A longer message is refused, as Hypseus refuses it. +static int32_t apiOverlayBanner(lua_State *L) { + const char *text = NULL; + int32_t position = BANNER_POSITION_DEFAULT; + + _argCheck(L, "overlayBanner", 1, 2); + text = _argString(L, "overlayBanner", 1); + if (lua_gettop(L) == 2) { + position = _argInteger(L, "overlayBanner", 2); + if ((position < SUBTITLE_POSITION_MIN) || (position > SUBTITLE_POSITION_MAX)) { + position = BANNER_POSITION_DEFAULT; + } + } + if (strlen(text) <= BANNER_TEXT_MAX) { + free(_global.bannerText); + _global.bannerText = strdup(text); + _global.bannerFrames = BANNER_FRAMES; + _global.bannerPosition = position; + _subtitleShow(_global.bannerText, _global.bannerPosition); + } + _luaTrace(L, "overlayBanner", "%s %d %d", text, position, _global.bannerFrames); + + return 0; +} + + // height = overlayGetHeight() static int32_t apiOverlayGetHeight(lua_State *L) { _luaTrace(L, "overlayGetHeight", "%d", _global.overlay->h); @@ -8122,6 +10626,17 @@ static int32_t apiOverlayPrint(lua_State *L) { } +// overlaySetMonochrome(enabled) Hypseus extension. Shows the overlay in grey; its pixels stay in colour. +static int32_t apiOverlaySetMonochrome(lua_State *L) { + _argCheck(L, "overlaySetMonochrome", 1, 1); + _global.overlayMonochrome = _argBoolean(L, "overlaySetMonochrome", 1); + _overlayTouched(); + _luaTrace(L, "overlaySetMonochrome", "%d", _global.overlayMonochrome); + + return 0; +} + + // overlaySetResolution(width, height) Replaces the overlay; its contents are lost. static int32_t apiOverlaySetResolution(lua_State *L) { int32_t width = 0; @@ -8447,6 +10962,26 @@ static int32_t apiRagdollSetStrength(lua_State *L) { } +// ratio = ratioGetX() Hypseus extension. --xratio's gun coordinate scale, 0 when it was not given. +static int32_t apiRatioGetX(lua_State *L) { + _argCheck(L, "ratioGetX", 0, 0); + lua_pushnumber(L, _global.conf->ratioX); + _luaTrace(L, "ratioGetX", "%f", _global.conf->ratioX); + + return 1; +} + + +// ratio = ratioGetY() Hypseus extension. --yratio's gun coordinate scale, 0 when it was not given. +static int32_t apiRatioGetY(lua_State *L) { + _argCheck(L, "ratioGetY", 0, 0); + lua_pushnumber(L, _global.conf->ratioY); + _luaTrace(L, "ratioGetY", "%f", _global.conf->ratioY); + + return 1; +} + + // sceneEnable(bool): turns the 3D layer on or off static int32_t apiSceneEnable(lua_State *L) { bool enabled; @@ -8676,6 +11211,107 @@ static int32_t apiSceneUnproject(lua_State *L) { } +// scoreBezelClear() Hypseus extension. Blanks every readout until a value arrives. +static int32_t apiScoreBezelClear(lua_State *L) { + _argCheck(L, "scoreBezelClear", 0, 0); + _global.scoreBlank = true; + _scorePanelUpdate(); + _luaTrace(L, "scoreBezelClear", "%d", _global.scoreEnabled); + + return 0; +} + + +// scoreBezelCredits(credits) Hypseus extension. The credit readout. +static int32_t apiScoreBezelCredits(lua_State *L) { + _argCheck(L, "scoreBezelCredits", 1, 1); + _global.scoreCredits = _argInteger(L, "scoreBezelCredits", 1); + _global.scoreBlank = false; + _scorePanelUpdate(); + _luaTrace(L, "scoreBezelCredits", "%d", _global.scoreCredits); + + return 0; +} + + +// scoreBezelEnable(enabled [, type]) Hypseus extension. Shows or hides the score panel. Hypseus +// chooses one of five scoreboards with type; Singe has the one document, so the argument is checked +// and ignored. +static int32_t apiScoreBezelEnable(lua_State *L) { + bool enabled = false; + + _argCheck(L, "scoreBezelEnable", 1, 2); + enabled = _argBoolean(L, "scoreBezelEnable", 1); + if (lua_gettop(L) == 2) { + _argInteger(L, "scoreBezelEnable", 2); + } + _scorePanelEnable(enabled); + _luaTrace(L, "scoreBezelEnable", "%d %d", enabled, _global.scoreEnabled); + + return 0; +} + + +// state = scoreBezelGetState() Hypseus extension. Whether the score panel is showing. Every +// Hypseus game asks before using it and draws its own sprite scoreboard when it is not. +static int32_t apiScoreBezelGetState(lua_State *L) { + _argCheck(L, "scoreBezelGetState", 0, 0); + _luaTrace(L, "scoreBezelGetState", "%d", _global.scoreEnabled); + lua_pushboolean(L, _global.scoreEnabled); + + return 1; +} + + +// scoreBezelLives(player, lives) Hypseus extension. A player's life readout. +static int32_t apiScoreBezelLives(lua_State *L) { + int32_t player = 0; + + _argCheck(L, "scoreBezelLives", 2, 2); + player = _scorePanelPlayer(_argInteger(L, "scoreBezelLives", 1)); + if (player >= 0) { + _global.scoreLives[player] = _argInteger(L, "scoreBezelLives", 2); + _global.scoreBlank = false; + _scorePanelUpdate(); + } + _luaTrace(L, "scoreBezelLives", "%d %d", player, (player >= 0) ? _global.scoreLives[player] : 0); + + return 0; +} + + +// scoreBezelScore(player, score) Hypseus extension. A player's score readout. +static int32_t apiScoreBezelScore(lua_State *L) { + int32_t player = 0; + + _argCheck(L, "scoreBezelScore", 2, 2); + player = _scorePanelPlayer(_argInteger(L, "scoreBezelScore", 1)); + if (player >= 0) { + _global.scoreValue[player] = _argInteger(L, "scoreBezelScore", 2); + _global.scoreBlank = false; + _scorePanelUpdate(); + } + _luaTrace(L, "scoreBezelScore", "%d %d", player, (player >= 0) ? _global.scoreValue[player] : 0); + + return 0; +} + + +// scoreBezelTwinScoreOn(enabled) Hypseus extension. Shows the second player's readouts; switching +// them off blanks the panel, as it does in Hypseus. +static int32_t apiScoreBezelTwinScoreOn(lua_State *L) { + _argCheck(L, "scoreBezelTwinScoreOn", 1, 1); + _global.scoreTwin = _argBoolean(L, "scoreBezelTwinScoreOn", 1); + if (!_global.scoreTwin) { + _global.scoreBlank = true; + } + _scorePanelUpdate(); + _luaTrace(L, "scoreBezelTwinScoreOn", "%d", _global.scoreTwin); + + return 0; +} + + // scriptExecute(config) Runs another script after this one ends. static int32_t apiScriptExecute(lua_State *L) { ConfigT *conf = _scriptConfFromTable(L, "scriptExecute"); @@ -8703,6 +11339,61 @@ static int32_t apiScriptPush(lua_State *L) { } +// setOverlayLinearScale(enabled) Hypseus extension. Smooths the overlay as it is scaled to the window instead of blocking it up. +static int32_t apiSetOverlayLinearScale(lua_State *L) { + bool enabled = false; + + _argCheck(L, "setOverlayLinearScale", 1, 1); + enabled = _argBoolean(L, "setOverlayLinearScale", 1); + if (enabled != _global.overlayLinearScale) { + _global.overlayLinearScale = enabled; + _overlayApplyScaleMode(); + } + _luaTrace(L, "setOverlayLinearScale", "%d", _global.overlayLinearScale); + + return 0; +} + + +// loaded, id = setOverlayOnTop(enabled) Hypseus extension. Whether the overlay and the GUIs draw +// above the bezel artwork or below it. Only bezel artwork drawn in front of the picture +// (--bezelflip) is ever above them, so this only changes anything there. +static int32_t apiSetOverlayOnTop(lua_State *L) { + const char *id = BEZEL_HASH_NONE; + bool loaded = (_global.bezelTexture != NULL); + bool enabled = false; + + _argCheck(L, "setOverlayOnTop", 1, 1); + enabled = _argBoolean(L, "setOverlayOnTop", 1); + if (loaded) { + _global.overlayOnTop = enabled; + _global.refreshDisplay = true; + id = _global.bezelHash; + } + _luaTrace(L, "setOverlayOnTop", "%d %d %s", enabled, loaded, id); + lua_pushboolean(L, loaded); + lua_pushstring(L, id); + + return 2; +} + + +// setOverlayOpacity(alpha) Hypseus extension. How solid the whole overlay is, 0 for invisible to +// 255 for the alpha the game drew. It multiplies whatever alpha the overlay's own pixels carry. +static int32_t apiSetOverlayOpacity(lua_State *L) { + int32_t alpha = 0; + + _argCheck(L, "setOverlayOpacity", 1, 1); + alpha = _argInteger(L, "setOverlayOpacity", 1); + _global.overlayOpacity = (uint8_t)SDL_clamp(alpha, 0, COLOR_BYTE_MAX); + _overlayApplyOpacity(); + _global.refreshDisplay = true; + _luaTrace(L, "setOverlayOpacity", "%d", _global.overlayOpacity); + + return 0; +} + + // milliseconds = singeGetAudioCalibration() The per-machine value saved by the menu's calibration screen. static int32_t apiSingeGetAudioCalibration(lua_State *L) { int32_t value = videoGetAudioCalibration(); @@ -8891,6 +11582,16 @@ static int32_t apiSingeSetPauseKeyEnabled(lua_State *L) { } +// singeSetQuitKeyEnabled(enabled) Hypseus extension. Framework.singe aliases keyboardCatchQuit(catch) with the opposite sense. +static int32_t apiSingeSetQuitKeyEnabled(lua_State *L) { + _argCheck(L, "singeSetQuitKeyEnabled", 1, 1); + _global.quitEnabled = _argBoolean(L, "singeSetQuitKeyEnabled", 1); + _luaTrace(L, "singeSetQuitKeyEnabled", "%d", _global.quitEnabled); + + return 0; +} + + // version = singeVersion() static int32_t apiSingeVersion(lua_State *L) { _luaTrace(L, "singeVersion", "%s", VERSION_STRING); @@ -9057,16 +11758,7 @@ static int32_t apiSoundLoad(lua_State *L) { if (io == NULL) { _luaDie(L, "soundLoad", "Unable to open %s", name); } - sound = (SoundT *)calloc(1, sizeof(SoundT)); - if (!sound) { - _luaDie(L, "soundLoad", "Unable to allocate new sound."); - } - sound->audio = MIX_LoadAudio_IO(videoGetMixer(), io, true, true); - if (!sound->audio) { - _luaDie(L, "soundLoad", "%s", SDL_GetError()); - } - sound->id = _global.nextSoundId++; - HASH_ADD_INT(_global.soundList, id, sound); + sound = _soundLoadFromIO(L, "soundLoad", io); _luaTrace(L, "soundLoad", "%d %s", sound->id, name); lua_pushinteger(L, sound->id); @@ -9074,6 +11766,24 @@ static int32_t apiSoundLoad(lua_State *L) { } +// id = soundLoadData(data) Hypseus extension. A sound built from bytes held in a Lua string +// instead of a file, for a game that keeps its audio somewhere Singe cannot open by name. Every +// format soundLoad reads is read here. +static int32_t apiSoundLoadData(lua_State *L) { + const char *data = NULL; + SoundT *sound = NULL; + size_t length = 0; + + _argCheck(L, "soundLoadData", 1, 1); + data = _argData(L, "soundLoadData", 1, &length); + sound = _soundLoadFromIO(L, "soundLoadData", SDL_IOFromConstMem(data, length)); + _luaTrace(L, "soundLoadData", "%d %zu", sound->id, length); + lua_pushinteger(L, sound->id); + + return 1; +} + + // wasPlaying = soundPause(channel) static int32_t apiSoundPause(lua_State *L) { int32_t channel = 0; @@ -9110,7 +11820,7 @@ static int32_t apiSoundPlay(lua_State *L) { if (channel >= 0) { _effectReset(channel); MIX_SetTrackAudio(_effectTracks[channel], sound->audio); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume)); + MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); options = SDL_CreateProperties(); SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops); if (!MIX_PlayTrack(_effectTracks[channel], options)) { @@ -9166,7 +11876,7 @@ static int32_t apiSoundSetNode(lua_State *L) { effect->node = LISTENER_CAMERA; effect->positioned = false; MIX_SetTrack3DPosition(_effectTracks[channel], NULL); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume)); + MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); } return 0; } @@ -9187,7 +11897,7 @@ static int32_t apiSoundSetPan(lua_State *L) { gains.left = SDL_min(1.0f, 1.0f - pan); gains.right = SDL_min(1.0f, 1.0f + pan); MIX_SetTrackStereo(_effectTracks[channel], &gains); - MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume)); + MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); return 0; } @@ -9238,7 +11948,7 @@ static int32_t apiSoundSetVolume(lua_State *L) { _luaDie(L, "soundSetVolume", "Invalid sound volume value: %d", volume); } _global.effectsVolume = volume; - MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume)); + MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); _luaTrace(L, "soundSetVolume", "%d", _global.effectsVolume); return 0; @@ -9357,6 +12067,142 @@ static int32_t apiSpriteDraw(lua_State *L) { } +// spriteDrawFrame(id, x, y, frame[, scale[, scaleY]]) Hypseus extension. Frames are 1-based and an out of range frame draws frame 1; the sprite's rotation, scale and playback state are ignored. +static int32_t apiSpriteDrawFrame(lua_State *L) { + int32_t n = lua_gettop(L); + SpriteT *sprite = NULL; + SDL_Surface *source = NULL; + int32_t frame = 0; + double scaleX = 1.0; + double scaleY = 1.0; + SDL_Rect dest; + + _argCheck(L, "spriteDrawFrame", 4, 6); + sprite = _argSprite(L, "spriteDrawFrame", 1); + dest.x = _argInteger(L, "spriteDrawFrame", 2); + dest.y = _argInteger(L, "spriteDrawFrame", 3); + frame = _argInteger(L, "spriteDrawFrame", 4); + source = sprite->originalSurface; + if (sprite->animation != NULL) { + if ((frame < 1) || (frame > sprite->animation->count)) { + frame = 1; + } + source = sprite->animation->frames[frame - 1]; + } + dest.w = source->w; + dest.h = source->h; + if (n == 4) { + SDL_BlitSurface(source, NULL, _global.overlay, &dest); + } else { + scaleX = _argNumber(L, "spriteDrawFrame", 5); + scaleY = (n == 6) ? _argNumber(L, "spriteDrawFrame", 6) : scaleX; + dest.w = (int32_t)(source->w * scaleX); + dest.h = (int32_t)(source->h * scaleY); + if ((dest.w > 0) && (dest.h > 0)) { + SDL_BlitSurfaceScaled(source, NULL, _global.overlay, &dest, SDL_SCALEMODE_NEAREST); + } + } + _overlayTouched(); + _luaTrace(L, "spriteDrawFrame", "%d %d %d %d %d %d", sprite->id, dest.x, dest.y, frame, dest.w, dest.h); + + return 0; +} + + +// spriteDrawGrid(id, x, y, sourceX, sourceY, width, height) Hypseus extension. Draws one +// rectangle out of the sprite, at its own size, with its top left corner at x, y. The rectangle is +// in the sprite's own pixels and must lie inside it. +static int32_t apiSpriteDrawGrid(lua_State *L) { + SpriteT *sprite = NULL; + SDL_Rect source; + SDL_Rect dest; + + _argCheck(L, "spriteDrawGrid", 7, 7); + sprite = _argSprite(L, "spriteDrawGrid", 1); + dest.x = _argInteger(L, "spriteDrawGrid", 2); + dest.y = _argInteger(L, "spriteDrawGrid", 3); + source.x = _argInteger(L, "spriteDrawGrid", 4); + source.y = _argInteger(L, "spriteDrawGrid", 5); + source.w = _argInteger(L, "spriteDrawGrid", 6); + source.h = _argInteger(L, "spriteDrawGrid", 7); + if ((source.x < 0) || (source.y < 0) || (source.w < 1) || (source.h < 1) || ((source.x + source.w) > sprite->surface->w) || ((source.y + source.h) > sprite->surface->h)) { + _luaDie(L, "spriteDrawGrid", "Rectangle %d,%d %dx%d is not inside sprite %d, which is %dx%d.", source.x, source.y, source.w, source.h, sprite->id, sprite->surface->w, sprite->surface->h); + } + dest.w = source.w; + dest.h = source.h; + SDL_BlitSurface(sprite->surface, &source, _global.overlay, &dest); + _overlayTouched(); + _luaTrace(L, "spriteDrawGrid", "%d %d %d %d %d %d %d", sprite->id, dest.x, dest.y, source.x, source.y, source.w, source.h); + + return 0; +} + + +// spriteDrawRotatedFrame(id, x, y[, scale[, scaleY]]) Hypseus extension. Draws the frame +// spriteRotateFrame turned, centred on x, y, at its own size or scaled again. Unlike spriteDrawFrame +// this one honours a rotation, which is the reason the pair exists. +static int32_t apiSpriteDrawRotatedFrame(lua_State *L) { + int32_t n = lua_gettop(L); + SpriteT *sprite = NULL; + double scaleX = 1.0; + double scaleY = 1.0; + SDL_Rect dest; + + _argCheck(L, "spriteDrawRotatedFrame", 3, 5); + sprite = _argSprite(L, "spriteDrawRotatedFrame", 1); + dest.x = _argInteger(L, "spriteDrawRotatedFrame", 2); + dest.y = _argInteger(L, "spriteDrawRotatedFrame", 3); + if (sprite->rotatedFrame == NULL) { + _luaDie(L, "spriteDrawRotatedFrame", "Sprite %d has no rotated frame; call spriteRotateFrame first.", sprite->id); + } + if (n >= 4) { + scaleX = _argNumber(L, "spriteDrawRotatedFrame", 4); + scaleY = (n == 5) ? _argNumber(L, "spriteDrawRotatedFrame", 5) : scaleX; + } + dest.w = (int32_t)(sprite->rotatedFrame->w * scaleX); + dest.h = (int32_t)(sprite->rotatedFrame->h * scaleY); + dest.x -= dest.w / 2; + dest.y -= dest.h / 2; + if ((dest.w > 0) && (dest.h > 0)) { + if (n == 3) { + SDL_BlitSurface(sprite->rotatedFrame, NULL, _global.overlay, &dest); + } else { + SDL_BlitSurfaceScaled(sprite->rotatedFrame, NULL, _global.overlay, &dest, SDL_SCALEMODE_NEAREST); + } + } + _overlayTouched(); + _luaTrace(L, "spriteDrawRotatedFrame", "%d %d %d %d %d", sprite->id, dest.x, dest.y, dest.w, dest.h); + + return 0; +} + + +// height = spriteFrameHeight(id) Hypseus extension. One frame's height before scaling and rotation; the image's, for a still. +static int32_t apiSpriteFrameHeight(lua_State *L) { + SpriteT *sprite = NULL; + + _argCheck(L, "spriteFrameHeight", 1, 1); + sprite = _argSprite(L, "spriteFrameHeight", 1); + _luaTrace(L, "spriteFrameHeight", "%d %d", sprite->id, sprite->frameHeight); + lua_pushinteger(L, sprite->frameHeight); + + return 1; +} + + +// width = spriteFrameWidth(id) Hypseus extension. One frame's width before scaling and rotation; the image's, for a still. +static int32_t apiSpriteFrameWidth(lua_State *L) { + SpriteT *sprite = NULL; + + _argCheck(L, "spriteFrameWidth", 1, 1); + sprite = _argSprite(L, "spriteFrameWidth", 1); + _luaTrace(L, "spriteFrameWidth", "%d %d", sprite->id, sprite->frameWidth); + lua_pushinteger(L, sprite->frameWidth); + + return 1; +} + + // frame = spriteGetFrame(id) static int32_t apiSpriteGetFrame(lua_State *L) { SpriteT *sprite = NULL; @@ -9370,6 +12216,21 @@ static int32_t apiSpriteGetFrame(lua_State *L) { } +// count = spriteGetFrames(id) Hypseus extension. Frames in a sheet or an animation; 0 for a still. +static int32_t apiSpriteGetFrames(lua_State *L) { + SpriteT *sprite = NULL; + int32_t frames = 0; + + _argCheck(L, "spriteGetFrames", 1, 1); + sprite = _argSprite(L, "spriteGetFrames", 1); + frames = (sprite->animation != NULL) ? sprite->animation->count : 0; + _luaTrace(L, "spriteGetFrames", "%d %d", sprite->id, frames); + lua_pushinteger(L, frames); + + return 1; +} + + // height = spriteGetHeight(id) Height as drawn, after scaling and rotation. static int32_t apiSpriteGetHeight(lua_State *L) { SpriteT *sprite = NULL; @@ -9414,54 +12275,14 @@ static int32_t apiSpriteLoad(lua_State *L) { const char *name = NULL; SpriteT *sprite = NULL; SDL_IOStream *io = NULL; - int32_t x = 0; _argCheck(L, "spriteLoad", 1, 1); - name = _argString(L, "spriteLoad", 1); - sprite = (SpriteT *)calloc(1, sizeof(SpriteT)); - if (!sprite) { - _luaDie(L, "spriteLoad", "Unable to allocate new sprite."); - } - // Try to load requested file as an animation first - io = vfsOpenIO(name); + name = _argString(L, "spriteLoad", 1); + io = vfsOpenIO(name); if (io == NULL) { _luaDie(L, "spriteLoad", "%s", SDL_GetError()); } - sprite->animation = IMG_LoadAnimation_IO(io, false); - if ((sprite->animation != NULL) && (sprite->animation->count < 2)) { - // Only one frame: take it over as the still image (no copy, so packed 1-bit PNGs survive). - sprite->originalSurface = sprite->animation->frames[0]; - sprite->animation->frames[0] = NULL; - _surfaceUnpack(&sprite->originalSurface); - IMG_FreeAnimation(sprite->animation); - sprite->animation = NULL; - } else { - if (sprite->animation != NULL) { - for (x = 0; x < sprite->animation->count; x++) { - _surfaceUnpack(&sprite->animation->frames[x]); - SDL_SetSurfaceColorKey(sprite->animation->frames[x], true, COLOR_KEY_VALUE); - sprite->loopMs += (uint64_t)SDL_max(sprite->animation->delays[x], ANIMATION_MIN_DELAY_MS); - } - sprite->originalSurface = sprite->animation->frames[0]; - } else { - SDL_SeekIO(io, 0, SDL_IO_SEEK_SET); - sprite->originalSurface = IMG_Load_IO(io, false); - _surfaceUnpack(&sprite->originalSurface); - } - } - SDL_CloseIO(io); - if (!sprite->originalSurface) { - _luaDie(L, "spriteLoad", "%s", SDL_GetError()); - } - if (sprite->animation == NULL) { - // An animation's frames were keyed above, this one included. - SDL_SetSurfaceColorKey(sprite->originalSurface, true, COLOR_KEY_VALUE); - } - sprite->surface = sprite->originalSurface; - sprite->scaleX = 1.0; - sprite->scaleY = 1.0; - sprite->id = _global.nextSpriteId++; - HASH_ADD_INT(_global.spriteList, id, sprite); + sprite = _spriteLoadFromIO(L, "spriteLoad", io); _luaTrace(L, "spriteLoad", "%d %s", sprite->id, name); lua_pushinteger(L, sprite->id); @@ -9469,6 +12290,94 @@ static int32_t apiSpriteLoad(lua_State *L) { } +// id = spriteLoadData(data) Hypseus extension. A sprite built from an image held in a Lua string +// instead of a file, for a game that keeps its artwork somewhere Singe cannot open by name. Every +// format spriteLoad reads is read here, animations included. +static int32_t apiSpriteLoadData(lua_State *L) { + const char *data = NULL; + SpriteT *sprite = NULL; + size_t length = 0; + + _argCheck(L, "spriteLoadData", 1, 1); + data = _argData(L, "spriteLoadData", 1, &length); + sprite = _spriteLoadFromIO(L, "spriteLoadData", SDL_IOFromConstMem(data, length)); + _luaTrace(L, "spriteLoadData", "%d %zu", sprite->id, length); + lua_pushinteger(L, sprite->id); + + return 1; +} + + +// id = spriteLoadFrames(count, filename) Hypseus extension. A single row strip of count equal frames loads as an animation that is not playing. +static int32_t apiSpriteLoadFrames(lua_State *L) { + const char *name = NULL; + SpriteT *sprite = NULL; + SDL_IOStream *io = NULL; + SDL_Surface *sheet = NULL; + SDL_Surface *frame = NULL; + int32_t count = 0; + int32_t x = 0; + SDL_Rect source; + + _argCheck(L, "spriteLoadFrames", 2, 2); + count = _argInteger(L, "spriteLoadFrames", 1); + name = _argString(L, "spriteLoadFrames", 2); + if ((count < SPRITE_SHEET_FRAMES_MIN) || (count > SPRITE_SHEET_FRAMES_MAX)) { + _luaDie(L, "spriteLoadFrames", "Frame count %d is outside %d to %d.", count, SPRITE_SHEET_FRAMES_MIN, SPRITE_SHEET_FRAMES_MAX); + } + io = vfsOpenIO(name); + if (io == NULL) { + _luaDie(L, "spriteLoadFrames", "%s", SDL_GetError()); + } + sheet = IMG_Load_IO(io, true); + if (sheet == NULL) { + _luaDie(L, "spriteLoadFrames", "%s", SDL_GetError()); + } + _surfaceUnpack(&sheet); + source.x = 0; + source.y = 0; + source.w = sheet->w / count; + source.h = sheet->h; + if (source.w < 1) { + _luaDie(L, "spriteLoadFrames", "%s is narrower than %d frames.", name, count); + } + sprite = _spriteNew(L, "spriteLoadFrames"); + sprite->animation = (IMG_Animation *)SDL_calloc(1, sizeof(IMG_Animation)); + if (sprite->animation != NULL) { + // IMG_FreeAnimation releases these, so they come from SDL's allocator. + sprite->animation->frames = (SDL_Surface **)SDL_calloc((size_t)count, sizeof(SDL_Surface *)); + sprite->animation->delays = (int *)SDL_calloc((size_t)count, sizeof(int)); + } + if ((sprite->animation == NULL) || (sprite->animation->frames == NULL) || (sprite->animation->delays == NULL)) { + _luaDie(L, "spriteLoadFrames", "Unable to allocate the sprite sheet."); + } + sprite->animation->w = source.w; + sprite->animation->h = source.h; + sprite->animation->count = count; + // Copy the pixels as they are; blending would darken translucent ones over the empty frame. + SDL_SetSurfaceBlendMode(sheet, SDL_BLENDMODE_NONE); + for (x = 0; x < count; x++) { + frame = SDL_CreateSurface(source.w, source.h, sheet->format); + if (frame == NULL) { + _luaDie(L, "spriteLoadFrames", "%s", SDL_GetError()); + } + if (SDL_ISPIXELFORMAT_INDEXED(sheet->format)) { + SDL_SetSurfacePalette(frame, SDL_GetSurfacePalette(sheet)); + } + SDL_BlitSurface(sheet, &source, frame, NULL); + sprite->animation->frames[x] = frame; + source.x += source.w; + } + SDL_DestroySurface(sheet); + _spriteAdoptAnimation(sprite); + _spriteRegister(sprite); + _luaTrace(L, "spriteLoadFrames", "%d %d %s", sprite->id, count, name); + lua_pushinteger(L, sprite->id); + + return 1; +} + + // spriteLoop(id, loop) static int32_t apiSpriteLoop(lua_State *L) { SpriteT *sprite = NULL; @@ -9529,6 +12438,20 @@ static int32_t apiSpriteQuality(lua_State *L) { } +// spriteResetColorKey(id, enabled) Hypseus extension. Accepted for compatibility: Singe's rotated and scaled copies keep their transparency, so there is nothing to re-key. +static int32_t apiSpriteResetColorKey(lua_State *L) { + SpriteT *sprite = NULL; + bool enabled = false; + + _argCheck(L, "spriteResetColorKey", 2, 2); + sprite = _argSprite(L, "spriteResetColorKey", 1); + enabled = _argBoolean(L, "spriteResetColorKey", 2); + _luaTrace(L, "spriteResetColorKey", "%d %d", sprite->id, enabled); + + return 0; +} + + // spriteRotate(id, degrees) Clockwise. static int32_t apiSpriteRotate(lua_State *L) { SpriteT *sprite = NULL; @@ -9572,6 +12495,37 @@ static int32_t apiSpriteRotateAndScale(lua_State *L) { } +// spriteRotateFrame(id, degrees, frame) Hypseus extension. Takes one frame out of a sheet, turns +// it clockwise and scales it by the sprite's own scale, and keeps it for spriteDrawRotatedFrame. +// Frames are 1 based and an out of range frame is frame 1, as spriteDrawFrame has them; a still +// image turns its whole picture. The sprite's own drawn surface is left alone. +static int32_t apiSpriteRotateFrame(lua_State *L) { + SpriteT *sprite = NULL; + SDL_Surface *source = NULL; + int32_t frame = 0; + + _argCheck(L, "spriteRotateFrame", 3, 3); + sprite = _argSprite(L, "spriteRotateFrame", 1); + sprite->angle = fmod(_argNumber(L, "spriteRotateFrame", 2), DEGREES_PER_CIRCLE); + frame = _argInteger(L, "spriteRotateFrame", 3); + source = sprite->originalSurface; + if (sprite->animation != NULL) { + if ((frame < 1) || (frame > sprite->animation->count)) { + frame = 1; + } + source = sprite->animation->frames[frame - 1]; + } + SDL_DestroySurface(sprite->rotatedFrame); + sprite->rotatedFrame = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); + if (sprite->rotatedFrame == NULL) { + _luaDie(L, "spriteRotateFrame", "Unable to turn frame %d of sprite %d.", frame, sprite->id); + } + _luaTrace(L, "spriteRotateFrame", "%d %f %d %dx%d", sprite->id, sprite->angle, frame, sprite->rotatedFrame->w, sprite->rotatedFrame->h); + + return 0; +} + + // spriteScale(id, scale) or spriteScale(id, scaleX, scaleY) static int32_t apiSpriteScale(lua_State *L) { int32_t n = lua_gettop(L); @@ -9627,6 +12581,83 @@ static int32_t apiSpriteUnload(lua_State *L) { } +// srtClear() Hypseus extension. Takes whatever is on screen off at once, subtitle or banner, and +// leaves the loaded cues and the enabled state alone: the next cue still shows. +static int32_t apiSrtClear(lua_State *L) { + _argCheck(L, "srtClear", 0, 0); + free(_global.bannerText); + _global.bannerText = NULL; + _global.bannerFrames = 0; + _subtitleShow(NULL, 0); + // Remembering the cue that was showing as the one already dealt with is what keeps it off: + // the next frame finds the same cue and leaves it alone, and the one after it comes up as usual. + _global.subtitleCue = _subtitleCueNow(); + _luaTrace(L, "srtClear", "%d %d", _global.subtitleCue, _global.subtitleCueCount); + + return 0; +} + + +// srtEnable(enabled) Hypseus extension. Whether the loaded cues are shown as the disc plays. +static int32_t apiSrtEnable(lua_State *L) { + _argCheck(L, "srtEnable", 1, 1); + _global.subtitleEnabled = _argBoolean(L, "srtEnable", 1); + if (!_global.subtitleEnabled) { + _global.subtitleCue = SUBTITLE_NONE; + _subtitleShow(NULL, 0); + } + _luaTrace(L, "srtEnable", "%d", _global.subtitleEnabled); + + return 0; +} + + +// loaded = srtLoad(filename) Hypseus extension. Reads a SubRip file and turns its timestamps into +// disc frames at the disc's frame rate. Answers false, having loaded nothing, when the file is +// missing, holds no usable cue, or there is no disc to time it against. Loading replaces whatever +// was loaded before; srtEnable then shows it. +static int32_t apiSrtLoad(lua_State *L) { + const char *name = NULL; + char *text = NULL; + size_t bytes = 0; + bool loaded = false; + + _argCheck(L, "srtLoad", 1, 1); + name = _argString(L, "srtLoad", 1); + text = vfsRead(name, &bytes); + if (text != NULL) { + loaded = _subtitleParse(text); + free(text); + } + if (!loaded) { + _subtitleClearCues(); + _subtitleShow(NULL, 0); + utilSay("Warning: No subtitles were loaded from %s.", name); + } + _luaTrace(L, "srtLoad", "%s %d %d", name, loaded, _global.subtitleCueCount); + lua_pushboolean(L, loaded); + + return 1; +} + + +// srtPosition(percent) Hypseus extension. Where a subtitle sits, as a per cent down the picture, +// 1 to 95. Anything outside that is ignored, as Hypseus ignores it. +static int32_t apiSrtPosition(lua_State *L) { + int32_t position = 0; + + _argCheck(L, "srtPosition", 1, 1); + position = _argInteger(L, "srtPosition", 1); + if ((position >= SUBTITLE_POSITION_MIN) && (position <= SUBTITLE_POSITION_MAX)) { + _global.subtitlePosition = position; + _global.subtitleCue = SUBTITLE_NONE; + } + _luaTrace(L, "srtPosition", "%d %d", position, _global.subtitlePosition); + + return 0; +} + + // height = terrainGetHeight(node, x, z): the terrain's height at a world x, z, or nil off it static int32_t apiTerrainGetHeight(lua_State *L) { float height = 0.0f; @@ -10309,6 +13340,69 @@ static int32_t apiViewSetCamera(lua_State *L) { } +// vldpFlash() Hypseus extension. Puts one white picture on the disc, the way a real player flashes +// when it is searched. The next disc update takes it off again, so it lasts one drawn frame; a +// stopped or paused disc keeps it that long and no longer. +static int32_t apiVldpFlash(lua_State *L) { + _argCheck(L, "vldpFlash", 0, 0); + if (_global.videoHandle >= 0) { + videoFlash(_global.videoHandle); + _global.refreshDisplay = true; + } + _luaTrace(L, "vldpFlash", "%d", _global.videoHandle); + + return 0; +} + + +// vldpFocusArea(x, y, width, height) Hypseus extension. Shows only that part of the disc frame, in disc pixels, stretched into the usual place; with no arguments the whole frame again. +static int32_t apiVldpFocusArea(lua_State *L) { + int32_t n = lua_gettop(L); + double width = 0.0; + double height = 0.0; + double x = 0.0; + double y = 0.0; + double w = 0.0; + double h = 0.0; + + _argCheck(L, "vldpFocusArea", 0, 4); + if (n == 0) { + _global.focused = false; + _global.refreshDisplay = true; + _luaTrace(L, "vldpFocusArea", "reset"); + return 0; + } + if (n != 4) { + _luaDie(L, "vldpFocusArea", "Expected 0 or 4 arguments, got %d.", n); + } + x = _argNumber(L, "vldpFocusArea", 1); + y = _argNumber(L, "vldpFocusArea", 2); + w = _argNumber(L, "vldpFocusArea", 3); + h = _argNumber(L, "vldpFocusArea", 4); + if (_global.videoHandle >= 0) { + // Held inside the frame. + width = videoGetWidth(_global.videoHandle); + height = videoGetHeight(_global.videoHandle); + x = SDL_clamp(x, 0.0, width); + y = SDL_clamp(y, 0.0, height); + w = SDL_clamp(w, 0.0, width - x); + h = SDL_clamp(h, 0.0, height - y); + if ((w <= 0.0) || (h <= 0.0)) { + _luaDie(L, "vldpFocusArea", "The focus area lies outside the disc frame."); + } + _global.focusArea.x = (float)x; + _global.focusArea.y = (float)y; + _global.focusArea.w = (float)w; + _global.focusArea.h = (float)h; + _global.focused = true; + _global.refreshDisplay = true; + } + _luaTrace(L, "vldpFocusArea", "%f %f %f %f", x, y, w, h); + + return 0; +} + + // r, g, b = vldpGetPixel(x, y) Overlay coordinates; reads the current laserdisc frame. static int32_t apiVldpGetPixel(lua_State *L) { int32_t x = 0; @@ -10332,6 +13426,154 @@ static int32_t apiVldpGetPixel(lua_State *L) { } +// degrees = vldpGetRotate() Hypseus extension. The presentation rotation in degrees clockwise (--rotate, 0 when unset). +static int32_t apiVldpGetRotate(lua_State *L) { + _argCheck(L, "vldpGetRotate", 0, 0); + _luaTrace(L, "vldpGetRotate", "%d", _global.videoRotate); + lua_pushinteger(L, _global.videoRotate); + + return 1; +} + + +// scale = vldpGetScale() Hypseus extension. The display scale factor as a percentage (--scalefactor, 100 when unset). +static int32_t apiVldpGetScale(lua_State *L) { + _argCheck(L, "vldpGetScale", 0, 0); + _luaTrace(L, "vldpGetScale", "%d", _global.videoScale); + lua_pushinteger(L, _global.videoScale); + + return 1; +} + + +// y, u, v = vldpGetYUVPixel(x, y) Hypseus extension. Raw 4:2:0 samples of the current disc frame at overlay coordinates; -1, -1, -1 without one. +static int32_t apiVldpGetYUVPixel(lua_State *L) { + int32_t x = 0; + int32_t y = 0; + int32_t luma = -1; + int32_t cb = -1; + int32_t cr = -1; + uint8_t l = 0; + uint8_t u = 0; + uint8_t v = 0; + + _argCheck(L, "vldpGetYUVPixel", 2, 2); + x = (int32_t)(_argNumber(L, "vldpGetYUVPixel", 1) / _global.overlayScaleX); + y = (int32_t)(_argNumber(L, "vldpGetYUVPixel", 2) / _global.overlayScaleY); + if ((_global.videoHandle >= 0) && videoGetYUVPixel(_global.videoHandle, x, y, &l, &u, &v)) { + luma = l; + cb = u; + cr = v; + } + _luaTrace(L, "vldpGetYUVPixel", "%d %d %d %d %d", x, y, luma, cb, cr); + lua_pushinteger(L, luma); + lua_pushinteger(L, cb); + lua_pushinteger(L, cr); + + return 3; +} + + +// vldpResetFocus() Hypseus extension. The whole disc frame again, as vldpFocusArea() with no arguments. +static int32_t apiVldpResetFocus(lua_State *L) { + _argCheck(L, "vldpResetFocus", 0, 0); + _global.focused = false; + _global.refreshDisplay = true; + _luaTrace(L, "vldpResetFocus", "reset"); + + return 0; +} + + +// vldpSetBlend(enabled) Hypseus extension. Smooths the disc picture down its rows, each luma +// sample becoming the average of itself and the samples above and below it, which is Hypseus's own +// three tap filter and takes the hard edges off an interlaced transfer. +static int32_t apiVldpSetBlend(lua_State *L) { + _argCheck(L, "vldpSetBlend", 1, 1); + _global.discBlend = _argBoolean(L, "vldpSetBlend", 1); + _discApplyPicture(); + _global.refreshDisplay = true; + _luaTrace(L, "vldpSetBlend", "%d", _global.discBlend); + + return 0; +} + + +// vldpSetLuma(enabled [, level]) Hypseus extension. Brightens or darkens the disc picture. The +// level runs 0 to 8 with 4, the default, changing nothing: the distance from 4 is added to every +// luma sample in eighths, so 0 halves the brightness and 8 raises it by half. +static int32_t apiVldpSetLuma(lua_State *L) { + _argCheck(L, "vldpSetLuma", 1, 2); + _global.discLumaOn = _argBoolean(L, "vldpSetLuma", 1); + _global.discLumaLevel = (lua_gettop(L) == 2) ? _argInteger(L, "vldpSetLuma", 2) : LUMA_LEVEL_NEUTRAL; + if ((_global.discLumaLevel < 0) || (_global.discLumaLevel > LUMA_LEVEL_MAX)) { + _luaDie(L, "vldpSetLuma", "Luma level must be 0 to %d: %d", LUMA_LEVEL_MAX, _global.discLumaLevel); + } + _discApplyPicture(); + _global.refreshDisplay = true; + _luaTrace(L, "vldpSetLuma", "%d %d", _global.discLumaOn, _global.discLumaLevel); + + return 0; +} + + +// vldpSetMonochrome(enabled) Hypseus extension. Shows the disc in luma only; vldpGetPixel keeps returning colour. +static int32_t apiVldpSetMonochrome(lua_State *L) { + bool enabled = false; + + _argCheck(L, "vldpSetMonochrome", 1, 1); + enabled = _argBoolean(L, "vldpSetMonochrome", 1); + _global.discMonochrome = enabled; + _discApplyPicture(); + _global.refreshDisplay = true; + _luaTrace(L, "vldpSetMonochrome", "%d", enabled); + + return 0; +} + + +// applied = vldpSetRotate(degrees) Hypseus extension. Turns the whole presentation clockwise; only +// 0, 90, 180 and 270 are taken, and anything else answers false rather than dying. +static int32_t apiVldpSetRotate(lua_State *L) { + int32_t degrees = 0; + bool applied = false; + + _argCheck(L, "vldpSetRotate", 1, 1); + degrees = (int32_t)_argNumber(L, "vldpSetRotate", 1); + applied = _setVideoRotate(degrees); + if (applied) { + _computeVideoRect(); + } + _luaTrace(L, "vldpSetRotate", "%d %d", degrees, applied); + lua_pushboolean(L, applied); + + return 1; +} + + +// applied = vldpSetScale(percent) Hypseus extension. Shrinks the picture about its centre, 25 to +// 100 per cent; anything else answers false, as does a second change within 15 milliseconds, which +// is the rate Hypseus limits its own held-key zoom to. +static int32_t apiVldpSetScale(lua_State *L) { + int32_t percent = 0; + uint64_t now = SDL_GetTicks(); + bool applied = false; + + _argCheck(L, "vldpSetScale", 1, 1); + percent = (int32_t)_argNumber(L, "vldpSetScale", 1); + if ((percent >= SCALE_FACTOR_MIN) && (percent <= SCALE_FACTOR_MAX) && (now >= _global.videoScaleClock)) { + _global.videoScale = percent; + _global.videoScaleClock = now + VIDEO_SCALE_THROTTLE_MS; + _computeVideoRect(); + applied = true; + } + _luaTrace(L, "vldpSetScale", "%d %d", percent, applied); + lua_pushboolean(L, applied); + + return 1; +} + + static int32_t apiVldpSetVerbose(lua_State *L) { return _apiUnimplemented(L, "vldpSetVerbose"); } @@ -10504,6 +13746,10 @@ ConfigT *confFromGamesDat(const ConfigT *conf) { if (conf->given & GIVEN_AUDIO_DELAY) { result->audioDelayMs = conf->audioDelayMs; } + if (conf->given & GIVEN_AUDIO_SUFFIX) { + free(result->audioSuffix); + result->audioSuffix = (conf->audioSuffix != NULL) ? strdup(conf->audioSuffix) : NULL; + } if (conf->given & GIVEN_CANVAS) { result->canvasWidth = conf->canvasWidth; result->canvasHeight = conf->canvasHeight; @@ -10517,6 +13763,44 @@ ConfigT *confFromGamesDat(const ConfigT *conf) { } +// Releases what settingsLoad returned. +void settingsFree(SettingT *settings, int32_t count) { + int32_t x = 0; + + for (x = 0; x < count; x++) { + free(settings[x].key); + free(settings[x].value); + free(settings[x].source); + } + free(settings); +} + + +// Reads settings.cfg from the four places controls.cfg comes from, in the same order and each +// place only once, and hands back every key the files set with the file that set it. A later file +// replaces an earlier one key by key, exactly as the control mappings merge. Values come back as +// the strings the command line would have carried, so the caller needs no second parser. +SettingT *settingsLoad(const ConfigT *conf, int32_t *count) { + SettingT *list = NULL; + char *places[SETTINGS_PLACES]; + int32_t c = 0; + + *count = 0; + _configPlaces(SETTINGS_FILE, conf->dataDir, conf->scriptFile, places); + for (c = 0; c < SETTINGS_PLACES; c++) { + if (places[c] != NULL) { + _settingsReadFile(places[c], &list, count); + } + free(places[c]); + } + if (*count > 1) { + qsort(list, (size_t)*count, sizeof(SettingT), _settingsCompare); + } + + return list; +} + + void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf) { int32_t x = 0; int32_t y = 0; @@ -10531,16 +13815,14 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co int64_t thisFrame = -1; int64_t lastFrame = -1; uint64_t frameClock = 0; - SDL_FRect windowTarget; - SDL_FRect scaledTarget; - SDL_FRect sindenWhite; - SDL_FRect sindenBlack; + SDL_FRect rotateTarget; + SDL_FRect screenRect; SDL_Texture *sceneTexture = NULL; - SDL_Color sindenWhiteColor = { COLOR_BYTE_MAX, COLOR_BYTE_MAX, COLOR_BYTE_MAX, SDL_ALPHA_OPAQUE }; - SDL_Color sindenBlackColor = { 0, 0, 0, SDL_ALPHA_OPAQUE }; - int32_t sindenWhiteWidth = 0; - int32_t sindenBlackWidth = 0; - bool sindenBorder = false; + SDL_Surface *overlayShown = NULL; + int32_t presentWidth = 0; + int32_t presentHeight = 0; + float deltaX = 0; + float deltaY = 0; double mouseFraction = 0; SDL_Event event; ManyMouseEvent mouseEvent; @@ -10554,6 +13836,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _global.videoHandle = -1; _global.mouseMode = MOUSE_SINGLE; _global.controllerDeadZone = CONTROLLER_DEAD_ZONE_DEFAULT; + _global.fullScreen = conf->fullScreen || conf->fullScreenWindow; _global.running = true; _global.discStopped = true; _global.mouseEnabled = true; @@ -10618,95 +13901,80 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co SDL_RenderClear(_global.renderer); } - // Everything renders in video coordinates, letterboxed unless the user wants it stretched, and - // either way mouse positions convert back into those coordinates. - SDL_SetRenderLogicalPresentation(_global.renderer, videoWidth, videoHeight, _global.conf->stretchVideo ? SDL_LOGICAL_PRESENTATION_STRETCH : SDL_LOGICAL_PRESENTATION_LETTERBOX); - - // Default render location is the entire window - windowTarget.x = 0; - windowTarget.y = 0; - windowTarget.w = videoWidth; - windowTarget.h = videoHeight; + // The picture's position, size and rotation start from the command line; a script may move + // them later through vldpSetScale and vldpSetRotate. + _global.videoScale = _global.conf->scaleFactor; + _global.videoShiftX = _global.conf->shiftX; + _global.videoShiftY = _global.conf->shiftY; // Sinden Light Gun Border Setup: the black border (if any) is the outermost ring, the white - // border sits inside it, and the game is drawn inside both. Widths are in video pixels. + // border sits inside it, and the game is drawn inside both. Widths are in video pixels; + // _computeVideoRect() places the rings, so a runtime change moves them too. if (_global.conf->sindenArgc > 0) { - sindenBorder = true; + _global.sindenBorder = true; + _global.sindenWhiteColor.r = COLOR_BYTE_MAX; + _global.sindenWhiteColor.g = COLOR_BYTE_MAX; + _global.sindenWhiteColor.b = COLOR_BYTE_MAX; + _global.sindenWhiteColor.a = SDL_ALPHA_OPAQUE; + _global.sindenBlackColor.a = SDL_ALPHA_OPAQUE; switch (_global.conf->sindenArgc) { // WW - Just the width of the white border case SINDEN_WHITE: - sindenWhiteWidth = _global.conf->sindenArgv[0]; + _global.sindenWhiteWidth = _global.conf->sindenArgv[0]; break; // WW WB - Width of white border and then black border case SINDEN_WHITE_BLACK: - sindenWhiteWidth = _global.conf->sindenArgv[0]; - sindenBlackWidth = _global.conf->sindenArgv[1]; + _global.sindenWhiteWidth = _global.conf->sindenArgv[0]; + _global.sindenBlackWidth = _global.conf->sindenArgv[1]; break; // RW GW BW WW - Custom color "white" border and width case SINDEN_CUSTOM_WHITE: - sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; - sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; - sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; - sindenWhiteWidth = _global.conf->sindenArgv[3]; + _global.sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; + _global.sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; + _global.sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; + _global.sindenWhiteWidth = _global.conf->sindenArgv[3]; break; // RW GW BW WW WB - Custom color "white" border and width then width of black border case SINDEN_CUSTOM_WHITE_BLACK: - sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; - sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; - sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; - sindenWhiteWidth = _global.conf->sindenArgv[3]; - sindenBlackWidth = _global.conf->sindenArgv[4]; + _global.sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; + _global.sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; + _global.sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; + _global.sindenWhiteWidth = _global.conf->sindenArgv[3]; + _global.sindenBlackWidth = _global.conf->sindenArgv[4]; break; // RW GW BW WW RB GB BB WB - Custom color "white" border and width then custom color "black" border and width case SINDEN_CUSTOM_WHITE_CUSTOM_BLACK: - sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; - sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; - sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; - sindenWhiteWidth = _global.conf->sindenArgv[3]; - sindenBlackColor.r = (uint8_t)_global.conf->sindenArgv[4]; - sindenBlackColor.g = (uint8_t)_global.conf->sindenArgv[5]; - sindenBlackColor.b = (uint8_t)_global.conf->sindenArgv[6]; - sindenBlackWidth = _global.conf->sindenArgv[7]; + _global.sindenWhiteColor.r = (uint8_t)_global.conf->sindenArgv[0]; + _global.sindenWhiteColor.g = (uint8_t)_global.conf->sindenArgv[1]; + _global.sindenWhiteColor.b = (uint8_t)_global.conf->sindenArgv[2]; + _global.sindenWhiteWidth = _global.conf->sindenArgv[3]; + _global.sindenBlackColor.r = (uint8_t)_global.conf->sindenArgv[4]; + _global.sindenBlackColor.g = (uint8_t)_global.conf->sindenArgv[5]; + _global.sindenBlackColor.b = (uint8_t)_global.conf->sindenArgv[6]; + _global.sindenBlackWidth = _global.conf->sindenArgv[7]; break; default: utilDie("Bad Sinden argument count: %d", _global.conf->sindenArgc); } - if ((sindenWhiteWidth < 0) || (sindenBlackWidth < 0) || (2 * (sindenWhiteWidth + sindenBlackWidth) >= SDL_min(videoWidth, videoHeight))) { - utilDie("Sinden border of %d + %d pixels does not fit a %dx%d video", sindenWhiteWidth, sindenBlackWidth, videoWidth, videoHeight); + if ((_global.sindenWhiteWidth < 0) || (_global.sindenBlackWidth < 0) || (2 * (_global.sindenWhiteWidth + _global.sindenBlackWidth) >= SDL_min(videoWidth, videoHeight))) { + utilDie("Sinden border of %d + %d pixels does not fit a %dx%d video", _global.sindenWhiteWidth, _global.sindenBlackWidth, videoWidth, videoHeight); } - // The black ring is the whole frame, the white ring everything inside it, the game inside both. - sindenBlack.x = 0; - sindenBlack.y = 0; - sindenBlack.w = videoWidth; - sindenBlack.h = videoHeight; - sindenWhite.x = sindenBlackWidth; - sindenWhite.y = sindenBlackWidth; - sindenWhite.w = videoWidth - (sindenBlackWidth * 2); - sindenWhite.h = videoHeight - (sindenBlackWidth * 2); - windowTarget.x = sindenBlackWidth + sindenWhiteWidth; - windowTarget.y = sindenBlackWidth + sindenWhiteWidth; - windowTarget.w = videoWidth - (windowTarget.x * 2); - windowTarget.h = videoHeight - (windowTarget.y * 2); } - // Overscan compensation shrinks whatever the game is drawn into (the whole window, or the inside - // of the Sinden border) about its centre. - if (_global.conf->scaleFactor < SCALE_FACTOR_MAX) { - scaledTarget.w = windowTarget.w * (float)_global.conf->scaleFactor / (float)SCALE_FACTOR_MAX; - scaledTarget.h = windowTarget.h * (float)_global.conf->scaleFactor / (float)SCALE_FACTOR_MAX; - scaledTarget.x = windowTarget.x + (windowTarget.w - scaledTarget.w) / 2.0f; - scaledTarget.y = windowTarget.y + (windowTarget.h - scaledTarget.h) / 2.0f; - windowTarget = scaledTarget; - } - // Mouse positions arrive in video coordinates and are mapped into the drawn rectangle. - _global.drawTarget = windowTarget; - _global.drawScaleX = (double)videoWidth / (double)windowTarget.w; - _global.drawScaleY = (double)videoHeight / (double)windowTarget.h; + // The bezel artwork is an input to the rectangle (its cutout is where the picture goes) and to + // where the Sinden border sits, so it is loaded before either is worked out. + _bezelLoad(); + + // Everything renders in video coordinates, letterboxed unless the user wants it stretched, and + // either way mouse positions convert back into those coordinates. A quarter turn swaps the + // logical size, so _setVideoRotate sets the presentation for every rotation including none. + _setVideoRotate(_global.conf->rotate); + _computeVideoRect(); // Create overlay surface and its texture x = (int32_t)(videoWidth * OVERLAY_SCALE_DEFAULT); @@ -10736,13 +14004,23 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _global.mice[x].y = videoHeight / 2; _progTrace("Mouse %d: %s", x, _global.mice[x].name); } + if (_global.conf->manyMouse) { + _progTrace("--manymouse: the mice are told apart whatever the game asks for"); + } + if (_global.conf->absolutesOnly) { + _progTrace("--absolutes_only: only a device that has reported an absolute position is heard"); + } + + // Extra controller mappings, before any pad is opened: what a user supplies beats SDL's own + // database, and both beat the mapping Singe writes for a device nobody has ever mapped. + _loadGamepadDatabase(); // Controllers are started by the event loop only for the first script in // the queue - so kick 'em here to be sure they're going. _startControllers(); // Sound effect tracks: a fixed pool so scripts keep getting small channel numbers. - _global.effectsVolume = _defaultEffectsVolume(); + _global.effectsVolume = _defaultVolume(AUDIO_MAX_VOLUME); _progTrace("Setting up sound effects mixer"); for (x = 0; x < EFFECT_TRACKS; x++) { _effectTracks[x] = MIX_CreateTrack(videoGetMixer()); @@ -10750,11 +14028,16 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co utilDie("%s", SDL_GetError()); } MIX_TagTrack(_effectTracks[x], EFFECT_TAG); - MIX_SetTrackGain(_effectTracks[x], _mixerGain(_global.effectsVolume)); + MIX_SetTrackGain(_effectTracks[x], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); // Let us know when sounds end MIX_SetTrackStoppedCallback(_effectTracks[x], _effectStopped, (void *)(intptr_t)x); } + // Nothing is heard while --nosound is given, and nothing until the first input while + // --startsilent is: one gain over the whole mixer, so no configured volume is disturbed. + _setAudioMuted(_global.conf->noSound || _global.conf->startSilent); + _global.idleClock = SDL_GetTicks(); + // The script's own defaults (the disc parked on frame 1 among them), now that everything they touch exists. _resetScriptState(); @@ -10783,6 +14066,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // SDL Event Loop while (SDL_PollEvent(&event)) { + if (_isInputEvent(event.type)) { + _noteInput(); + } switch (event.type) { case SDL_EVENT_GAMEPAD_AXIS_MOTION: slot = _controllerSlot(event.gaxis.which); @@ -10793,7 +14079,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Each axis direction is a "key" so it can be mapped in controls.cfg. code = CODE_GAMEPAD_BASE + slot * CODE_GAMEPAD_STRIDE + event.gaxis.axis * CODE_AXIS_STRIDE; code += (event.gaxis.value < 0) ? CODE_AXIS_NEGATIVE : CODE_AXIS_POSITIVE; - if (abs(event.gaxis.value) > _global.controllerDeadZone) { + if (abs(event.gaxis.value) > _global.axisDeadZone[axisIndex]) { if (_global.axisCode[axisIndex] != code) { _releaseAxis(axisIndex); _processKey(true, 0, code); @@ -10832,6 +14118,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co case SDL_EVENT_GAMEPAD_ADDED: case SDL_EVENT_GAMEPAD_REMOVED: + case SDL_EVENT_JOYSTICK_ADDED: + case SDL_EVENT_JOYSTICK_REMOVED: + // A joystick arriving matters as much as a gamepad arriving: a device SDL does + // not recognise is only ever reported as a joystick, and it is here that it is + // given a mapping of its own and becomes a pad. _startControllers(); break; @@ -10853,6 +14144,15 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co break; } } + // Alt-Enter is the engine's, as it is in Hypseus: the first hotkey a user reaches + // for, and the game never sees it. + if ((event.type == SDL_EVENT_KEY_DOWN) && ((event.key.mod & SDL_KMOD_ALT) != 0) && ((event.key.scancode == SDL_SCANCODE_RETURN) || (event.key.scancode == SDL_SCANCODE_KP_ENTER))) { + // Swallow the release too, through the same list that swallows a key held + // over from before the script, so the game never sees half a keystroke. + _global.keySuppressed[event.key.scancode] = true; + _toggleFullScreen(); + break; + } // A GUI with a focused element takes the key before the game does. if (!_guiKey(event.key.key, event.key.mod, event.type == SDL_EVENT_KEY_DOWN)) { _processKey(event.type == SDL_EVENT_KEY_DOWN, event.key.key, event.key.scancode); @@ -10871,12 +14171,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co case SDL_EVENT_MOUSE_MOTION: if (_global.mouseEnabled && (_global.mouseMode == MOUSE_SINGLE)) { // Positions arrive in window pixels; the game works in the overlay's coordinates, - // drawn into whatever the Sinden border or overscan left of the video area. + // drawn into whatever the rotation, shift, scale and Sinden border left of it. SDL_ConvertEventToRenderCoordinates(_global.renderer, &event); - x = (int32_t)((event.motion.x - _global.drawTarget.x) * _global.drawScaleX * _global.overlayScaleX); - y = (int32_t)((event.motion.y - _global.drawTarget.y) * _global.drawScaleY * _global.overlayScaleY); - xr = (int32_t)(event.motion.xrel * _global.drawScaleX * _global.overlayScaleX); - yr = (int32_t)(event.motion.yrel * _global.drawScaleY * _global.overlayScaleY); + _mapPointer(event.motion.x, event.motion.y, &x, &y); + _mapPointerDelta(event.motion.xrel, event.motion.yrel, &xr, &yr); _guiMouseMove(x, y); _fireMouseMoved(0, x, y, xr, yr); } @@ -10900,6 +14198,14 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co } break; + case SDL_EVENT_MOUSE_REMOVED: + // A gun unplugged. Hypseus's SWITCH_MOUSE_DISCONNECT, so a gun game can say so + // on screen instead of appearing to have died. + _progTrace("A mouse was disconnected"); + _deliverSwitch(INPUT_MOUSE_DISCONNECT, true); + _deliverSwitch(INPUT_MOUSE_DISCONNECT, false); + break; + case SDL_EVENT_QUIT: _progTrace("Quit requested"); _global.running = false; @@ -10912,23 +14218,32 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Mouse Event Loop - drained even when unused so the queue never fills. while (ManyMouse_PollEvent(&mouseEvent)) { + _noteInput(); if (!_global.mouseEnabled || (_global.mouseMode != MOUSE_MANY) || (mouseEvent.device >= (unsigned)_global.mouseCount)) { continue; } mouse = &_global.mice[mouseEvent.device]; + if (mouseEvent.type == MANYMOUSE_EVENT_ABSMOTION) { + _global.mouseAbsoluteSeen[mouseEvent.device] = true; + } + // --absolutes_only keeps the light guns and drops the ordinary mice. ManyMouse has no + // way to ask a device what it is, so a device counts as absolute once it has reported an + // absolute position and not before; a real gun does that with its first movement. + if (_global.conf->absolutesOnly && !_global.mouseAbsoluteSeen[mouseEvent.device]) { + continue; + } switch (mouseEvent.type) { case MANYMOUSE_EVENT_RELMOTION: - // Integrate the motion into an absolute position clamped to the video. - xr = 0; - yr = 0; - if (mouseEvent.item == 0) { - xr = mouseEvent.value; - mouse->x += xr; - } else { - yr = mouseEvent.value; - mouse->y += yr; - } + // Integrate the motion into an absolute position clamped to the video. A rotated + // presentation turns the axes, so the cursor follows the hand on the screen shown. + deltaX = (mouseEvent.item == 0) ? (float)mouseEvent.value : 0.0f; + deltaY = (mouseEvent.item == 0) ? 0.0f : (float)mouseEvent.value; + _unrotateDelta(deltaX, deltaY, &deltaX, &deltaY); + xr = (int32_t)deltaX; + yr = (int32_t)deltaY; + mouse->x += xr; + mouse->y += yr; if (mouse->x < 0) { mouse->x = 0; } @@ -10950,16 +14265,20 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co case MANYMOUSE_EVENT_ABSMOTION: // Absolute devices (tablets, some guns) report a position within a range that spans - // the video; the game sits in whatever the Sinden border or overscan left of it. + // the screen, one axis at a time; the two are remembered so a rotated presentation + // can be turned back as a pair, and the game then sits in whatever the shift, the + // scale factor and the Sinden border left of the picture. if (mouseEvent.maxval > mouseEvent.minval) { + _presentSize(&presentWidth, &presentHeight); mouseFraction = (double)(mouseEvent.value - mouseEvent.minval) / (double)(mouseEvent.maxval - mouseEvent.minval); if (mouseEvent.item == 0) { - mouse->x = (int32_t)((mouseFraction * videoWidth - _global.drawTarget.x) * _global.drawScaleX); + mouse->absX = (float)(mouseFraction * presentWidth); } else { - mouse->y = (int32_t)((mouseFraction * videoHeight - _global.drawTarget.y) * _global.drawScaleY); + mouse->absY = (float)(mouseFraction * presentHeight); } - x = (int32_t)(mouse->x * _global.overlayScaleX); - y = (int32_t)(mouse->y * _global.overlayScaleY); + _mapPointer(mouse->absX, mouse->absY, &x, &y); + mouse->x = (int32_t)(x / _global.overlayScaleX); + mouse->y = (int32_t)(y / _global.overlayScaleY); _fireMouseMoved((int32_t)mouseEvent.device, x, y, 0, 0); } break; @@ -10980,11 +14299,27 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co } break; + case MANYMOUSE_EVENT_DISCONNECT: + // A gun unplugged, told apart by device here, which the single mouse path cannot do. + _progTrace("Mouse %u was disconnected", mouseEvent.device); + _deliverSwitch(INPUT_MOUSE_DISCONNECT, true); + _deliverSwitch(INPUT_MOUSE_DISCONNECT, false); + break; + default: break; } } + // --joymouse: the stick has had a frame to move the cursor in. + _joyMouseUpdate(); + + // --idleexit: nothing has been touched for that long, so an attract cabinet lets go. + if ((_global.conf->idleExitSeconds > 0) && ((SDL_GetTicks() - _global.idleClock) >= ((uint64_t)_global.conf->idleExitSeconds * MS_PER_SECOND))) { + _progTrace("Idle for %d seconds; quitting", _global.conf->idleExitSeconds); + _global.running = false; + } + // Deliver sound completions on this thread. They wait out an engine pause. if (!_global.frozen) { finishedCount = _soundQueueDrain(finished); @@ -10998,6 +14333,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co thisFrame = videoUpdate(_global.videoHandle, &_global.videoTexture); if (_global.conf->isFrameFile) { frameFileUpdate(_global.frameFileHandle, &_global.videoHandle); + // A frame file crosses into another player as it plays, and the new one knows + // nothing of vldpSetMonochrome, vldpSetBlend or vldpSetLuma until it is told. + _discApplyPicture(); } // Did we get a new video frame? if (thisFrame != lastFrame) { @@ -11022,26 +14360,30 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Update display if (_global.refreshDisplay || _global.overlayDirty || sceneIsEnabled()) { + // A rotated presentation draws the whole frame into a texture first and turns it at the end. + if (_global.rotateTexture != NULL) { + SDL_SetRenderTarget(_global.renderer, _global.rotateTexture); + } // Clear entire display to black SDL_SetRenderDrawColor(_global.renderer, 0, 0, 0, SDL_ALPHA_OPAQUE); SDL_RenderClear(_global.renderer); - // Sinden Gun Border, outer ring first; the game then covers the middle - if (sindenBorder) { - if (sindenBlackWidth > 0) { - SDL_SetRenderDrawColor(_global.renderer, sindenBlackColor.r, sindenBlackColor.g, sindenBlackColor.b, sindenBlackColor.a); - SDL_RenderFillRect(_global.renderer, &sindenBlack); - } - SDL_SetRenderDrawColor(_global.renderer, sindenWhiteColor.r, sindenWhiteColor.g, sindenWhiteColor.b, sindenWhiteColor.a); - SDL_RenderFillRect(_global.renderer, &sindenWhite); + // Bezel artwork behind everything, unless --bezelflip put it in front + if ((_global.bezelTexture != NULL) && !_global.bezelInFront) { + _bezelDraw(); + } + // Sinden Gun Border, outer ring first; the game then covers the middle. One at the + // window's edge is drawn at the end of the frame instead, over the bezel artwork. + if (_global.sindenBorder && !_global.sindenAtWindow) { + _drawSindenBorder(); } // Laserdisc Video. Games without a disc draw on black. if (_global.videoHandle >= 0) { if (_global.discStopped) { // Stopped discs display blue like the good old days SDL_SetRenderDrawColor(_global.renderer, 0, 0, BLUE_SCREEN_BLUE, SDL_ALPHA_OPAQUE); - SDL_RenderFillRect(_global.renderer, &windowTarget); + SDL_RenderFillRect(_global.renderer, &_global.videoRect); } else { - SDL_RenderTexture(_global.renderer, _global.videoTexture, NULL, &windowTarget); + SDL_RenderTexture(_global.renderer, _global.videoTexture, _global.focused ? &_global.focusArea : NULL, &_global.videoRect); } } // 3D scene @@ -11051,26 +14393,60 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co navUpdate(!_global.frozen); _navCallbacks(); particlesUpdate(!_global.frozen); + // The subtitle or the banner is settled before the GUIs render, so a change lands this frame. + _subtitleUpdate(); guiUpdate((double)SDL_GetTicks() / MS_PER_SECOND_NUMBER); _guiTextInput(); sceneUpdateVideo(_sceneVideoSource); sceneTexture = sceneRender(); _updateSounds(); if (sceneTexture != NULL) { - SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &windowTarget); + SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &_global.videoRect); } // 2D particles beneath the overlay, then the overlay, then the ones above it - _drawParticles2D(PARTICLE_UNDER, &windowTarget); + _drawParticles2D(PARTICLE_UNDER, &_global.videoRect); if (_global.overlayDirty) { - SDL_UpdateTexture(_global.overlayTexture, NULL, _global.overlay->pixels, _global.overlay->pitch); + overlayShown = _global.overlayMonochrome ? _overlayGrey() : _global.overlay; + SDL_UpdateTexture(_global.overlayTexture, NULL, overlayShown->pixels, overlayShown->pitch); _global.overlayDirty = false; } - SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &windowTarget); - _drawGuis(&windowTarget); - _drawParticles2D(PARTICLE_OVER, &windowTarget); + // Artwork in front of the picture goes over it here when the overlay is to stay on + // top of the artwork, and after the overlay and the GUIs when it is not. + if ((_global.bezelTexture != NULL) && _global.bezelInFront && _global.overlayOnTop) { + _bezelDraw(); + } + SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &_global.videoRect); + _subtitleQueue(); + _drawGuis(&_global.videoRect, false); + _drawParticles2D(PARTICLE_OVER, &_global.videoRect); particlesClearQueue2D(); + if ((_global.bezelTexture != NULL) && _global.bezelInFront && !_global.overlayOnTop) { + _bezelDraw(); + } + // Screen space GUIs and the score panel are on the artwork, so they come after it. + _logicalRect(&screenRect); + _scorePanelQueue(); + _drawGuis(&screenRect, true); + _guisShown(); + if (_global.sindenBorder && _global.sindenAtWindow) { + _drawSindenBorder(); + } if (_global.frozen) { - _drawPauseIndicator(&windowTarget); + _drawPauseIndicator(&_global.videoRect); + } + // The finished frame, turned into the window. The destination is the unrotated + // rectangle centred in the presentation; a quarter turn about its centre lands it + // exactly on the swapped logical rectangle. + if (_global.rotateTexture != NULL) { + _presentSize(&presentWidth, &presentHeight); + rotateTarget.x = ((float)presentWidth - (float)videoWidth) / 2.0f; + rotateTarget.y = ((float)presentHeight - (float)videoHeight) / 2.0f; + rotateTarget.w = (float)videoWidth; + rotateTarget.h = (float)videoHeight; + SDL_SetRenderTarget(_global.renderer, NULL); + SDL_SetRenderDrawColor(_global.renderer, 0, 0, 0, SDL_ALPHA_OPAQUE); + SDL_RenderClear(_global.renderer); + SDL_RenderTextureRotated(_global.renderer, _global.rotateTexture, NULL, &rotateTarget, (double)_global.videoRotate, NULL, SDL_FLIP_NONE); } // Save it? if (_global.requestScreenShot) { @@ -11112,10 +14488,17 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Free overlay & overlay font _progTrace("Destroying overlay"); + SDL_DestroyTexture(_global.rotateTexture); + _global.rotateTexture = NULL; + SDL_DestroyTexture(_global.bezelTexture); + _global.bezelTexture = NULL; + free(_global.bezelHash); + free(_global.scoreTwinMarkup); SDL_DestroyTexture(_global.pauseTexture); _subsystemsQuit(); SDL_DestroyTexture(_global.overlayTexture); SDL_DestroySurface(_global.overlay); + SDL_DestroySurface(_global.overlayGrey); _progTrace("Destroying console font"); SDL_DestroySurface(_global.consoleFontSurface); @@ -11148,6 +14531,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co free(_global.watched[x].name); } free(_global.watched); + free(_global.discAudioSuffix); SDL_free(_global.navDrawVertices); SDL_free(_global.quadVertices); SDL_free(_global.quadIndices); diff --git a/src/singe.h b/src/singe.h index d10025c47..450407e3f 100644 --- a/src/singe.h +++ b/src/singe.h @@ -41,6 +41,14 @@ typedef enum ToolModeE { TOOL_UNPACK } 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. typedef enum SindenModeE { SINDEN_WHITE = 1, @@ -53,14 +61,15 @@ typedef enum SindenModeE { // Options typed on the command line: a games.dat entry found beside a loose script leaves these alone. typedef enum GivenE { - GIVEN_VIDEO = 1 << 0, // -v or -D - GIVEN_STRETCH = 1 << 1, - GIVEN_NO_MOUSE = 1 << 2, - GIVEN_RESOLUTION = 1 << 3, // -x or -y - GIVEN_SINDEN = 1 << 4, - GIVEN_AUDIO_TRACK = 1 << 5, - GIVEN_AUDIO_DELAY = 1 << 6, - GIVEN_CANVAS = 1 << 7 + GIVEN_VIDEO = 1 << 0, // -v or -D + GIVEN_STRETCH = 1 << 1, + GIVEN_NO_MOUSE = 1 << 2, + GIVEN_RESOLUTION = 1 << 3, // -x or -y + GIVEN_SINDEN = 1 << 4, + GIVEN_AUDIO_TRACK = 1 << 5, + GIVEN_AUDIO_DELAY = 1 << 6, + GIVEN_CANVAS = 1 << 7, + GIVEN_AUDIO_SUFFIX = 1 << 8 } GivenE; typedef struct ConfigS { @@ -72,8 +81,13 @@ typedef struct ConfigS { ToolModeE toolMode; uint32_t given; // GivenE bits 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 *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 isFrameFile; bool stretchVideo; @@ -89,26 +103,57 @@ typedef struct ConfigS { bool reload; // --reload: watch the loose script files and rerun the game when one changes; F5 too bool scriptTracing; 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 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 volumeVldp; 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 yResolution; int32_t canvasWidth; // World size when there is no disc int32_t canvasHeight; + int32_t sindenEdge; // --sindenedge: SindenEdgeE, where the border rings are drawn int32_t sindenArgc; int32_t sindenArgv[SINDEN_ARG_MAX]; int32_t audioOutputTrack; 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 *confFromDatabase(const ConfigT *conf); -ConfigT *confFromGamesDat(const ConfigT *conf); -void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf); +// 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 *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); #endif // SINGE_H diff --git a/src/videoPlayer.c b/src/videoPlayer.c index fd3f32c67..871683818 100644 --- a/src/videoPlayer.c +++ b/src/videoPlayer.c @@ -87,12 +87,31 @@ typedef struct iso639_lang_t iso639_lang_t; #define PERCENT_MAX 100 #define PERCENT_TO_SCALE 0.01f #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 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 // 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 { 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 @@ -131,9 +150,17 @@ typedef struct AudioTrackS { #pragma GCC diagnostic ignored "-Wpadded" typedef struct VideoPlayerS { int32_t id; + char *filename; // The video file, for reopening its own audio bool playing; bool resetTime; 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 height; int32_t volumeLeft; @@ -160,6 +187,7 @@ typedef struct VideoPlayerS { bool videoDrained; bool packetPending; // videoPacket holds data the decoder refused (EAGAIN) 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 // 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 _alsaSetQuiet(bool quiet); static int64_t _audioClock(VideoPlayerT *v, uint64_t now); +static void _audioClose(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 _audioSeek(VideoPlayerT *v, int64_t ms); static void _audioSelectTrack(VideoPlayerT *v, int32_t track); static void _audioSetupResampler(VideoPlayerT *v); +static void _audioStartTrack(VideoPlayerT *v, int32_t track); static int _avioRead(void *opaque, uint8_t *buffer, int size); static int64_t _avioSeek(void *opaque, int64_t offset, int whence); 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 VideoPlayerT *_getPlayer(int32_t playerHandle, const char *caller); 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 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 _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 void _reportKeyframes(VideoPlayerT *v, const char *filename); static void _requestFrame(VideoPlayerT *v); @@ -244,6 +278,7 @@ static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase); static bool _takeDecodedFrame(VideoPlayerT *v); static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static void _uploadFrame(VideoPlayerT *v); +static const AVCodec *_v4l2Decoder(const AVCodec *decoder); 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) { avcodec_free_context(&v->audioCodec); 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 // before the seek target. 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) { 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); } 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); } 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 -// lists its tracks. The track table is sized for every stream; only the audio ones fill it. -static void _loadAudio(VideoPlayerT *v, const char *filename) { - AVDictionaryEntry *tag = NULL; - int32_t x = 0; - - v->audioFormat = _formatOpen(filename); - if (v->audioFormat == NULL) { - 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++; +// The scratch luma plane the blend, the luma level and the flash write into, allocated the first +// time one of them is asked for. It is the size of a decoded luma plane, stride and all. +static uint8_t *_lumaPlane(VideoPlayerT *v) { + if (v->lumaPlane == NULL) { + v->lumaBytes = (size_t)v->front.linesize[0] * (size_t)v->height; + v->lumaPlane = malloc(v->lumaBytes); + if (!v->lumaPlane) { + utilDie("Unable to allocate the luma plane."); } } - if (v->audioSourceCount == 0) { - free(v->audio); - v->audio = NULL; - _formatClose(&v->audioFormat); - return; - } - v->audioPacket = av_packet_alloc(); - v->audioFrame = av_frame_alloc(); - if (!v->audioPacket || !v->audioFrame) { - utilDie("Unable to allocate audio decoding buffers."); - } + return v->lumaPlane; } @@ -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. // Failure of any step leaves the context decoding in software. 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. static void _openVideo(VideoPlayerT *v, const char *filename) { const AVCodec *decoder = NULL; + const AVCodec *v4l2 = NULL; AVStream *stream = NULL; int32_t x = 0; @@ -1080,14 +1229,17 @@ static void _openVideo(VideoPlayerT *v, const char *filename) { v->fps.den = DEFAULT_FPS_DENOMINATOR; } - 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 for %s.", filename); - } - v->videoCodec->thread_count = 0; // Let libavcodec pick - _openHardware(v, decoder); - if (avcodec_open2(v->videoCodec, decoder, NULL) < 0) { - utilDie("Unable to open the video decoder for %s.", filename); + v4l2 = _v4l2Decoder(decoder); + if ((v4l2 != NULL) && _openDecoder(v, v4l2, stream)) { + 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); + } + if (!_openDecoder(v, decoder, stream)) { + utilDie("Unable to open the video decoder for %s.", filename); + } } v->videoPacket = av_packet_alloc(); v->videoFrame = av_frame_alloc(); @@ -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. static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *expected) { 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) { + 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) { SDL_UpdateTexture(v->videoTexture, NULL, v->front.data[0], v->front.linesize[0]); } 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; } +// 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) { 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) { 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) { 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. 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"); @@ -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) { uint64_t started = 0; @@ -1487,14 +1784,18 @@ bool videoIsPlaying(int32_t playerHandle) { // audioFilename may be NULL when the audio lives in the video file. rgb players decode to BGRA so // 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) { - VideoPlayerT *v = NULL; - SDL_PropertiesID playProps = 0; + VideoPlayerT *v = NULL; + const char *name = audioFilename ? audioFilename : videoFilename; // Create new videoPlayer v = calloc(1, sizeof(VideoPlayerT)); if (!v) { 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) v->currentAudioTrack = -1; @@ -1534,34 +1835,21 @@ int32_t videoLoad(const char *videoFilename, const char *audioFilename, const ch 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 v->id = _nextId++; 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; } @@ -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) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSeek"); 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. void videoSetHardwareDecoding(bool enabled) { _hardwareDecoding = enabled; } +// Brightens or darkens the picture. The level runs 0 to LUMA_LEVEL_MAX with LUMA_LEVEL_NEUTRAL +// changing nothing, and the distance from neutral is applied to every luma sample in eighths, so 0 +// halves the brightness and 8 raises it by half. Turning it off, or asking for the neutral level, +// 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) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSetVolume"); MIX_StereoGains gains; @@ -1688,21 +2056,11 @@ void videoUnload(int32_t playerHandle) { av_buffer_unref(&v->hwDevice); _formatClose(&v->videoFormat); free(v->frames); + free(v->filename); + free(v->neutralChroma); + free(v->lumaPlane); SDL_DestroyTexture(v->videoTexture); - - 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); - } + _audioClose(v); #pragma GCC diagnostic push #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); - if (_takeDecodedFrame(v)) { + if (_takeDecodedFrame(v) || v->flashed) { _uploadFrame(v); } *texture = v->videoTexture; diff --git a/src/videoPlayer.h b/src/videoPlayer.h index 5fff638c3..2818b0f21 100644 --- a/src/videoPlayer.h +++ b/src/videoPlayer.h @@ -33,23 +33,30 @@ #define VIDEO_VOLUME_MAX 100 #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 videoGetAudioDelay(void); int32_t videoGetAudioLatency(void); int32_t videoGetAudioTrack(int32_t playerHandle); int32_t videoGetAudioTracks(int32_t playerHandle); +double videoGetFps(int32_t playerHandle); int64_t videoGetFrame(int32_t playerHandle); int64_t videoGetFrameCount(int32_t playerHandle); int32_t videoGetHeight(int32_t playerHandle); const char *videoGetLanguage(int32_t playerHandle, int32_t audioTrack); const char *videoGetLanguageDescription(const char *languageCode); 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 videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch); void videoGetVolume(int32_t playerHandle, int32_t *leftPercent, int32_t *rightPercent); 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); bool videoIsPlaying(int32_t playerHandle); 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 videoPlay(int32_t playerHandle); void videoQuit(void); +bool videoReopenAudio(int32_t playerHandle, const char *audioFilename); void videoSeek(int32_t playerHandle, int64_t seekFrame); void videoSetAudioCalibration(int32_t milliseconds); void videoSetAudioDelay(int32_t milliseconds); void videoSetAudioTrack(int32_t playerHandle, int32_t track); +void videoSetBlend(int32_t playerHandle, 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 videoUnload(int32_t playerHandle); void videoUnlockAudio(void); diff --git a/testScripts/README.md b/testScripts/README.md index 925bc1134..72b18059f 100644 --- a/testScripts/README.md +++ b/testScripts/README.md @@ -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 | | 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 | +| 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 `` 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 | diff --git a/testScripts/arrows.png b/testScripts/arrows.png new file mode 100644 index 000000000..54bcc6864 --- /dev/null +++ b/testScripts/arrows.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683c610c9c3a19f6df12a5080b684046fa02f0481ff1de2c6994ef2158e64074 +size 600 diff --git a/testScripts/bezels/cabinet.cfg b/testScripts/bezels/cabinet.cfg new file mode 100644 index 000000000..00115b59c --- /dev/null +++ b/testScripts/bezels/cabinet.cfg @@ -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 } diff --git a/testScripts/bezels/cabinet.png b/testScripts/bezels/cabinet.png new file mode 100644 index 000000000..cc8483486 --- /dev/null +++ b/testScripts/bezels/cabinet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20d8fca9feab41dc08577ffb01f55cd2a6337cb69e2f6e0801f941eba02dc517 +size 15495 diff --git a/testScripts/broken.srt b/testScripts/broken.srt new file mode 100644 index 000000000..e87114ae1 --- /dev/null +++ b/testScripts/broken.srt @@ -0,0 +1,3 @@ +This file is not SubRip at all. +It has no timestamps and no cues, +so srtLoad must answer false. diff --git a/testScripts/checker.png b/testScripts/checker.png new file mode 100644 index 000000000..da6d65251 --- /dev/null +++ b/testScripts/checker.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb781c131340b718d427ac044f53ef6828e3f225a7732823fc8fe01cffc3ea96 +size 262 diff --git a/testScripts/games.dat b/testScripts/games.dat index bd2b0c93e..011831d96 100644 --- a/testScripts/games.dat +++ b/testScripts/games.dat @@ -453,4 +453,92 @@ GAMES = { DEVELOPER = "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", + }, } diff --git a/testScripts/scene44.singe b/testScripts/scene44.singe new file mode 100644 index 000000000..6845618ee --- /dev/null +++ b/testScripts/scene44.singe @@ -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 diff --git a/testScripts/scene45.singe b/testScripts/scene45.singe new file mode 100644 index 000000000..0037cc94c --- /dev/null +++ b/testScripts/scene45.singe @@ -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 diff --git a/testScripts/scene46.singe b/testScripts/scene46.singe new file mode 100644 index 000000000..3f7f404d8 --- /dev/null +++ b/testScripts/scene46.singe @@ -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 diff --git a/testScripts/scene47.rml b/testScripts/scene47.rml new file mode 100644 index 000000000..01c7fb887 --- /dev/null +++ b/testScripts/scene47.rml @@ -0,0 +1,24 @@ + + + scene47 + + + + +
+

ON THE
ARTWORK

+
guiDrawScreen
draws me on the
artwork.
+
step
+
1
+
bezel
+
no
+
+ +
diff --git a/testScripts/scene47.singe b/testScripts/scene47.singe new file mode 100644 index 000000000..4113a6673 --- /dev/null +++ b/testScripts/scene47.singe @@ -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 diff --git a/testScripts/scene48.singe b/testScripts/scene48.singe new file mode 100644 index 000000000..9cc77a99f --- /dev/null +++ b/testScripts/scene48.singe @@ -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 diff --git a/testScripts/scene49.singe b/testScripts/scene49.singe new file mode 100644 index 000000000..0ae3b280f --- /dev/null +++ b/testScripts/scene49.singe @@ -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 diff --git a/testScripts/scene50.singe b/testScripts/scene50.singe new file mode 100644 index 000000000..9375c6ac1 --- /dev/null +++ b/testScripts/scene50.singe @@ -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 diff --git a/testScripts/scene51.singe b/testScripts/scene51.singe new file mode 100644 index 000000000..37091c5c1 --- /dev/null +++ b/testScripts/scene51.singe @@ -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 diff --git a/testScripts/strip.png b/testScripts/strip.png new file mode 100644 index 000000000..b76e48495 --- /dev/null +++ b/testScripts/strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0db95bbc1d8fa6a4cd12a98eac1a28b68dd5795bc3c28380f8786906b4636971 +size 1201 diff --git a/testScripts/subtitles.srt b/testScripts/subtitles.srt new file mode 100644 index 000000000..a6cfddcd9 --- /dev/null +++ b/testScripts/subtitles.srt @@ -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 & an ampersand. + +4 +00:00:09,500 --> 00:00:11,500 +The fourth and last cue. diff --git a/testScripts/tone.ogg b/testScripts/tone.ogg new file mode 100644 index 000000000..35477eba7 --- /dev/null +++ b/testScripts/tone.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7b0278214ceafc1df310599c163e2ef4e47f08198502859b721711d16a4e820 +size 20621 diff --git a/testScripts/tune.mp3 b/testScripts/tune.mp3 new file mode 100644 index 000000000..e0153261c --- /dev/null +++ b/testScripts/tune.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b7f564d0511be978ab9a02f4d5027bb15c2274bb94747d1b6678c33371733b2 +size 72769