From 6859990bece1f68757916c17dc76f3d9b4ae63fc Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Fri, 4 Sep 2026 13:48:00 -0500 Subject: [PATCH] Game patches supported. --- CHANGELOG | 16 ++--- assets/Menu.singe | 25 +++----- docs/Manual.adoc | 39 ++++++------ src/main.c | 11 ++-- src/pack.c | 150 +++++++++++++++++++++++++++++++++++++++++++--- src/pack.h | 1 + src/singe.c | 20 ++----- 7 files changed, 194 insertions(+), 68 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 337545a5c..d6d8aad66 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/assets/Menu.singe b/assets/Menu.singe index 03dd157ac..4aac9f5c4 100644 --- a/assets/Menu.singe +++ b/assets/Menu.singe @@ -419,26 +419,17 @@ 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 - for _, key in ipairs({ "CABINET", "MARQUEE", "ATTRACT" }) do - if value[key] then - value[key] = container .. "/" .. value[key] - end + if container then + -- Packed game: the engine opens its files through the database, and so does the menu. + value.CONTAINER = container + for _, key in ipairs({ "CABINET", "MARQUEE", "ATTRACT" }) do + if value[key] then + value[key] = container .. "/" .. value[key] end end - table.insert(GAME_LIST, value) - GAME_COUNT = GAME_COUNT + 1 end + table.insert(GAME_LIST, value) + GAME_COUNT = GAME_COUNT + 1 end GAMES = {} end diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 625a490ca..ea009587d 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -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 <>. `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. @@ -657,7 +662,7 @@ with the old sprite argument order (see <>. +* 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 <>. * 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 diff --git a/src/main.c b/src/main.c index 4f8f1c985..e962684de 100644 --- a/src/main.c +++ b/src/main.c @@ -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); diff --git a/src/pack.c b/src/pack.c index f42d83db5..710e77a4a 100644 --- a/src/pack.c +++ b/src/pack.c @@ -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 PAGE_SIZE 4096 +#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) { diff --git a/src/pack.h b/src/pack.h index 4f0227367..ae9fea761 100644 --- a/src/pack.h +++ b/src/pack.h @@ -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); diff --git a/src/singe.c b/src/singe.c index c85aff6c7..c93dc2a39 100644 --- a/src/singe.c +++ b/src/singe.c @@ -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);