diff --git a/docs/engine/front-end.md b/docs/engine/front-end.md index e2e12159..4444be51 100644 --- a/docs/engine/front-end.md +++ b/docs/engine/front-end.md @@ -323,8 +323,8 @@ Not ported: 3D RENDER MODE, which OpenReliant has no Direct3D devices for. **Improvement:** the original can't load mods. GAME OPTIONS' MODS button opens OpenReliant's mods screen ([`mod_manager.zig`](../../src/engine/game/interface/mod_manager.zig), [#497](https://github.com/OpenReliant/openreliant/issues/497)) after `interface\optfade.bik`, on the settings screen's shapes and background (`interface\frntend5.spr`, `interface\optfade.tga`), laid out as its controls tab is: - MODS, in white, centred on (320, 95), where the settings screen has its tabs. -- A framed list of the mods in the `mods` folder, from (45, 136), 324 by 250, with nine rows 26 pixels apart. Each row is a check box, ticked while the mod is on, and the mod's name and version from its manifest, or its folder name if it has none. The chosen row's name is white, a mod that is on but doesn't load is red, and a mod that is off is dim. The lists' arrows right of the frame scroll it, as the mouse wheel and the Up and Down keys do. -- A framed panel from (401, 136), 195 by 250, with the chosen mod's thumbnail where it has one, fitted to a box 175 by 70 at its top; its name, version, author, description and page; the mods whose files it replaces (REPLACES FILES OF) and the mods below it that replace its own (FILES REPLACED BY), each up to two lines; and in red why it doesn't load: an archive that doesn't match its checksum file, or a mod it needs that is missing, off, or below it. The description takes the lines the rest leaves. +- A framed list of the mods in the `mods` folder, from (45, 136), 324 by 250, with nine rows 26 pixels apart. Each row is a check box, ticked while the mod is on, and the mod's name and version from its manifest, or its folder name if it has none. The chosen row's name is white, a mod that is on but doesn't load is red, a mod the catalogue offers in a newer version is gold, and a mod that is off is dim. The lists' arrows right of the frame scroll it, as the mouse wheel and the Up and Down keys do. +- A framed panel from (401, 136), 195 by 250, with the chosen mod's thumbnail where it has one, fitted to a box 175 by 70 at its top; its name, version and author; in gold, UPDATE AVAILABLE IN GET MODS with the version where the catalogue offers a newer one that this OpenReliant can run, for an archive and not for a folder copied in by hand ([Getting mods from the catalogue](../guide/modding.md#getting-mods-from-the-catalogue)); its description and page; the mods whose files it replaces (REPLACES FILES OF) and the mods below it that replace its own (FILES REPLACED BY), each up to two lines; and in red why it doesn't load: an archive that doesn't match its checksum file, or a mod it needs that is missing, off, or below it. The description takes the lines the rest leaves. - A gold box of up and down arrows (shape `0x2A`, lit with `0x2B` and `0x27`) at the foot of the gap between the frames, which moves the chosen mod up or down the order. - The settings screen's buttons: OK and MAIN MENU in the left column, and in the right column REFRESH, where RESET DEFAULTS stands on the settings screen, and CANCEL CHANGES. REFRESH reads the `mods` folder again, to find mods added or removed since OpenReliant started, and keeps the chosen mod chosen. CANCEL CHANGES puts the mods back as they were when the screen opened, or at the last REFRESH. OK and Escape end the screen, back to GAME OPTIONS. - OPTIONS, in the foot of the panel, where the chosen mod's scripts offer a page of options ([The mod's options](#the-mods-options)). It is a button of the settings screen's shapes (`0x28`, lit `0x29`) with its label right of it. @@ -341,7 +341,7 @@ Each change is written to `starlancer.ini` at once, in the section `[OpenReliant - The framed panel from (401, 136), which says CHOOSE A MOD while nothing is selected, and otherwise shows the selected mod's thumbnail, downloaded when the mod is selected and fitted into the same box as on the mods screen; its name; right under it, its status line, in gold: INSTALLED with the installed version, BY HAND where the settings file doesn't record the install, and UPDATE TO with the catalogue's version where it is newer, INSTALLED BY HAND AS A FOLDER: INSTALL WON'T REPLACE IT where a folder mod of the name is in the `mods` folder, DOWNLOADING with the percentage, CANCELING once CANCEL was pressed, or INSTALLED: RESTART TO APPLY; in red: NEEDS OPENRELIANT with the version, or why the install failed. Then its version and author; its full category, as IN SHIPS / FIGHTERS / ALLIANCE; the repository that lists it, as FROM openreliant-mods; the OpenReliant it needs, in red when it is newer than this one; its size and its description, which takes the lines left. During a download, a progress bar at the bottom of the panel, measured against the size the catalogue gives. - The settings screen's buttons: OK and MAIN MENU in the left column. In the right column, INSTALL (or UPDATE when an older version is installed) where RESET DEFAULTS is, dimmed when there is nothing to do, and RELOAD where CANCEL CHANGES is, which reads the repositories again and tries again the thumbnails that failed. While a mod downloads, one download runs at a time and the catalogue can't change under it, so RELOAD is dimmed, and INSTALL reads CANCEL while the downloading mod is chosen and is dimmed while another is. CANCEL stops the download, which deletes its part file; the panel says CANCELING until the download's thread has stopped, then shows the mod as it was before. OK and Escape go back to the mods screen, which rereads the `mods` folder if a mod was installed. -The repositories `starlancer.ini` names ([Repositories](../guide/modding.md#repositories)) are read one after another into one catalogue, where the first repository to list an id wins, and the catalogue is kept until RELOAD or until the front end closes. A mod without the keys it needs (`id`, `archive`, `sha256`, `size`), whose id can't name a mod, or whose size is over 4294967295 bytes, the most a HOG archive's 32-bit offsets reach, is skipped with a line in the log, which escapes the id; an index in a newer format fails with THE CATALOG NEEDS A NEWER OPENRELIANT. Every download goes through [`web.zig`](../../src/engine/web.zig), on a thread of its own with its own HTTP client, and over HTTPS: `http` is changed to `https` before each request and each redirect, which the download follows itself, five at most; any other scheme is refused. Each request sends `OpenReliant` as its user agent. The player's own machine (`localhost` and the names under it, `127.0.0.0/8` and `0.0.0.0` in every form the system's resolver reads, such as `127.1`, and `::1`, `::` and the IPv4 addresses mapped into IPv6) is read only for a repository whose URL names it, and for the thumbnails and archives its index links, over plain HTTP. Anywhere else, a link to the player's machine, or a redirect to it from another host, fails with THE CATALOG POINTS AT THIS COMPUTER, so that a repository on the web can't make OpenReliant send requests to the services on the player's machine. A host name that the system's resolver turns into the player's own address isn't caught, since only the link itself is checked, but such a link is read over HTTPS, so a service there gets the start of a secure connection that it can't complete, never a request. A failure says what happened: the server's name can't be found, a secure connection can't be made, the system's certificates can't be loaded, too many redirects, a bad redirect, and so on. An index or a thumbnail gives up after 60 seconds, or after 15 seconds without anything arriving, and an archive after 30 seconds without anything arriving, with THE SERVER DOESN'T ANSWER. An index is read up to 4 MiB and a thumbnail up to 2 MiB; an archive is read up to the size the catalogue gives, and a download that goes past it, or that the server announces at another size, stops at once. INSTALL downloads the archive into `.hog.part` in the `mods` folder, hashing the bytes on their way to the file. Once the archive is complete and matches the catalogue's digest, the digest is written as `.hog.sha256.part`, the archive's part is renamed to `.hog`, replacing an older copy, and the checksum file's part is renamed to `.hog.sha256`; then the install is recorded in `[OpenReliantInstalledMods]` with the repository's name, so that the loader refuses the archive without its checksum file from then on. A download that fails or doesn't match leaves nothing behind; if the checksum file can't be put in place, the archive is removed again, with the checksum file of the copy it replaced. Leaving the screen lets a download continue. CANCEL stops it: its thread cancels the request, deletes the archive's part file and ends, and only then does the screen let the install go, so that an install of the same mod started right after can't run into the part file being deleted. Closing the front end lets go of a download without waiting, so that quitting is never held up, not even by a lookup of a host's address, which nothing can interrupt: the download's thread cancels it and ends on its own, deleting an archive's part file, unless OpenReliant has ended first, in which case the next install of the mod replaces the part file. Not yet: updates shown on the mods screen ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). The GET MODS button on the mods screen uses the settings screen's shapes, at (45, 116) with its label to its right, and is shown only when the settings file names a repository. +The repositories `starlancer.ini` names ([Repositories](../guide/modding.md#repositories)) are read one after another into one catalogue, where the first repository to list an id wins, and the catalogue is kept until RELOAD or until the front end closes. A mod without the keys it needs (`id`, `archive`, `sha256`, `size`), whose id can't name a mod, or whose size is over 4294967295 bytes, the most a HOG archive's 32-bit offsets reach, is skipped with a line in the log, which escapes the id; an index in a newer format fails with THE CATALOG NEEDS A NEWER OPENRELIANT. Every download goes through [`web.zig`](../../src/engine/web.zig), on a thread of its own with its own HTTP client, and over HTTPS: `http` is changed to `https` before each request and each redirect, which the download follows itself, five at most; any other scheme is refused. Each request sends `OpenReliant` as its user agent. The player's own machine (`localhost` and the names under it, `127.0.0.0/8` and `0.0.0.0` in every form the system's resolver reads, such as `127.1`, and `::1`, `::` and the IPv4 addresses mapped into IPv6) is read only for a repository whose URL names it, and for the thumbnails and archives its index links, over plain HTTP. Anywhere else, a link to the player's machine, or a redirect to it from another host, fails with THE CATALOG POINTS AT THIS COMPUTER, so that a repository on the web can't make OpenReliant send requests to the services on the player's machine. A host name that the system's resolver turns into the player's own address isn't caught, since only the link itself is checked, but such a link is read over HTTPS, so a service there gets the start of a secure connection that it can't complete, never a request. A failure says what happened: the server's name can't be found, a secure connection can't be made, the system's certificates can't be loaded, too many redirects, a bad redirect, and so on. An index or a thumbnail gives up after 60 seconds, or after 15 seconds without anything arriving, and an archive after 30 seconds without anything arriving, with THE SERVER DOESN'T ANSWER. An index is read up to 4 MiB and a thumbnail up to 2 MiB; an archive is read up to the size the catalogue gives, and a download that goes past it, or that the server announces at another size, stops at once. INSTALL downloads the archive into `.hog.part` in the `mods` folder, hashing the bytes on their way to the file. Once the archive is complete and matches the catalogue's digest, the digest is written as `.hog.sha256.part`, the archive's part is renamed to `.hog`, replacing an older copy, and the checksum file's part is renamed to `.hog.sha256`; then the install is recorded in `[OpenReliantInstalledMods]` with the repository's name, so that the loader refuses the archive without its checksum file from then on. A download that fails or doesn't match leaves nothing behind; if the checksum file can't be put in place, the archive is removed again, with the checksum file of the copy it replaced. Leaving the screen lets a download continue. CANCEL stops it: its thread cancels the request, deletes the archive's part file and ends, and only then does the screen let the install go, so that an install of the same mod started right after can't run into the part file being deleted. Closing the front end lets go of a download without waiting, so that quitting is never held up, not even by a lookup of a host's address, which nothing can interrupt: the download's thread cancels it and ends on its own, deleting an archive's part file, unless OpenReliant has ended first, in which case the next install of the mod replaces the part file. The mods screen uses the catalogue too, to say which of its mods have an update: with CHECK FOR UPDATES on ([Newer releases](../guide/configuration.md#newer-releases)), entering it starts this reading in the background, once per run, and each of its passes takes in what has arrived, as this screen's passes do; with it off, nothing is read, but a catalogue this screen has read serves all the same. Opened from the mods screen, GET MODS chooses the mod the mods screen had chosen, unfolding its group, where the catalogue lists it. The GET MODS button on the mods screen uses the settings screen's shapes, at (45, 116) with its label to its right, and is shown only when the settings file names a repository. ## The mod's options diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ab5406c5..c006aaf7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -86,7 +86,7 @@ player doesn't launch, the launch counts as over 600 ticks, six seconds, into th | `--developer-mode` | The tools for writing mods' scripts: the scripting console, which F11 brings up where a mod has scripts, and folder mods' scripts reloading when they or their shaders are saved ([Scripting](scripting.md#the-console)) | | `--editor-link` | Listen for a mission editor or a script debugger, such as `openreliant debug`, on this computer's own address, 127.0.0.1, which can then pause the mission and stop and step its script, as the original's editor link does ([Debugging mission scripts](debugging.md)) | | `--editor-link-port ` | With `--editor-link`, the port to listen at; 22539 by default | -| `--no-update-check` | Don't check for a newer release of OpenReliant. By default it checks as it starts, logs a newer release and shows it once in the main menu ([Newer releases](#newer-releases)) | +| `--no-update-check` | Don't check for a newer release of OpenReliant, and don't read the catalogue of mods on the mods screen. By default it checks as it starts, logs a newer release and shows it once in the main menu, and the mods screen says which mods have an update ([Newer releases](#newer-releases)) | | `--screenshot ` | Draw one frame, with the camera settled, to a PNG, and quit; the controls, the `[OpenReliant]` settings and the details in `[Device]` are not read, so that it comes out the same each time | | `--screenshot-ticks ` | With `--screenshot`, how many game ticks to run first, one a frame, so that the scene plays out; 2 by default | | `--seed ` | Start each mission's random numbers from this seed, so that a run comes out the same each time, for testing; by default, as in the game, from the clock as the mission starts, and from a fixed seed with `--screenshot` | @@ -197,7 +197,7 @@ Samples=8 | `Compressor` | 1 or 0, which AUDIO's COMPRESSOR sets | `--no-compressor` | | `DeveloperMode` | 1 or 0; 0 by default | `--developer-mode` | | `TextureCompression` | 1 or 0; 1 by default: the mods' pictures compressed for the GPU ([Modding](modding.md#compression)) | `--uncompressed-textures` | -| `UpdateCheck` | 1 or 0, which VIDEO's CHECK FOR UPDATES sets: whether OpenReliant checks for a newer release as it starts ([Newer releases](#newer-releases)) | `--no-update-check` | +| `UpdateCheck` | 1 or 0, which VIDEO's CHECK FOR UPDATES sets: whether OpenReliant checks for a newer release as it starts, and whether the mods screen reads the catalogue of mods for updates to the installed mods ([Newer releases](#newer-releases)) | `--no-update-check` | | `UpdateSeen` | The newest release the main menu has shown you, such as `0.10.0`, which the main menu writes | | The mods screen keeps which mods are on, and the order they load in, in a section of its own, `[OpenReliantMods]` ([The mods screen](modding.md#the-mods-screen)). The GET MODS screen reads the repositories of mods on the web from `[OpenReliantModRepositories]`, one line for each: a name of your choosing, and the URL of its `mods.json` ([Repositories](modding.md#repositories)). Without the section, it reads OpenReliant's own repository; an empty section hides GET MODS. It records the mods it installs in `[OpenReliantInstalledMods]`, one line for each: the archive's name, and the repository it came from. OpenReliant refuses a recorded archive whose checksum file is missing or doesn't match ([Getting mods from the catalogue](modding.md#getting-mods-from-the-catalogue)). @@ -220,4 +220,6 @@ As it starts, OpenReliant asks GitHub's API for its latest release, in the backg When a release newer than yours is out, the log says so, with the address of the release's page, and the main menu shows it once, in a dialog like QUIT's: YES opens the page in your default web browser, and NO closes the dialog. `UpdateSeen` keeps the release it showed you, so the dialog comes back only for a newer one. A build from source counts as the release it was built from, and pre-releases are never shown. -To turn the check off, untick CHECK FOR UPDATES on the settings screen's VIDEO tab, which takes effect at the next start, set `UpdateCheck=0`, or start with `--no-update-check`. A run with `--screenshot` never checks. +The same setting lets the mods screen read the catalogue of mods on the web as it opens, once per run, to say which of your mods have an update ([The mods screen](modding.md#the-mods-screen)). The repository's server sees your address then, as GitHub does for the release check. Nothing is read while GET MODS is hidden, that is, while the settings file names no repository ([Repositories](modding.md#repositories)). + +To turn both off, untick CHECK FOR UPDATES on the settings screen's VIDEO tab, set `UpdateCheck=0`, or start with `--no-update-check`. The release check takes the change at the next start, the mods screen at once. A run with `--screenshot` never checks. diff --git a/docs/guide/modding.md b/docs/guide/modding.md index 18116285..4a40ba39 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -160,6 +160,12 @@ again, to find mods you've added or removed while the screen is open. replaces (REPLACES FILES OF), and the mods below it that replace its own (FILES REPLACED BY). - A mod that won't load is listed in red, and the panel says why: a damaged archive ([Checksums](#checksums)), or a mod it needs (`Requires`) that is missing, off, or below it. +- A mod the catalogue on the web offers in a newer version is listed in gold, and the panel says + UPDATE AVAILABLE IN GET MODS with the version. With CHECK FOR UPDATES ticked on the VIDEO tab + ([Newer releases](configuration.md#newer-releases)), the screen reads the catalogue in the + background as it opens, once per run; unticked, it reads nothing, and shows what GET MODS has + read. GET MODS opens the catalogue on the chosen mod ([Getting mods from the + catalogue](#getting-mods-from-the-catalogue)). The screen keeps the order and which mods are off in `starlancer.ini` in the game's folder, in its own section, one line for each mod: the mod's name in the `mods` folder, and 1 if it's on or 0 if @@ -219,8 +225,7 @@ CATALOG POINTS AT THIS COMPUTER. A server that stops answering fails with THE SE an index or a thumbnail gives up after a minute, or after 15 seconds with nothing arriving, and an archive after 30 seconds with nothing arriving. RELOAD then reads the repositories again, and tries again the thumbnails that failed. Quitting OpenReliant never waits for a download. The screen lists -at most 127 mods. The mods screen doesn't say yet which of its mods have an update -([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). +at most 127 mods. OpenReliant records the mods GET MODS installs in `starlancer.ini`, in the section `[OpenReliantInstalledMods]`, one line for each: the archive's name, and the repository it came diff --git a/src/engine/game/interface/mod_catalogue.zig b/src/engine/game/interface/mod_catalogue.zig index cf436ac9..832f51f6 100644 --- a/src/engine/game/interface/mod_catalogue.zig +++ b/src/engine/game/interface/mod_catalogue.zig @@ -18,8 +18,10 @@ //! listed in red and can't be installed, and a folder mod of the same name, copied in by hand, is //! left as it is: the loader would load both it and the archive. //! -//! Not yet: updates shown on the mods screen itself -//! ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)). +//! The mods screen uses the catalogue too, to say which of its mods have an update: as it opens, +//! with CHECK FOR UPDATES on, it has this screen read the repositories in the background +//! (`readInBackground`, `continueReading`), and asks it which version the catalogue offers for +//! each mod (`updateFor`). The catalogue read once serves both screens until RELOAD. //! //! **Improvement:** the original has no mods, and never reads the web. @@ -397,6 +399,14 @@ pub const ModCatalogue = struct { return writer.buffered(); } + /// Starts reading the repositories for the mods screen, while this screen isn't shown, unless + /// the catalogue is read already or being read. The mods screen then calls `continueReading` + /// each pass, as this screen's own passes do. + pub fn readInBackground(screen: *ModCatalogue, context: Context) void { + screen.running = context.source.version; + if (screen.loaded == null and screen.reading == null) screen.startReading(context); + } + /// Starts reading the repositories the settings file names, the first one first (`readNext`), /// into a new catalogue. Without a repository, nothing is read: GET MODS is hidden then. fn startReading(screen: *ModCatalogue, context: Context) void { @@ -491,15 +501,53 @@ pub const ModCatalogue = struct { if (entry.needsLater(screen.running) != null) return .none; return switch (screen.statusOf(entry)) { .absent => .install, - .installed => |installed| { - if (installed.folder) return .none; - const have = bigfile.mods.parseVersion(installed.version orelse return .install) orelse return .install; - const offered = entry.semantic() orelse return .install; - return if (offered.order(have) == .gt) .update else .none; - }, + .installed => |installed| if (installed.folder) .none else offeredFor(entry, installed.version), }; } + /// What INSTALL does for the catalogue's `entry` where the mod is installed as an archive in the + /// version `installed`: update it where the catalogue's version is newer, install it again where + /// either version can't be read, and nothing otherwise. + fn offeredFor(entry: Entry, installed: ?[]const u8) Action { + const have = bigfile.mods.parseVersion(installed orelse return .install) orelse return .install; + const offered = entry.semantic() orelse return .install; + return if (offered.order(have) == .gt) .update else .none; + } + + /// The version the catalogue offers for `mod`, a mod in the `mods` folder, where UPDATE would + /// install it: the catalogue lists the mod's id in a newer version that this OpenReliant can + /// run, and the mod is an archive, not a folder copied in by hand. Null otherwise, and until + /// the catalogue is read. The mods screen shows it. + pub fn updateFor(screen: ModCatalogue, mod: *const Mod) ?[]const u8 { + if (mod.source == .folder) return null; + for (screen.entries()) |entry| { + if (!std.ascii.eqlIgnoreCase(entry.id, mod.qualifier())) continue; + if (entry.needsLater(screen.running) != null) return null; + return if (offeredFor(entry, mod.about(.version)) == .update) entry.version else null; + } + return null; + } + + /// Chooses the mod whose id is `qualifier`, unfolding its group and scrolling the list to it, + /// where the catalogue lists it; otherwise the choice stays. The mods screen asks for the mod + /// it had chosen as GET MODS opens. + pub fn chooseMod(screen: *ModCatalogue, qualifier: []const u8) void { + const index: u8 = for (screen.entries(), 0..) |entry, index| { + if (std.ascii.eqlIgnoreCase(entry.id, qualifier)) break @intCast(index); + } else return; + screen.chosen = index; + const group = groupOf(screen.entries()[index]); + for (screen.groups[0..screen.group_count], 0..) |name, at| { + if (std.ascii.eqlIgnoreCase(name, group)) screen.folded.unset(at); + } + screen.makeRows(); + screen.list.rows.count = screen.row_count; + const row: u8 = for (screen.rows[0..screen.row_count], 0..) |row, at| { + if (row == .mod and row.mod == index) break @intCast(at); + } else return; + while (screen.list.place(row) == null) screen.list.rows.scroll(if (row < screen.list.rows.first) .up else .down); + } + /// What INSTALL does for the selected mod; nothing when no mod is selected. fn action(screen: ModCatalogue) Action { const index = screen.chosen orelse return .none; @@ -516,7 +564,7 @@ pub const ModCatalogue = struct { /// downloads finish, then handles the input. Returns how the screen ends, once it does. pub fn frame(screen: *ModCatalogue, context: Context) ?Leave { screen.running = context.source.version; - screen.takeCatalogue(context); + screen.continueReading(context); screen.takeInstall(context); screen.takeThumbnail(context.source); if (context.keyboard.pressed(input.scan.escape, .none, true)) return .{ .mods = .{ .installed = screen.installed_any } }; @@ -548,8 +596,10 @@ pub const ModCatalogue = struct { } /// Once the index of the repository being read has downloaded, or has failed to, adds its mods - /// to the catalogue being gathered, and goes on to the next repository. - fn takeCatalogue(screen: *ModCatalogue, context: Context) void { + /// to the catalogue being gathered, and goes on to the next repository. Nothing happens while + /// no repository is being read. The mods screen calls it too, each pass, while it reads the + /// catalogue in the background. + pub fn continueReading(screen: *ModCatalogue, context: Context) void { const index = screen.reading orelse return; const gpa = context.source.gpa; switch (screen.fetch.state()) { @@ -1300,6 +1350,33 @@ test "CANCEL stops the download of the chosen mod, and nothing is left of it" { try std.testing.expectEqual(Leave{ .mods = .{ .installed = false } }, fixture.click(Button.ok.rect().centre()).?); } +test "the mods screen asks which version the catalogue offers for a mod, and GET MODS opens on it" { + var fixture: Fixture = undefined; + try fixture.initOffline(); + defer fixture.deinit(); + const screen = &fixture.screen; + // Alpha, an archive in 1.2, has 1.3 in the catalogue. Gamma is a folder copied in by hand, so + // it has none, though the catalogue offers 2.0. + var alpha: ?*const Mod = null; + var gamma: ?*const Mod = null; + for ([_][]const Mod{ fixture.mods.list, fixture.mods.off, fixture.mods.damaged, fixture.mods.unmet }) |each| for (each) |*mod| { + if (std.mem.eql(u8, mod.name, "alpha.hog")) alpha = mod; + if (std.mem.eql(u8, mod.name, "gamma")) gamma = mod; + }; + try std.testing.expectEqualStrings("1.3", screen.updateFor(alpha.?).?); + try std.testing.expectEqual(null, screen.updateFor(gamma.?)); + // Opened on Beta, in the folded SHIPS group, the screen chooses it and unfolds the group. + screen.fold(0); + try std.testing.expectEqual(3, screen.row_count); + screen.chooseMod("BETA"); + try std.testing.expectEqual(1, screen.chosen.?); + try std.testing.expectEqual(5, screen.row_count); + try std.testing.expect(!screen.folded.isSet(0)); + // An id the catalogue doesn't list changes nothing. + screen.chooseMod("nothing"); + try std.testing.expectEqual(1, screen.chosen.?); +} + test sizeText { var buffer: [32]u8 = undefined; try std.testing.expectEqualStrings("30.8 MB", sizeText(&buffer, 32297124)); diff --git a/src/engine/game/interface/mod_manager.zig b/src/engine/game/interface/mod_manager.zig index ab8d6c49..6f2f59e2 100644 --- a/src/engine/game/interface/mod_manager.zig +++ b/src/engine/game/interface/mod_manager.zig @@ -13,7 +13,15 @@ //! settings screen, reads the `mods` folder again, to find the mods added or removed since OpenReliant //! started. CANCEL CHANGES puts the mods back as they were when the screen opened. OPTIONS, in the //! panel, opens the page of options the chosen mod's scripts offer, where it has one -//! (`mod_options`). +//! (`mod_options`). GET MODS, above the list, opens the catalogue of mods on the web +//! (`mod_catalogue`) on the chosen mod. +//! +//! The screen says which of its mods have an update in the catalogue +//! ([#1041](https://github.com/OpenReliant/openreliant/issues/1041)): with CHECK FOR UPDATES on, it +//! has the GET MODS screen read the repositories in the background as it opens, once per run, +//! and a mod the catalogue lists in a newer version is gold in the list, with UPDATE AVAILABLE in +//! its panel. With CHECK FOR UPDATES off, nothing is read, but a catalogue GET MODS has read serves +//! all the same. //! //! **Improvement:** the original can't load mods. @@ -36,6 +44,7 @@ const Arrow = canvas_module.Arrow; const settings = @import("settings.zig"); const widgets = settings.widgets; const mod_options = @import("mod_options.zig"); +const mod_catalogue = @import("mod_catalogue.zig"); const Mod = bigfile.mods.Mod; const Order = bigfile.mods.Order; @@ -310,6 +319,10 @@ pub const Context = struct { ticks: u32, /// The mods, and where to read them again. source: Source, + /// The GET MODS screen, whose catalogue says which mods have an update; none says nothing. + catalogue: ?*mod_catalogue.ModCatalogue = null, + /// CHECK FOR UPDATES: whether the screen may have the catalogue read from the web. + update_check: bool = false, }; /// How the screen ends. @@ -318,8 +331,9 @@ pub const Leave = union(enum) { end: settings.End, /// OPTIONS: on to the options of the mod of this name. options: []const u8, - /// GET MODS: on to the GET MODS screen. - catalogue, + /// GET MODS: on to the GET MODS screen, on the mod of this id (`Mod.qualifier`) where one is + /// chosen. + catalogue: ?[]const u8, }; /// The mods, and where to find them again. @@ -367,8 +381,12 @@ pub const ModManager = struct { thumbnail: ?*srtexture.Image = null, /// The rows whose mods hold files of the chosen mod's names; kept up to date after each pass. conflicts: Conflicts = .{}, + /// The GET MODS screen, whose catalogue the list and the panel show the updates from; kept up + /// to date as the screen opens and each pass begins. + catalogue: ?*const mod_catalogue.ModCatalogue = null, - /// Opens the screen: the mods OpenReliant found, in the order the settings file gives them. + /// Opens the screen: the mods OpenReliant found, in the order the settings file gives them, and + /// has the catalogue read where CHECK FOR UPDATES allows it. pub fn enter(screen: *ModManager, context: Context) void { screen.release(); screen.* = .{ .loaded = context.source.loaded.list, .thumbnails = screen.thumbnails }; @@ -379,6 +397,38 @@ pub const ModManager = struct { screen.has_options = screen.optionsOf(context.source) != null; screen.has_catalogue = bigfile.catalogue.Repositories.of(context.settings_file.profile).any(); screen.showThumbnail(context.source); + screen.readCatalogue(context, .start); + } + + /// Keeps the GET MODS screen for the updates, and has it read the catalogue in the background + /// (`mod_catalogue.readInBackground`), where the screen opens (`.start`) with CHECK FOR UPDATES + /// on and a repository to read. Each pass then takes in what has arrived + /// (`mod_catalogue.continueReading`), so a reading GET MODS left behind goes on too. + fn readCatalogue(screen: *ModManager, context: Context, step: enum { start, go_on }) void { + const catalogue = context.catalogue orelse return; + screen.catalogue = catalogue; + const catalogue_context = catalogueContext(context); + if (step == .start and context.update_check and screen.has_catalogue) catalogue.readInBackground(catalogue_context); + catalogue.continueReading(catalogue_context); + } + + /// What the GET MODS screen reads of a pass of this screen: the same. + fn catalogueContext(context: Context) mod_catalogue.Context { + return .{ .pointer = context.pointer, .keyboard = context.keyboard, .settings_file = context.settings_file, .ticks = context.ticks, .source = context.source }; + } + + /// The version the catalogue offers for the mod of `row`, where it is newer than the mod's + /// (`mod_catalogue.updateFor`); null without a catalogue. + fn updateFor(screen: ModManager, row: Row) ?[]const u8 { + const catalogue = screen.catalogue orelse return null; + return catalogue.updateFor(row.mod); + } + + /// The id of the chosen mod (`Mod.qualifier`), which GET MODS opens on; null when none is + /// chosen. + fn chosenQualifier(screen: ModManager) ?[]const u8 { + const row = screen.chosen orelse return null; + return screen.rows[row].mod.qualifier(); } /// Lets go of the thumbnails, and what REFRESH opened, as the front end closes. @@ -475,6 +525,7 @@ pub const ModManager = struct { screen.list.scrollBy(pointer.wheel, context.keyboard, context.ticks); screen.has_options = screen.optionsOf(context.source) != null; screen.has_catalogue = bigfile.catalogue.Repositories.of(context.settings_file.profile).any(); + screen.readCatalogue(context, .go_on); const under = screen.itemAt(pointer.at) orelse return null; if (!pointer.down) { screen.lit = under; @@ -489,7 +540,7 @@ pub const ModManager = struct { .cancel_changes => try screen.cancel(context), }, .options => if (screen.optionsOf(context.source)) |mod| return .{ .options = mod }, - .catalogue => return .catalogue, + .catalogue => return .{ .catalogue = screen.chosenQualifier() }, .move => |way| try screen.move(way, context), .scroll => |way| { screen.list.scrollHeld(way, context.ticks); @@ -611,7 +662,8 @@ pub const ModManager = struct { } /// The rows shown, each a check box and the mod's name, the chosen one white, one that is on but - /// doesn't load red, a mod that is off dim, and the arrows, the one under the pointer lit. + /// doesn't load red, one with an update in the catalogue gold, a mod that is off dim, and the + /// arrows, the one under the pointer lit. fn drawList(screen: ModManager, canvas: Canvas, art: *hud.Art) canvas_module.Error!void { if (screen.count == 0) for (empty_notes) |note| try note.write(canvas, canvas.fonts.small, canvas_module.blue); const loading = screen.loads(); @@ -622,7 +674,8 @@ pub const ModManager = struct { var named: [name_buffer]u8 = undefined; const is_chosen = if (screen.chosen) |chosen| chosen == at else false; const fails = row.damaged() or (row.on and !loading.isSet(at)); - const colour = if (fails) canvas_module.red else if (is_chosen) canvas_module.white else canvas_module.blue; + const updatable = screen.updateFor(row) != null; + const colour = if (fails) canvas_module.red else if (is_chosen) canvas_module.white else if (updatable) canvas_module.gold else canvas_module.blue; try canvas.dimmedUnless(row.on).wrapped(canvas.fonts.small, box.label(.{ .words = "" }).at, nameOf(&named, row), colour, .left, .{ .width = name_width, .height = row_spacing, .most = 1 }); } try arrows.draw(canvas, art, screen.litArrow(.scroll)); @@ -662,6 +715,12 @@ pub const ModManager = struct { try canvas.wrapped(font, .{ x, y }, line, canvas_module.blue, .left, details_lines); y += details_lines.height; }; + // The update the catalogue offers, in gold like RESTART TO APPLY, so that it stands out. + if (screen.updateFor(row)) |version| { + const line = std.mem.print(&buffer, "UPDATE {s} AVAILABLE IN GET MODS", .{version}) catch "UPDATE AVAILABLE IN GET MODS"; + try canvas.wrapped(font, .{ x, y }, line, canvas_module.gold, .left, note_lines); + y += note_lines.height * @as(i32, @intCast(note_lines.count(font, line))); + } // What goes below the description: its page, its conflicts and why it doesn't load. var texts: [4][name_buffer]u8 = undefined; var notes: [4]Note = undefined; @@ -824,6 +883,10 @@ const Fixture = struct { screen: ModManager = .{}, /// The options of the mod `beta`. options: mod_options.Recorder, + /// The GET MODS screen, and whether CHECK FOR UPDATES is on, which is off unless a test turns + /// it on, so that nothing is read from the web. + catalogue: mod_catalogue.ModCatalogue = .{}, + update_check: bool = false, fn init(fixture: *Fixture, text: []const u8) !void { const gpa = std.testing.allocator; @@ -847,12 +910,15 @@ const Fixture = struct { fixture.mods = try .openOrdered(gpa, io, fixture.tmp.dir, null, .{ .profile = fixture.file.profile }); fixture.keyboard = .{}; fixture.options = .{ .mod = "beta", .page = .{ .title = "BETA", .options = &mod_options.test_options } }; + fixture.catalogue = .{}; + fixture.update_check = false; fixture.screen = .{}; fixture.screen.enter(fixture.context(.{})); } fn deinit(fixture: *Fixture) void { fixture.screen.deinit(); + fixture.catalogue.deinit(); fixture.mods.close(std.testing.allocator); fixture.arena.deinit(); fixture.tmp.cleanup(); @@ -866,7 +932,7 @@ const Fixture = struct { .game = fixture.tmp.dir, .version = null, .pages = fixture.options.pages(), - } }; + }, .catalogue = &fixture.catalogue, .update_check = fixture.update_check }; } /// A click at `at`: the pointer there with its button up, then down. @@ -1127,7 +1193,58 @@ test "GET MODS opens the catalogue screen when the settings file names a reposit fixture.file.profile = .{ .text = "" }; fixture.screen.enter(fixture.context(.{})); try std.testing.expectEqual(Item.catalogue, fixture.screen.itemAt(catalogue_rect.centre()).?); - try std.testing.expectEqual(Leave.catalogue, fixture.click(catalogue_rect.centre()).?); + // It opens on the chosen mod. + try std.testing.expectEqualStrings("alpha", fixture.click(catalogue_rect.centre()).?.catalogue.?); + // Entered with CHECK FOR UPDATES off, the screen reads nothing from the web. + try std.testing.expectEqual(null, fixture.catalogue.reading); + try std.testing.expectEqual(null, fixture.catalogue.loaded); +} + +test "the mods screen says which mods the catalogue offers an update for" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // The catalogue offers delta, an archive, in a newer version, and alpha, a folder, too. + const digest = "abababababababababababababababababababababababababababababababab"; + const index = "{\"mods\": [{\"id\": \"delta\", \"version\": \"2.0\", \"size\": 1, \"archive\": \"/delta.hog\", \"sha256\": \"" ++ digest ++ "\"}, {\"id\": \"alpha\", \"version\": \"2.0\", \"size\": 1, \"archive\": \"/alpha.hog\", \"sha256\": \"" ++ digest ++ "\"}]}"; + var server: bigfile.catalogue.testing.Server = undefined; + try server.start(io, &.{.{ .path = "/mods.json", .body = index }}); + defer server.stop(gpa); + var url: [128]u8 = undefined; + var text: [256]u8 = undefined; + var fixture: Fixture = undefined; + try fixture.init(try std.mem.print(&text, "[OpenReliantModRepositories]\ntest={s}\n", .{server.url(&url, "/mods.json")})); + defer fixture.deinit(); + const screen = &fixture.screen; + try hog.testing.write(gpa, io, fixture.tmp.dir, "mods/delta.hog", &.{.{ .name = "mod.ini", .data = "[Mod]\nName=Delta mod\nVersion=1.0\n" }}); + // REFRESH lists the archive, after alpha and beta by name. + try screen.refresh(fixture.context(.{})); + try std.testing.expectEqualStrings("delta.hog", screen.rows[2].mod.name); + // With CHECK FOR UPDATES off, nothing is read, and no update is known. + try std.testing.expectEqual(null, fixture.catalogue.reading); + try std.testing.expectEqual(null, screen.updateFor(screen.rows[2])); + // With it on, entering the screen reads the repositories in the background, and the passes + // take the catalogue in once it has arrived. + fixture.update_check = true; + screen.enter(fixture.context(.{})); + try screen.refresh(fixture.context(.{})); + try std.testing.expectEqual(0, fixture.catalogue.reading.?); + try std.testing.expectEqual(null, screen.updateFor(screen.rows[2])); + _ = try bigfile.catalogue.testing.wait(&fixture.catalogue.fetch); + try std.testing.expectEqual(null, screen.frame(fixture.context(.{}))); + try std.testing.expectEqual(null, fixture.catalogue.reading); + try std.testing.expect(fixture.catalogue.loaded != null); + // Delta has an update; alpha, a folder, doesn't, nor the mods the catalogue doesn't list. + try std.testing.expectEqualStrings("2.0", screen.updateFor(screen.rows[2]).?); + try std.testing.expectEqual(null, screen.updateFor(screen.rows[0])); + try std.testing.expectEqual(null, screen.updateFor(screen.rows[1])); + // Entered again, the screen doesn't read the catalogue again, and the update still shows. + screen.enter(fixture.context(.{})); + try screen.refresh(fixture.context(.{})); + try std.testing.expectEqual(null, fixture.catalogue.reading); + try std.testing.expectEqualStrings("2.0", screen.updateFor(screen.rows[2]).?); + // GET MODS opens on the chosen mod. + try std.testing.expectEqual(null, fixture.click(nameRect(2).centre())); + try std.testing.expectEqualStrings("delta", fixture.click(catalogue_rect.centre()).?.catalogue.?); } test "a mod the settings file can't keep stays on and in place" { diff --git a/src/engine/game/interface/settings.zig b/src/engine/game/interface/settings.zig index 00c55c2a..485e1c89 100644 --- a/src/engine/game/interface/settings.zig +++ b/src/engine/game/interface/settings.zig @@ -146,7 +146,8 @@ pub const Own = struct { /// (`camera.factorsFor`). field_of_view: f32 = camera.original_field_of_view, /// Whether OpenReliant checks for a newer release of itself as it starts, which the - /// main menu shows the player (`main_menu.Release`). + /// main menu shows the player (`main_menu.Release`), and whether the mods screen reads + /// the catalogue of mods for updates to the installed mods (`mod_manager`). update_check: bool = true, }; diff --git a/src/engine/game/interface/settings/video.zig b/src/engine/game/interface/settings/video.zig index b2106fd7..edde7a45 100644 --- a/src/engine/game/interface/settings/video.zig +++ b/src/engine/game/interface/settings/video.zig @@ -11,7 +11,9 @@ //! **Improvement:** RESOLUTION chooses the size OpenReliant draws its frames at, a share of the //! window's own, where the game's chooses the display's mode. FULL SCREEN, VSYNC, FRAME RATE LIMIT, //! FIELD OF VIEW and CHECK FOR UPDATES are OpenReliant's own. They change at once, as the driver -//! applies them (`settings.Own`), but for CHECK FOR UPDATES, which takes effect at the next start. +//! applies them (`settings.Own`), but for CHECK FOR UPDATES, whose check for a newer release takes +//! effect at the next start; its reading of the catalogue for updates to the mods, on the mods +//! screen, takes effect at once. //! //! Not ported: 3D RENDER MODE, which chooses the game's Direct3D device; and TEXTURE DETAIL, GRAPHIC //! DETAIL and LIGHT MAPS, at whose highest OpenReliant draws diff --git a/src/engine/genilib/interf.zig b/src/engine/genilib/interf.zig index 25adbd7e..68dad8d1 100644 --- a/src/engine/genilib/interf.zig +++ b/src/engine/genilib/interf.zig @@ -325,6 +325,9 @@ pub const Interface = struct { scripted_shown: ?Screen = null, /// The mod the mods screen has opened the options of, while they are shown. options_of: []const u8 = "", + /// The id of the mod the mods screen had chosen as it opened GET MODS, which GET MODS chooses + /// as it is entered; none once it has. + catalogue_of: ?[]const u8 = null, pilot_roster: pilot_roster.Roster = .{}, saved_games: saved_games.SavedGames = .{}, /// The pilot the roster sets, which every mission the front end starts is flown by. @@ -442,7 +445,10 @@ pub const Interface = struct { front.options_of = mod; front.screen = .mod_options; }, - .catalogue => front.screen = .mod_catalog, + .catalogue => |chosen| { + front.catalogue_of = chosen; + front.screen = .mod_catalog; + }, } return null; }, @@ -655,7 +661,12 @@ pub const Interface = struct { if (front.mods_installed) front.mod_manager.refresh(mods_context) catch |err| log.warn("the mods folder can't be read again: {s}", .{@errorName(err)}); front.mods_installed = false; }, - .mod_catalog => if (context.settings) |settings_file| if (context.mods) |mods| front.mod_catalogue.enter(catalogueContext(front, context, settings_file, mods, front.pointer)), + .mod_catalog => if (context.settings) |settings_file| if (context.mods) |mods| { + front.mod_catalogue.enter(catalogueContext(front, context, settings_file, mods, front.pointer)); + // On the mod the mods screen had chosen, where the catalogue lists it. + if (front.catalogue_of) |chosen| front.mod_catalogue.chooseMod(chosen); + front.catalogue_of = null; + }, .mod_options => if (context.mods) |mods| { const shown = front.mod_options.enter(front.options_of, optionsContext(front, context, mods, front.pointer)); if (!shown) front.screen = .mods; @@ -779,8 +790,11 @@ fn settingsContext(front: *const Interface, context: Context, settings_file: *pr } /// What a pass of the mods screen reads, with the pointer at `pointer`. -fn modsContext(front: *const Interface, context: Context, settings_file: *profile.File, source: mod_manager.Source, pointer: canvas.Pointer) mod_manager.Context { - return .{ .pointer = pointer, .keyboard = &context.devices.keyboard, .settings_file = settings_file, .ticks = front.ticks, .source = source }; +fn modsContext(front: *Interface, context: Context, settings_file: *profile.File, source: mod_manager.Source, pointer: canvas.Pointer) mod_manager.Context { + // The mods screen reads the catalogue through the GET MODS screen, where CHECK FOR UPDATES is + // on; without OpenReliant's options, as in the tests, it reads nothing from the web. + const update_check = if (context.own) |own| own.display().chosen.update_check else false; + return .{ .pointer = pointer, .keyboard = &context.devices.keyboard, .settings_file = settings_file, .ticks = front.ticks, .source = source, .catalogue = &front.mod_catalogue, .update_check = update_check }; } /// What a pass of the GET MODS screen reads, with the pointer at `pointer`. diff --git a/src/openreliant/settings.zig b/src/openreliant/settings.zig index 87ada444..93e9074b 100644 --- a/src/openreliant/settings.zig +++ b/src/openreliant/settings.zig @@ -257,7 +257,8 @@ pub const Own = struct { field_of_view: ?*f32 = null, ui_scale: ?*engine.game.hud.UiScale = null, /// Whether OpenReliant checks for a newer release as it starts, as chosen, which takes effect - /// at the next start (`updates.zig`). + /// at the next start (`updates.zig`), and whether the mods screen reads the catalogue of mods + /// for updates to the installed mods, which takes effect at once (`mod_manager`). update_check: bool = true, pub const Display = struct {