Game patches supported.

This commit is contained in:
Scott Duensing 2026-09-04 13:48:00 -05:00
parent 84a2c25183
commit 6859990bec
7 changed files with 194 additions and 68 deletions

View file

@ -49,14 +49,14 @@ API Changes
LEGACY_SPRITE_ARGS = true to their games.dat entry. See "Migrating from
Singe 2.10" in the manual.
- Singe is no longer laserdisc only. A game declares a disc with
DISC = true in games.dat (or --disc / --framefile on the command line);
otherwise it runs without one, on a canvas sized by CANVAS_X/CANVAS_Y or
--canvas=WxH (default 720x480), the disc functions become harmless, and
discGetState() returns the new DISC_EJECTED.
Every existing games.dat entry needs DISC = true added; the menu skips
an entry with VIDEO but no DISC = true and says why, and the engine
refuses one if launched directly. Scripts can test SINGE_DISC.
- Singe is no longer laserdisc only. A games.dat entry with a VIDEO is a
laserdisc game; one with the line missing or blank runs without a
disc, on a canvas sized by CANVAS_X/CANVAS_Y (default 720x480), the
disc functions become harmless, and discGetState() returns the new
DISC_EJECTED. On the command line --disc (implied by --framefile)
declares the disc, since a video found beside the script no longer
does; --canvas=WxH sizes the canvas. Existing games.dat files need no
change. Scripts can test SINGE_DISC.
- mouseSetEnabled(bool) and singeSetPauseKeyEnabled(bool) added. The old
mouseEnable/mouseDisable and singeEnablePauseKey/singeDisablePauseKey are

View file

@ -419,14 +419,6 @@ local function loadGamesDat(source, container)
return
end
for _, value in pairs(GAMES or {}) do
-- Since 3.00 a laserdisc game must say DISC = true; refuse the ambiguous cases here
-- with a message rather than letting the engine stop the menu when it is picked.
local title = tostring(value.TITLE or value.SCRIPT or "?")
if value.VIDEO and not value.DISC then
debugPrint(source .. ": \"" .. title .. "\" names a VIDEO without DISC = true; add DISC = true to the entry. Skipped.")
elseif value.DISC and not value.VIDEO then
debugPrint(source .. ": \"" .. title .. "\" says DISC = true but has no VIDEO. Skipped.")
else
if container then
-- Packed game: the engine opens its files through the database, and so does the menu.
value.CONTAINER = container
@ -439,7 +431,6 @@ local function loadGamesDat(source, container)
table.insert(GAME_LIST, value)
GAME_COUNT = GAME_COUNT + 1
end
end
GAMES = {}
end

View file

@ -353,13 +353,20 @@ rule ambiguous), or whose scripts and data files reach outside the game
with `..`; it reports the file and line. Two files whose names differ only
by case cannot both be packed. Stale `.index` files are skipped. A game
that references another game's directory (a shared framework beside it)
must copy that directory inside first.
must copy that directory inside first. Before the database is committed,
its `games.dat` is checked: every entry needs a `SCRIPT`, and each
`SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE`, and `ATTRACT` it names must be a
file in the database (or under `Singe/`); a bad path is reported with the
entry's title and the pack is refused. `--patch` runs the same check on the
patched game, so a patch cannot leave a dangling path behind either.
`--patch` takes either a directory laid out like the game, whose files
replace the packed ones, or a database made by `--pack` from such a
directory. Either way the change is one transaction: the game is intact if
it is interrupted. A patch database may also carry a `removed(path)` table
naming files to delete.
replace the packed ones, or a `.patch` file made by `--pack` from such a
directory (`Singe --pack fixes DLe-1.1.patch`; a patch needs no
`games.dat`). Files the patch does not name are left alone. Either way the
change is one transaction: the game is intact if it is interrupted. A patch
may also carry a `removed(path)` table naming files to delete. A `.patch`
can be unpacked like a game, but it cannot be run and the menu ignores it.
A packed game keeps its data directory exactly where the loose game would
have it (`data/DLe/` for `DLe/DLe.singe`), so saves and settings are shared
@ -615,7 +622,6 @@ GAMES = {
{
TITLE = ".38 Ambush Alley",
SCRIPT = "ActionMax/38AmbushAlley.singe",
DISC = true,
VIDEO = "ActionMax/frame_38AmbushAlley.txt",
DATA = "ActionMax",
STRETCH = false,
@ -641,14 +647,13 @@ GAMES = {
}
----
The keys `SCRIPT`, `DISC`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
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
script, through `scriptExecute` / `scriptPush`) launches the entry; they
override the command line. A laserdisc game must say `DISC = true` and name
its `VIDEO`; an entry with a `VIDEO` but no `DISC = true` is refused with a
message, as is `DISC = true` without a `VIDEO`. A game without a disc leaves
both out and may set `CANVAS_X` / `CANVAS_Y` (default 720x480); see
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
<<withoutadisc,Games Without a Disc>>. `LEGACY_SPRITE_ARGS = true` runs a game written for Singe 2.10
with the old sprite argument order (see <<migrating,Migrating from Singe
2.10>>). The remaining keys are read by the menu for display.
@ -657,7 +662,7 @@ with the old sprite argument order (see <<migrating,Migrating from Singe
=== Games Without a Disc
Singe no longer assumes a laserdisc. A game is a laserdisc game only when it
says so: `DISC = true` in its `games.dat` entry, or `--disc` (or a
names one: a `VIDEO` in its `games.dat` entry, or `--disc` (or a
`--framefile`) on the command line. Everything else runs without a disc, and
the world is a *canvas* instead of a video frame: `CANVAS_X` / `CANVAS_Y` in
`games.dat` or `--canvas=WxH` on the command line, 720x480 by default so a
@ -715,11 +720,11 @@ changed shape, but a few behaviors did:
* `mouseEnable` / `mouseDisable` and `singeEnablePauseKey` /
`singeDisablePauseKey` still work, as aliases of `mouseSetEnabled` and
`singeSetPauseKeyEnabled`.
* A laserdisc game must now declare itself: add `DISC = true` next to
`VIDEO` in every `games.dat` entry, and pass `--disc` (or `--framefile`)
on the command line. Without it the game runs without a disc, and a
`games.dat` entry that names a `VIDEO` without `DISC = true` is refused
with a message naming the fix. See <<withoutadisc,Games Without a Disc>>.
* A laserdisc is no longer assumed. A `games.dat` entry with a `VIDEO` is a
laserdisc game, as before; one without runs on a canvas instead. On the
command line pass `--disc` (or `--framefile`), since a video found beside
the script no longer implies a disc. See <<withoutadisc,Games Without a
Disc>>.
* The pause key now pauses the whole game, not just the media: the script
is frozen until the key is pressed again, and `SWITCH_PAUSE` is delivered
to the script only when the key has been disabled. It acts on the key

View file

@ -127,12 +127,12 @@ static const OptionT _options[] = {
{ '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 a game DIRECTORY into the database named after the options", false },
{ 'P', "pack", ap_yes, "DIRECTORY", "pack a game DIRECTORY into the .game (or changed files into the .patch) named after the options", false },
{ 'p', "program", ap_no, NULL, "trace Singe execution to screen and file", false },
{ 's', "nosound", ap_no, NULL, "mutes all sound", false },
{ 'T', "patch", ap_yes, "DATABASE", "patch a game DATABASE from the directory or patch database named after the options", false },
{ 'T', "patch", ap_yes, "DATABASE", "patch a 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 a game DATABASE into the directory named after the options", false },
{ 'U', "unpack", ap_yes, "DATABASE", "unpack a 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 },
@ -1203,7 +1203,10 @@ int main(int argc, char *argv[]) {
_showUsage(exeName, "No script file specified.");
}
// A game database on its own runs its first games.dat entry.
// 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);

View file

@ -12,13 +12,20 @@
#include "sqlite3.h"
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
#include "pack.h"
#include "singe.h"
#include "util.h"
#include "vfs.h"
#define ENGINE_PREFIX "singe/"
#define GAMES_DAT "games.dat"
#define PAGE_SIZE 4096
#define PATCH_EXTENSION "patch"
typedef struct EntryS {
@ -52,8 +59,10 @@ static void _entriesFree(EntryT *list);
static bool _exec(sqlite3 *db, const char *sql);
static bool _insertFile(WriterT *writer, const EntryT *entry);
static bool _isDirectory(const char *path);
static bool _isPatchName(const char *database);
static char *_keyFor(const char *relative);
static bool _scanEscapes(const EntryT *list);
static bool _validateGamesDat(WriterT *writer);
static bool _writeMeta(sqlite3 *db, const char *key, const char *value);
static bool _writerClose(WriterT *writer, bool commit);
static bool _writerOpen(WriterT *writer, const char *database, bool create);
@ -103,7 +112,8 @@ static bool _applyDatabase(WriterT *writer, const char *source) {
}
// Inserts every file below directory. checkGame enforces the rules a whole game must meet.
// Inserts every file below directory. checkGame enforces the rules a whole game must meet; the
// forbidden-file list and the escape scan apply to patches too.
static bool _applyDirectory(WriterT *writer, const char *directory, bool checkGame) {
EntryT *list = NULL;
EntryT *entry = NULL;
@ -134,7 +144,7 @@ static bool _applyDirectory(WriterT *writer, const char *directory, bool checkGa
}
}
}
if (ok && checkGame && !_scanEscapes(list)) {
if (ok && !_scanEscapes(list)) {
ok = false;
}
for (entry = list; ok && (entry != NULL); entry = entry->next) {
@ -296,6 +306,12 @@ static bool _isDirectory(const char *path) {
}
// Patch databases are .patch files: packed like a game but without a games.dat, and never run.
static bool _isPatchName(const char *database) {
return utilStricmp(utilGetFileExtension(database), PATCH_EXTENSION) == 0;
}
// The lookup key: lower case, forward slashes.
static char *_keyFor(const char *relative) {
char *key = strdup(relative);
@ -352,6 +368,101 @@ static bool _scanEscapes(const EntryT *list) {
}
// Every file a games.dat entry names must be in the database (or belong to the engine), checked
// before the transaction commits so a game or patch never ships with a dangling path.
static bool _validateGamesDat(WriterT *writer) {
static const char *const keys[] = { "SCRIPT", "VIDEO", "CABINET", "MARQUEE", "ATTRACT", NULL };
sqlite3_stmt *stmt = NULL;
lua_State *L = NULL;
char *gameDir = NULL;
char *key = NULL;
const char *value = NULL;
const char *title = NULL;
const char *inner = NULL;
size_t length = 0;
int32_t x = 0;
bool ok = true;
// The file itself, and the own-directory prefix its paths may carry.
if ((sqlite3_prepare_v2(writer->db, "SELECT data FROM assets WHERE path = ?", -1, &stmt, NULL) != SQLITE_OK)) {
return false;
}
sqlite3_bind_text(stmt, 1, GAMES_DAT, -1, SQLITE_STATIC);
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
utilSay("!!! No %s at the root of the game.", GAMES_DAT);
return false;
}
L = luaL_newstate();
luaL_openlibs(L);
if ((luaL_loadbuffer(L, sqlite3_column_blob(stmt, 0), (size_t)sqlite3_column_bytes(stmt, 0), GAMES_DAT) != LUA_OK) || (lua_pcall(L, 0, 0, 0) != LUA_OK)) {
utilSay("!!! %s", lua_tostring(L, -1));
sqlite3_finalize(stmt);
lua_close(L);
return false;
}
sqlite3_finalize(stmt);
if (sqlite3_prepare_v2(writer->db, "SELECT value FROM meta WHERE key = 'gamedir'", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
gameDir = _keyFor((const char *)sqlite3_column_text(stmt, 0));
}
sqlite3_finalize(stmt);
}
if (sqlite3_prepare_v2(writer->db, "SELECT count(*) FROM assets WHERE path = ?", -1, &stmt, NULL) != SQLITE_OK) {
free(gameDir);
lua_close(L);
return false;
}
lua_getglobal(L, "GAMES");
if (!lua_istable(L, -1)) {
utilSay("!!! %s defines no GAMES table.", GAMES_DAT);
ok = false;
}
lua_pushnil(L);
while (ok && lua_next(L, -2)) {
if (lua_istable(L, -1)) {
lua_getfield(L, -1, "TITLE");
title = lua_tostring(L, -1) ? lua_tostring(L, -1) : "(untitled)";
for (x = 0; keys[x] != NULL; x++) {
lua_getfield(L, -2, keys[x]);
value = lua_tostring(L, -1);
if ((value != NULL) && (value[0] != 0)) {
key = _keyFor(value);
inner = key;
if (gameDir != NULL) {
length = strlen(gameDir);
if ((strncmp(inner, gameDir, length) == 0) && (inner[length] == '/')) {
inner += length + 1;
}
}
if (strncmp(key, ENGINE_PREFIX, strlen(ENGINE_PREFIX)) != 0) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, inner, -1, SQLITE_TRANSIENT);
if ((sqlite3_step(stmt) != SQLITE_ROW) || (sqlite3_column_int64(stmt, 0) == 0)) {
utilSay("!!! %s: \"%s\" names %s = \"%s\", which is not in the game.", GAMES_DAT, title, keys[x], value);
ok = false;
}
}
free(key);
} else if (strcmp(keys[x], "SCRIPT") == 0) {
utilSay("!!! %s: \"%s\" has no SCRIPT.", GAMES_DAT, title);
ok = false;
}
lua_pop(L, 1);
}
lua_pop(L, 1);
}
lua_pop(L, 1);
}
sqlite3_finalize(stmt);
free(gameDir);
lua_close(L);
return ok;
}
static bool _writeMeta(sqlite3 *db, const char *key, const char *value) {
sqlite3_stmt *stmt = NULL;
bool ok = false;
@ -467,13 +578,14 @@ char *packForbiddenReason(const char *path, bool isPatch) {
}
// Packs a game directory into a new database.
// Packs a game directory into a new database, or a directory of changed files into a .patch.
bool packGame(const char *directory, const char *database) {
WriterT writer;
char *root = strdup(directory);
char *gamesDat = NULL;
char *chunk = NULL;
size_t length = strlen(root);
bool isPatch = _isPatchName(database);
bool ok = true;
while ((length > 1) && ((root[length - 1] == '/') || (root[length - 1] == '\\'))) {
@ -485,7 +597,7 @@ bool packGame(const char *directory, const char *database) {
return false;
}
gamesDat = utilCreateString("%s/games.dat", root);
if (!utilFileExists(gamesDat)) {
if (!isPatch && !utilFileExists(gamesDat)) {
utilSay("!!! %s has no games.dat; a game database needs one at its root.", root);
free(gamesDat);
free(root);
@ -502,7 +614,8 @@ bool packGame(const char *directory, const char *database) {
ok = ok && _writeMeta(writer.db, "chunk", chunk);
ok = ok && _writeMeta(writer.db, "packer", VERSION_STRING);
free(chunk);
ok = ok && _applyDirectory(&writer, root, true);
ok = ok && _applyDirectory(&writer, root, !isPatch);
ok = ok && (isPatch || _validateGamesDat(&writer));
if (ok) {
utilSay(">>> Packed %lld files (%lld bytes) from %s into %s", (long long)writer.files, (long long)writer.bytes, root, database);
}
@ -518,6 +631,26 @@ bool packGame(const char *directory, const char *database) {
}
// A database with our meta table, whatever its extension.
bool packIsDatabase(const char *database) {
sqlite3 *db = NULL;
sqlite3_stmt *stmt = NULL;
bool found = false;
if (!utilFileExists(database) || (sqlite3_open_v2(database, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK)) {
sqlite3_close(db);
return false;
}
if (sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key = 'version'", -1, &stmt, NULL) == SQLITE_OK) {
found = (sqlite3_step(stmt) == SQLITE_ROW);
}
sqlite3_finalize(stmt);
sqlite3_close(db);
return found;
}
// Applies a patch, either a directory of replacement files or a patch database, in one transaction.
bool packPatch(const char *database, const char *source) {
WriterT writer;
@ -534,6 +667,7 @@ bool packPatch(const char *database, const char *source) {
utilSay("!!! %s does not exist.", source);
ok = false;
}
ok = ok && _validateGamesDat(&writer);
if (ok) {
utilSay(">>> Patched %s with %lld files (%lld bytes) from %s", database, (long long)writer.files, (long long)writer.bytes, source);
}
@ -557,8 +691,8 @@ bool packUnpack(const char *database, const char *directory) {
int64_t files = 0;
bool ok = true;
if (!vfsIsDatabase(database)) {
utilSay("!!! %s is not a Singe game database.", database);
if (!packIsDatabase(database)) {
utilSay("!!! %s is not a Singe game or patch database.", database);
return false;
}
if (sqlite3_open_v2(database, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {

View file

@ -10,6 +10,7 @@
char *packForbiddenReason(const char *path, bool isPatch);
bool packGame(const char *directory, const char *database);
bool packIsDatabase(const char *database);
bool packPatch(const char *database, const char *source);
bool packUnpack(const char *database, const char *directory);

View file

@ -744,7 +744,7 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) {
int64_t valueNumber = 0;
ConfigT *c = NULL;
// Start with the given config, but every entry declares its own disc and container.
// Start with the given config, but every entry brings its own video and container.
c = cloneConf(base);
free(c->container);
c->container = NULL;
@ -825,8 +825,6 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) {
c->audioDelayMs = (int32_t)valueNumber;
} else if (strcmp(confKey, "LEGACY_SPRITE_ARGS") == 0) {
c->legacySpriteArgs = valueBoolean;
} else if (strcmp(confKey, "DISC") == 0) {
c->disc = valueBoolean;
} else if (strcmp(confKey, "CANVAS_X") == 0) {
c->canvasWidth = (int32_t)valueNumber;
} else if (strcmp(confKey, "CANVAS_Y") == 0) {
@ -837,13 +835,12 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) {
lua_pop(L, 1);
}
// A laserdisc game says DISC = true and names its VIDEO; anything else is a mistake worth stopping for.
if (c->disc && (c->videoFile == NULL)) {
utilDie("%s: DISC = true but no VIDEO given.", c->scriptFile);
}
if (!c->disc && (c->videoFile != NULL)) {
utilDie("%s: VIDEO given without DISC = true. Add DISC = true to the games.dat entry for a laserdisc game.", c->scriptFile);
// The VIDEO line is the disc: present and not blank means a laserdisc game.
if ((c->videoFile != NULL) && (c->videoFile[0] == 0)) {
free(c->videoFile);
c->videoFile = NULL;
}
c->disc = (c->videoFile != NULL);
if ((c->canvasWidth <= 0) || (c->canvasHeight <= 0)) {
utilDie("%s: CANVAS_X and CANVAS_Y must be positive.", c->scriptFile);
}
@ -4445,11 +4442,6 @@ ConfigT *confFromDatabase(const ConfigT *conf) {
}
lua_replace(L, 1);
lua_settop(L, 1);
// --disc on the command line stands in for a DISC = true the entry forgot.
if (conf->disc) {
lua_pushboolean(L, true);
lua_setfield(L, 1, "DISC");
}
result = _buildConfFromTable(L, base);
if (result->scriptFile == NULL) {
utilDie("%s: the first games.dat entry has no SCRIPT.", base->container);