singe/src/main.c

1340 lines
41 KiB
C

/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <math.h>
#include <sys/stat.h>
#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif
#include <signal.h>
#ifndef _WIN32
#include <execinfo.h>
#endif
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "../thirdparty/arg_parser/carg_parser.h"
#include "../thirdparty/uthash/src/utlist.h"
#include "main.h"
#include "stddclmr.h"
#include "util.h"
#include "frameFile.h"
#include "videoPlayer.h"
#include "singe.h"
#include "pack.h"
#include "vfs.h"
#include "../thirdparty/ffmpeg/libavformat/avformat.h"
#include "embedded.h"
#define MENU_OPTIONS "-k -w -d data -v"
#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_LINE_WIDTH 79 // Help text wraps so no line is wider than this
#define CRASH_FRAMES_MAX 64
typedef struct RatioS {
int32_t aspectNum;
int32_t aspectDom;
} RatioT;
typedef struct ResolutionS {
int32_t width;
int32_t height;
} ResolutionT;
typedef struct ModeS {
RatioT ratio;
ResolutionT resolution;
} ModeT;
typedef struct QueueS {
ConfigT *conf;
struct QueueS *next;
} QueueT;
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.
} OptionT;
typedef struct EmbeddedFileS {
const char *name;
const uint8_t *data;
size_t length;
} EmbeddedFileT;
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 },
{ '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 }
};
#define OPTION_COUNT (sizeof(_options) / sizeof(_options[0]))
// Sorted ascending within each ratio; the resolution search relies on that.
static const ModeT _modes[] = {
{ { 4, 3 }, { 640, 480 } },
{ { 4, 3 }, { 800, 600 } },
{ { 4, 3 }, { 960, 720 } },
{ { 4, 3 }, { 1024, 768 } },
{ { 4, 3 }, { 1280, 960 } },
{ { 4, 3 }, { 1400, 1050 } },
{ { 4, 3 }, { 1440, 1080 } },
{ { 4, 3 }, { 1600, 1200 } },
{ { 4, 3 }, { 1856, 1392 } },
{ { 4, 3 }, { 1920, 1440 } },
{ { 4, 3 }, { 2048, 1536 } },
{ { 16, 9 }, { 1024, 576 } },
{ { 16, 9 }, { 1152, 648 } },
{ { 16, 9 }, { 1280, 720 } },
{ { 16, 9 }, { 1366, 768 } },
{ { 16, 9 }, { 1600, 900 } },
{ { 16, 9 }, { 1920, 1080 } },
{ { 16, 9 }, { 2560, 1440 } },
{ { 16, 9 }, { 3840, 2160 } },
{ { 16, 9 }, { 7680, 4320 } },
{ { 16, 10 }, { 1280, 800 } },
{ { 16, 10 }, { 1440, 900 } },
{ { 16, 10 }, { 1680, 1050 } },
{ { 16, 10 }, { 1920, 1200 } },
{ { 16, 10 }, { 2560, 1600 } },
{ { 0, 0 }, { 0, 0 } }
};
static char *_cloneString(const char *string);
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 ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]);
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 _resolveFiles(const char *exeName, ConfigT *conf);
static bool _runTool(const ConfigT *conf);
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 _unpackData(const char *name);
static char *_cloneString(const char *string) {
if (string == NULL) {
return NULL;
}
return strdup(string);
}
// Last words on a crash: where it happened, so a report can name the line. Async-signal-unsafe
// calls are acceptable here; the process is already lost.
#ifndef _WIN32
static void _crashHandler(int signalNumber) {
void *frames[CRASH_FRAMES_MAX];
int32_t count = backtrace(frames, CRASH_FRAMES_MAX);
fprintf(stderr, "\nSinge crashed (signal %d). Backtrace:\n", signalNumber);
backtrace_symbols_fd(frames, count, STDERR_FILENO);
fprintf(stderr, "Run with --program and send trace.txt with this.\n");
signal(signalNumber, SIG_DFL);
raise(signalNumber);
}
#endif
// 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;
char *existing = NULL;
size_t bytes = 0;
bool written = false;
bool same = false;
bool existed = utilFileExists(filename);
if (existed) {
existing = utilReadFile(filename, &bytes);
same = (existing != NULL) && (bytes == length) && (memcmp(existing, data, length) == 0);
free(existing);
if (same) {
return false;
}
}
_showHeader();
out = fopen(filename, "wb");
if (!out) {
utilDie("Unable to create %s", filename);
}
written = (fwrite(data, 1, length, out) == length);
fclose(out);
if (!written) {
// Never leave a truncated file behind or it will not be recreated.
unlink(filename);
utilDie("Unable to write %s", filename);
}
utilSay(">>> %s File: %s", existed ? "Updated" : "Created", filename);
return true;
}
// Tries every extension libavformat can demux - lower case only, Windows users!
static char *_findVideoFile(const char *baseName) {
const AVInputFormat *format = NULL;
void *opaque = NULL;
char *extensions = NULL;
char *extension = NULL;
char *comma = NULL;
char *candidate = NULL;
while ((format = av_demuxer_iterate(&opaque)) != NULL) {
if (format->extensions == NULL) {
continue;
}
// Extensions are a comma separated list.
extensions = strdup(format->extensions);
for (extension = extensions; extension != NULL; extension = comma) {
comma = strchr(extension, ',');
if (comma != NULL) {
*comma = 0;
comma++;
}
candidate = utilCreateString("%s.%s", baseName, extension);
if (vfsExists(candidate)) {
free(extensions);
return candidate;
}
free(candidate);
}
free(extensions);
}
return NULL;
}
static void _launcher(const char *exeName, ConfigT *conf) {
int32_t x = 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;
const SDL_DisplayMode *mode = NULL;
SDL_AudioSpec spec;
// Get current screen resolution
mode = SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
if (mode == NULL) {
utilDie("%s", SDL_GetError());
}
_mainTrace(conf, "Display is %dx%d", mode->w, mode->h);
// Determine resolution if not specified
if ((conf->xResolution <= 0) || (conf->yResolution <= 0)) {
_mainTrace(conf, "Determining resolution settings");
if (conf->bestRatioIndex < 0) {
// Find our current aspect ratio
for (x = 0; _modes[x].ratio.aspectNum != 0; x++) {
thisRatio = fabsf(((float)_modes[x].ratio.aspectNum / (float)_modes[x].ratio.aspectDom) - ((float)mode->w / (float)mode->h));
if (thisRatio < bestRatio) {
bestRatio = thisRatio;
conf->bestRatioIndex = x;
}
}
}
_mainTrace(conf, "Aspect ratio is %d:%d", _modes[conf->bestRatioIndex].ratio.aspectNum, _modes[conf->bestRatioIndex].ratio.aspectDom);
// Were both resolutions not specified?
if ((conf->xResolution <= 0) && (conf->yResolution <= 0)) {
// Are we full screen?
if (conf->fullScreen || conf->fullScreenWindow) {
// Use desktop resolution
conf->xResolution = mode->w;
conf->yResolution = mode->h;
} else {
// Find largest window that will fit on the screen but not fill it
for (x = 0; _modes[x].ratio.aspectNum != 0; x++) {
if (_modeMatchesRatio(x, conf->bestRatioIndex) && (_modes[x].resolution.width < mode->w) && (_modes[x].resolution.height < mode->h)) {
bestResIndex = x;
}
}
if (bestResIndex < 0) {
_showUsage(exeName, "No window size fits this display. Specify a resolution or use full screen.");
}
conf->xResolution = _modes[bestResIndex].resolution.width;
conf->yResolution = _modes[bestResIndex].resolution.height;
}
} else {
// Find unprovided width/height using provided value
for (x = 0; _modes[x].ratio.aspectNum != 0; x++) {
if (_modeMatchesRatio(x, conf->bestRatioIndex)) {
if ((conf->xResolution > 0) && (_modes[x].resolution.width == conf->xResolution)) {
conf->yResolution = _modes[x].resolution.height;
break;
}
if ((conf->yResolution > 0) && (_modes[x].resolution.height == conf->yResolution)) {
conf->xResolution = _modes[x].resolution.width;
break;
}
}
}
}
}
_mainTrace(conf, "Resolution is %dx%d", conf->xResolution, conf->yResolution);
// Did we end up with a valid resolution?
if (conf->xResolution <= 0) {
_showUsage(exeName, "Unable to determine X resolution. (Is the Y value sane?)");
}
if (conf->yResolution <= 0) {
_showUsage(exeName, "Unable to determine Y resolution. (Is the X value sane?)");
}
if ((conf->xResolution > mode->w) || (conf->yResolution > mode->h)) {
_showUsage(exeName, "Specified resolution is larger than the display.");
}
// Create Window
_mainTrace(conf, "Creating window");
window = SDL_CreateWindow("SINGE", conf->xResolution, conf->yResolution, 0);
if (window == NULL) {
utilDie("%s", SDL_GetError());
}
// Window Icon
_mainTrace(conf, "Setting icon");
icon = IMG_Load_IO(SDL_IOFromConstMem(icon_png, icon_png_len), true);
if (icon == NULL) {
utilDie("%s", SDL_GetError());
}
SDL_SetWindowIcon(window, icon);
SDL_DestroySurface(icon);
icon = NULL;
// Do we want full screen of some kind?
if (conf->fullScreen || conf->fullScreenWindow) {
// Exclusive full screen takes the display's current mode; a NULL mode is a borderless desktop window.
_mainTrace(conf, "Going fullscreen");
SDL_SetWindowFullscreenMode(window, conf->fullScreen ? mode : NULL);
SDL_SetWindowFullscreen(window, true);
// Fullscreen is applied asynchronously; wait for it so the renderer sees the final size.
SDL_SyncWindow(window);
}
// Create a renderer. On a GPU device when the platform has one, so the 3D scene can share it;
// otherwise SDL's ordinary renderer, which prefers accelerated drivers but can fall back to software.
_mainTrace(conf, "Creating renderer");
device = SDL_CreateGPUDevice(SDL_GPU_SHADERFORMAT_SPIRV | SDL_GPU_SHADERFORMAT_DXIL | SDL_GPU_SHADERFORMAT_MSL, false, NULL);
if (device == NULL) {
_mainTrace(conf, "No GPU device (%s); 3D is unavailable", SDL_GetError());
} else {
renderer = SDL_CreateGPURenderer(device, window);
if (renderer == NULL) {
_mainTrace(conf, "GPU renderer failed (%s); 3D is unavailable", SDL_GetError());
SDL_DestroyGPUDevice(device);
device = NULL;
}
}
if (renderer == NULL) {
renderer = SDL_CreateRenderer(window, NULL);
if (renderer == NULL) {
utilDie("%s", SDL_GetError());
}
}
_mainTrace(conf, "Renderer: %s", SDL_GetRendererName(renderer));
// Clear screen with black
SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);
SDL_RenderClear(renderer);
// Create audio mixer device
_mainTrace(conf, "Configuring mixer");
SDL_SetHint(SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES, MIXER_CHUNK_SAMPLES);
spec.format = SDL_AUDIO_S16;
spec.channels = MIXER_CHANNELS;
spec.freq = MIXER_FREQUENCY;
mixer = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec);
if (mixer == NULL) {
utilDie("%s", SDL_GetError());
}
// Start our video playback system
_mainTrace(conf, "Initializing laserdisc video");
videoInit(mixer);
videoSetHardwareDecoding(!conf->softwareVideo);
// Finish our setup
_mainTrace(conf, "Disabling screen saver");
SDL_DisableScreenSaver();
// Run Singe!
_mainTrace(conf, "Starting Singe");
singe(window, renderer, device, conf);
// Shutdown - framefiles own video handles, so they go first.
_mainTrace(conf, "Shutting down laserdisc framefile handler");
frameFileQuit();
_mainTrace(conf, "Shutting down laserdisc video");
videoQuit();
_mainTrace(conf, "Stopping mixer");
MIX_DestroyMixer(mixer);
_mainTrace(conf, "Destroying renderer");
SDL_DestroyRenderer(renderer);
if (device != NULL) {
SDL_DestroyGPUDevice(device);
}
_mainTrace(conf, "Destroying window");
SDL_DestroyWindow(window);
_mainTrace(conf, "Re-enabling screen saver");
SDL_EnableScreenSaver();
}
static void _mainTrace(const ConfigT *conf, const char *fmt, ...) {
va_list args;
if (conf->programTracing) {
va_start(args, fmt);
utilTraceVArgs(fmt, args);
va_end(args);
}
}
static bool _modeMatchesRatio(int32_t index, int32_t ratioIndex) {
return (_modes[index].ratio.aspectNum == _modes[ratioIndex].ratio.aspectNum) && (_modes[index].ratio.aspectDom == _modes[ratioIndex].ratio.aspectDom);
}
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];
// 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, (const char **)argv, options, 0)) {
utilDie("Out of memory parsing arguments.");
}
if (ap_error(&parser)) {
utilDie("%s", ap_error(&parser));
}
// Default configuration values
conf = (ConfigT *)calloc(1, sizeof(ConfigT));
if (!conf) {
utilDie("Out of memory creating config.");
}
conf->bestRatioIndex = -1;
conf->volumeVldp = VOLUME_MAX;
conf->volumeNonVldp = VOLUME_MAX;
conf->scaleFactor = SCALE_FACTOR_MAX;
conf->resolutionWasCalculated = true;
conf->canvasWidth = CANVAS_DEFAULT_WIDTH;
conf->canvasHeight = CANVAS_DEFAULT_HEIGHT;
conf->entry = 1;
// 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':
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);
}
return conf;
}
static bool _parseInteger(const char *text, int32_t *value) {
char *end = NULL;
long parsed = 0;
if ((text == NULL) || (*text == 0)) {
return false;
}
errno = 0;
parsed = strtol(text, &end, 10);
if ((*end != 0) || (errno == ERANGE) || (parsed < INT32_MIN) || (parsed > INT32_MAX)) {
return false;
}
*value = (int32_t)parsed;
return true;
}
// 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) {
if ((value < min) || (value > max)) {
_showUsage(exeName, utilCreateString("%s must be between %d and %d %s.", what, min, max, unit));
}
}
static void _resolveFiles(const char *exeName, ConfigT *conf) {
size_t length = 0;
const char *extension = NULL;
char *temp = NULL;
ConfigT *replacement = NULL;
ConfigT swapped;
// Exists? A packed game answers through its database.
vfsInit(conf->container, conf->dataDirBase, conf->dataDir);
utilFixPathSeparators(&conf->scriptFile, false);
if (!vfsExists(conf->scriptFile)) {
// Missing. Is a path?
temp = NULL;
if (utilPathExists(conf->scriptFile)) {
// See if the script named for the path exists inside the path.
temp = utilCreateString("%s%c%s.singe", conf->scriptFile, utilGetPathSeparator(), utilGetLastPathComponent(conf->scriptFile));
if (!utilFileExists(temp)) {
free(temp);
temp = NULL;
}
}
free(conf->scriptFile);
conf->scriptFile = temp;
}
if (!conf->scriptFile) {
_showUsage(exeName, "Unable to locate the game.");
}
// 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);
if (replacement != NULL) {
swapped = *conf;
*conf = *replacement;
*replacement = swapped;
destroyConf(&replacement);
}
}
// Do we need to generate a video name?
if (conf->videoFile) {
utilFixPathSeparators(&conf->videoFile, false);
if (!vfsExists(conf->videoFile)) {
free(conf->videoFile);
conf->videoFile = NULL;
}
} else {
// Strip the script's extension (and its dot, when there is one).
extension = utilGetFileExtension(conf->scriptFile);
length = strlen(conf->scriptFile) - strlen(extension);
if (strlen(extension) > 0) {
length--;
}
temp = utilStrndup(conf->scriptFile, length);
conf->videoFile = _findVideoFile(temp);
// If we still don't have one, try a framefile
if (!conf->videoFile) {
conf->videoFile = utilCreateString("%s.txt", temp);
if (!vfsExists(conf->videoFile)) {
free(conf->videoFile);
conf->videoFile = NULL;
}
}
free(temp);
// A game is only a laserdisc game when it says so.
if (!conf->disc && conf->videoFile) {
utilSay("Note: %s found but --disc was not given; running without a disc.", conf->videoFile);
free(conf->videoFile);
conf->videoFile = NULL;
}
}
if (conf->disc && !conf->videoFile) {
_showUsage(exeName, "Unable to locate video.");
}
conf->isFrameFile = conf->disc && isFrameFileName(conf->videoFile);
free(conf->dataDir);
conf->dataDir = resolveDataDir(conf);
if (!conf->dataDir) {
_showUsage(exeName, "Unable to create data directory.");
}
}
// --pack, --unpack, and --patch: the option carries the source, the script argument the destination.
static bool _runTool(const ConfigT *conf) {
switch (conf->toolMode) {
case TOOL_PACK:
return packGame(conf->toolSource, conf->scriptFile);
case TOOL_PATCH:
return packPatch(conf->toolSource, conf->scriptFile);
case TOOL_UNPACK:
return packUnpack(conf->toolSource, conf->scriptFile);
default:
return false;
}
}
static void _showHeader(void) {
static bool shown = false;
if (!shown) {
utilRedirectConsole();
// 00000000011111111112222222222333333333344444444445555555555666666666677777777778
// 12345678901234567890123456789012345678901234567890123456789012345678901234567890
utilSay(" ___ ___ _ _ ___ ___");
utilSay("/ __|_ _| \\| |/ __| __| SINGE Is Not a Game Emulator %s", VERSION_STRING);
utilSay("\\__ \\| || .` | (_ | _| Copyright (c) 2006-%s Scott C. Duensing", COPYRIGHT_END_YEAR);
utilSay("|___/___|_|\\_|\\___|___| https://KangarooPunch.com https://SingeEngine.com");
utilNewline();
shown = true;
}
}
static void _showUsage(const char *name, const char *message) {
const int32_t helpColumn = 6 + USAGE_OPTION_WIDTH; // " -x, " plus the padded long form
const int32_t helpWidth = USAGE_LINE_WIDTH - helpColumn;
const char *help = NULL;
const char *end = NULL;
const char *space = NULL;
int32_t x = 0;
char *longForm = NULL;
_showHeader();
utilSay("Usage: %s [OPTIONS] gameName", utilGetLastPathComponent(name));
utilSay(" gameName: a .singe script, its directory, or a packed .game file");
utilNewline();
for (x = 0; x < (int32_t)OPTION_COUNT; x++) {
if (_options[x].hidden) {
continue;
}
if (_options[x].value != NULL) {
longForm = utilCreateString("--%s=%s", _options[x].name, _options[x].value);
} else {
longForm = utilCreateString("--%s", _options[x].name);
}
// Wrap the help at word boundaries; continuation lines start in the help column.
help = _options[x].help;
while (help != NULL) {
end = help + strlen(help);
if ((end - help) > helpWidth) {
end = help + helpWidth;
for (space = end; (space > help) && (*space != ' '); space--) {
}
if (space > help) {
end = space;
}
}
if (help == _options[x].help) {
utilSay(" -%c, %-*s%.*s", _options[x].code, USAGE_OPTION_WIDTH, longForm, (int)(end - help), help);
} else {
utilSay("%*s%.*s", helpColumn, "", (int)(end - help), help);
}
while (*end == ' ') {
end++;
}
help = (*end != 0) ? end : NULL;
}
free(longForm);
}
utilNewline();
if (message) {
utilSay("Error: %s", message);
utilNewline();
}
if (utilGetConsoleEnabled()) {
utilWaitForKeyOnWindows();
}
exit(message ? EXIT_FAILURE : EXIT_SUCCESS);
}
static void _startSDL(void) {
// Init SDL
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD | SDL_INIT_EVENTS)) {
utilDie("%s", SDL_GetError());
}
// Init SDL_mixer (SDL_image needs no init in SDL3)
if (!MIX_Init()) {
utilDie("%s", SDL_GetError());
}
// Init SDL_ttf
if (!TTF_Init()) {
utilDie("%s", SDL_GetError());
}
}
static void _stopSDL(void) {
TTF_Quit();
MIX_Quit();
SDL_Quit();
}
static void _unpackData(const char *name) {
const EmbeddedFileT files[] = {
{ "Framework.singe", Framework_singe, Framework_singe_len },
{ "controls.cfg.example", controls_cfg, controls_cfg_len },
{ "Menu.singe", Menu_singe, Menu_singe_len },
{ "FreeSansBold.ttf", FreeSansBold_ttf, FreeSansBold_ttf_len },
{ "menuBackground.mkv", menuBackground_mkv, menuBackground_mkv_len },
{ "click.wav", click_wav, click_wav_len },
{ "Manual.pdf", Manual_pdf, Manual_pdf_len }
};
int32_t x = 0;
char *temp = NULL;
char *data = NULL;
bool created = false;
// Extract missing or outdated support files. We do this here so they are not generated if launched from a front end.
if (!utilMkDirP(VFS_ENGINE_DIRECTORY, DIRECTORY_MODE)) {
utilDie("Unable to create %s directory.", VFS_ENGINE_DIRECTORY);
}
for (x = 0; x < (int32_t)(sizeof(files) / sizeof(files[0])); x++) {
temp = utilCreateString("%s%c%s", VFS_ENGINE_DIRECTORY, utilGetPathSeparator(), files[x].name);
created |= _extractFile(temp, files[x].data, files[x].length);
free(temp);
}
// Script to start menu system
if (utilGetPathSeparator() == '/') {
// Unix-ish
temp = strdup("Menu.sh");
data = utilCreateString("#!/bin/sh\n\ncd \"$(dirname \"$0\")\"\n./%s %s %s/menuBackground.mkv %s/Menu.singe\n", utilGetLastPathComponent(name), MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
} else {
// Winders
temp = strdup("Menu.bat");
data = utilCreateString("@start %s %s %s\\menuBackground.mkv %s\\Menu.singe\n", utilGetLastPathComponent(name), MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY);
}
created |= _extractFile(temp, (const uint8_t *)data, strlen(data));
utilChMod(temp, SCRIPT_MODE);
free(data);
free(temp);
if (created) {
utilNewline();
}
}
ConfigT *cloneConf(const ConfigT *conf) {
ConfigT *c = (ConfigT *)calloc(1, sizeof(ConfigT));
if (!c) {
utilDie("Out of memory cloning config.");
}
// 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->videoFile = _cloneString(conf->videoFile);
c->dataDirBase = _cloneString(conf->dataDirBase);
c->dataDir = _cloneString(conf->dataDir);
return c;
}
// Builds and creates dataDirBase + directory of filename. Returns a new string or NULL on failure.
char *createDataDir(const char *dataDirBase, const char *filename) {
const char separator = utilGetPathSeparator();
char *relative = utilGetUpToLastPathComponent(filename);
char *start = relative;
char *path = NULL;
char *p = NULL;
// Keep absolute paths inside the base: drop any drive letter and leading separators.
if (isalpha((unsigned char)start[0]) && (start[1] == ':')) {
start += 2;
}
while (*start == separator) {
start++;
}
// Neutralize ".." components.
for (p = start; *p != 0; p++) {
if ((p[0] == '.') && (p[1] == '.') && ((p == start) || (p[-1] == separator)) && ((p[2] == separator) || (p[2] == 0))) {
p[0] = '_';
p[1] = '_';
}
}
path = utilCreateString("%s%s", dataDirBase, start);
free(relative);
utilFixPathSeparators(&path, true);
// Try to create data directory to ensure it exists.
if (!utilMkDirP(path, DIRECTORY_MODE)) {
free(path);
return NULL;
}
return path;
}
// The data directory follows the script's directory; inside a database it sits under the database's
// name as well, so two archives holding the same script path never share one. vfs.c's overlay for
// a packed game hangs off this directory, so the two cannot disagree.
char *createDataDirFor(const ConfigT *conf) {
char *stem = NULL;
char *name = NULL;
char *path = NULL;
if (conf->container == NULL) {
return createDataDir(conf->dataDirBase, conf->scriptFile);
}
stem = vfsDatabaseStem(utilGetLastPathComponent(conf->container));
name = utilCreateString("%s%c%s", stem, utilGetPathSeparator(), conf->scriptFile);
path = createDataDir(conf->dataDirBase, name);
free(name);
free(stem);
return path;
}
void destroyConf(ConfigT **confPointer) {
ConfigT *conf = *confPointer;
if (conf == NULL) {
return;
}
free(conf->dataDir);
free(conf->dataDirBase);
free(conf->videoFile);
free(conf->scriptFile);
free(conf->container);
free(conf->toolSource);
free(conf);
*confPointer = NULL;
}
bool isFrameFileName(const char *filename) {
return utilStricmp(utilGetFileExtension(filename), "txt") == 0;
}
// Ok, this thing can have a mess of different arguments:
// WW - Just the width of the white border
// WW WB - Width of white border and then black border
// RW GW BW WW - Custom color "white" border and width
// RW GW BW WW WB - Custom color "white" border and width then width of black border
// RW GW BW WW RB GB BB WB - Custom color "white" border and width then custom color "black" border and width
bool parseSindenString(const char *sindenString, ConfigT *conf) {
const char *p = sindenString;
char *end = NULL;
long value = 0;
conf->sindenArgc = 0;
while (*p != 0) {
// Skip separators and any quotes the shell left behind.
if ((*p == ' ') || (*p == '"') || (*p == '\'')) {
p++;
continue;
}
if (conf->sindenArgc >= SINDEN_ARG_MAX) {
return false;
}
value = strtol(p, &end, 10);
if (end == p) {
return false;
}
conf->sindenArgv[conf->sindenArgc++] = (int32_t)value;
p = end;
}
// Did we get a sane number of arguments?
switch (conf->sindenArgc) {
case SINDEN_WHITE:
case SINDEN_WHITE_BLACK:
case SINDEN_CUSTOM_WHITE:
case SINDEN_CUSTOM_WHITE_BLACK:
case SINDEN_CUSTOM_WHITE_CUSTOM_BLACK:
return true;
default:
return false;
}
}
void queueScript(const ConfigT *conf) {
QueueT *q = (QueueT *)calloc(1, sizeof(QueueT));
if (!q) {
utilDie("Out of memory queueing script.");
}
q->conf = cloneConf(conf);
LL_APPEND(_scriptQueue, q);
}
// Where a game writes: under the -d base (or, for a packed game, the default base) in a directory
// named for the game; without either, the game's own folder. A new string, or NULL when it cannot
// be created. The one rule for command line, games.dat and scriptPush launches alike.
char *resolveDataDir(const ConfigT *conf) {
if (conf->dataDirGiven || (conf->container != NULL)) {
return createDataDirFor(conf);
}
return utilGetUpToLastPathComponent(conf->scriptFile);
}
int main(int argc, char *argv[]) {
const char *exeName = argv[0];
char *temp = NULL;
ConfigT *conf = NULL;
ConfigT *replacement = NULL;
QueueT *q = NULL;
bool ok = false;
#ifndef _WIN32
signal(SIGSEGV, _crashHandler);
signal(SIGBUS, _crashHandler);
signal(SIGABRT, _crashHandler);
signal(SIGFPE, _crashHandler);
#endif
// Options first so --help and --noconsole take effect before anything is written.
conf = _parseArguments(exeName, argc, argv);
// For that dumb OS
utilRedirectConsole();
_unpackData(exeName);
// -d names the base under which every game gets a data directory; without it the game folder serves.
if (conf->dataDir) {
conf->dataDirBase = conf->dataDir;
conf->dataDir = NULL;
conf->dataDirGiven = true;
utilFixPathSeparators(&conf->dataDirBase, true);
} else {
conf->dataDirBase = utilCreateString(".%c", utilGetPathSeparator());
}
// The packing tools need no window: run one and leave.
if (conf->toolMode != TOOL_NONE) {
if (!conf->scriptFile) {
_showUsage(exeName, "The packing tools need a second name after the options.");
}
ok = _runTool(conf);
destroyConf(&conf);
vfsQuit();
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
}
// Nothing to run? Installing was the whole job.
if (!conf->scriptFile) {
_showUsage(exeName, "No game specified.");
}
// A game database on its own runs its first games.dat entry; a patch is not a game.
if (!vfsIsDatabase(conf->scriptFile) && packIsDatabase(conf->scriptFile)) {
_showUsage(exeName, "That is a patch database. Apply it with --patch; it cannot be run.");
}
if (vfsIsDatabase(conf->scriptFile)) {
replacement = confFromDatabase(conf);
destroyConf(&conf);
conf = replacement;
}
// Queue initial script
_resolveFiles(exeName, conf);
queueScript(conf);
destroyConf(&conf);
_startSDL();
// Run script queue
while (_scriptQueue) {
q = _scriptQueue;
// Do they want tracing of any kind?
if (q->conf->scriptTracing || q->conf->programTracing) {
temp = utilCreateString("%strace.txt", q->conf->dataDir);
utilTraceStart(temp);
free(temp);
}
_launcher(exeName, q->conf);
destroyConf(&q->conf);
LL_DELETE(_scriptQueue, q);
free(q);
utilTraceEnd();
}
_stopSDL();
vfsQuit();
if (utilGetConsoleEnabled()) {
utilWaitForKeyOnWindows();
}
return EXIT_SUCCESS;
}