From 6c96e1db5a8283540e91848faf8e28b13445115f Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Thu, 15 Oct 2026 02:48:17 +0200 Subject: [PATCH 1/2] feat: the editor link's live scene, timeline and console, and --mods The editor keeps its live scene, its timeline and Mod mode's console with as little added to the link as they need. - The live scene reads where the ships are from Mission file, which the game already keeps up each frame as the original does, and their orders and targets from State. - The timeline reads the triggers fired and the commands run since the last State, from State. - The console takes the one new message, Console line, run as if typed into the game's console. The console's answers now go to the log too. - Playing with a chosen set of mods needs no message: --mods loads only the mods it names, in that order, for that run. Closes #1059 Closes #1207 Closes #1201 Closes #1202 --- docs/engine/editor-link.md | 55 +++++-- docs/guide/configuration.md | 1 + docs/guide/modding.md | 4 + docs/guide/scripting.md | 3 + src/engine/game/bigfile/mods.zig | 173 ++++++++++++++++----- src/engine/game/mission/editor.zig | 242 +++++++++++++++++++++++++++-- src/engine/link.zig | 193 ++++++++++++++++++++--- src/engine/vm/editor.zig | 43 ++++- src/engine/vm/machine.zig | 4 +- src/engine/vm/triggers.zig | 3 +- src/openreliant/main.zig | 26 +++- src/openreliant/options.zig | 40 ++++- src/scripting/console.zig | 37 ++++- 13 files changed, 724 insertions(+), 100 deletions(-) diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index f4e1043d..6b85ce78 100644 --- a/docs/engine/editor-link.md +++ b/docs/engine/editor-link.md @@ -268,8 +268,10 @@ takes none. ### What it does -- The editor's messages wait while no mission plays, as the original's wait in its block for the - next mission's start. Each attempt reads the mission's file again, so the flags last for one +- The original's messages wait while no mission plays, as they wait in the original's block for the + next mission's start, and the messages after one of them wait with it. OpenReliant's own are + taken as they come: a console line runs, and a request for the mission's file or the script's + state is passed over. Each attempt reads the mission's file again, so the flags last for one attempt: an editor sends them again once the file comes with each `Mission started`, and the start parts wait for them. - The link opens as the game starts, so that an editor waiting for the game is there before the @@ -321,13 +323,14 @@ still read. | Tag | Message | Payload | Sent | Why | |---|---|---|---|---| -| `0x100` | Hello | `protocol` (1), `openreliant` (its version), `game` (`StarLancer`), `vm` (the script VM's dialect, `starlancer`) | By the game, as an editor connects | The original's editor worked only with StarLancer. A client learns which game and script dialect it's linked to, and the version of these messages | +| `0x100` | Hello | `protocol` (2), `openreliant` (its version), `game` (`StarLancer`), `vm` (the script VM's dialect, `starlancer`) | By the game, as an editor connects | The original's editor worked only with StarLancer. A client learns which game and script dialect it's linked to, and the version of these messages | | `0x101` | Mission started | `number`, `file` (such as `mission1.dte`) | By the game, as a mission's script starts, and to an editor that connects while one plays | The original's messages never say which mission plays: its editor started its own. Tag `0x1B` says only whether the first thread has finished | | `0x102` | Mission ended | None | By the game, as the mission ends | The original's messages don't say when a mission ends | | `0x103` | Get mission | None | By the editor | Asks for `Mission file` | | `0x104` | Mission file | The mission's file as the game runs it, its globals as they stand | By the game, right after `Mission started`, and as the editor asks | The original's editor had its own copy of the mission, as an editor that builds the mission has. A client without one, such as `openreliant debug`, reads the script here, and an editor checks that the game plays its build | | `0x105` | Get state | None | By the editor | Asks for `State` | -| `0x106` | State | The script's state, below | By the game, as the editor asks | The original's debugger hears only where the script stopped (`0x1A`). None of its messages carries the threads, the globals or the game's variables | +| `0x106` | State | The script's state, below | By the game, as the editor asks | The original's debugger hears only where the script stopped (`0x1A`). None of its messages carries the threads, the globals, the game's variables, the ships' orders or what the script did | +| `0x107` | Console line | `line`: a line for the game's console | By the editor | The original has no mods, and no console to run their scripts in | `State` gives, a line each: @@ -341,8 +344,26 @@ still read. | `thread.N.trigger` | The trigger that started it, where one did | | `global.N` | Global `N`'s value | | `variable.NAME` | The game's variable `NAME`, such as `mission_over`; one with no name by its number, where it isn't 0 | - -Values are the script's words, unsigned. +| `ship.N.order` | The current order of the mission's ship `N`, by its name, such as `fight`, or its number where it has none; `none` for no order. Only the ships in play are given: made, and not destroyed | +| `ship.N.target` | The slot of the ship that order aims at, where it aims at one, which is a ship's index for the mission's ships | +| `event.N.kind`, `event.N.index` | What the script did since the last `State`, in order from 0: a trigger that fired (`trigger`), by its index in the trigger list, or a command that ran (`command`), by its number in the catalogue | +| `event.N.time` | When it happened, on the script's clock, in milliseconds | +| `events_dropped` | How many events were left out, where more than 512 came between two states. The oldest go first | + +Values are the script's words, unsigned, except the orders' names. The game keeps the events only +while an editor is linked, and forgets them once a `State` has given them. + +`Mission file` gives where the ships are: the game keeps each of the mission's ships' run-time +place and heading in its record, from its object, every frame, as the original does +(`mission_ships_sync`), and marks a destroyed one in its flags +([The mission file](../formats/dte.md)). With the orders from `State`, that's the live scene. + +`Console line` runs its line in the game's console as if typed into it +([The console](../guide/scripting.md#the-console)): one of the console's commands, such as +`reload`, or Luau in the context the console is in. The console is there in the developer mode, +where a mod has scripts; without it, the game logs that it passes over the line. What the console +answers goes to the game's log, `openreliant.log` in the game's folder, under `console`, next to +what the scripts print ([Log messages](../guide/modding.md#log-messages)). ### Adding a message @@ -382,9 +403,8 @@ needs that OpenReliant doesn't carry yet: - the other games' script dialects, which `Hello`'s `vm` names ([#1017](https://github.com/OpenReliant/openreliant/issues/1017)). -No message gives a live scene, each object's place, orientation and order as the game plays -([#1059](https://github.com/OpenReliant/openreliant/issues/1059)). A message for it would have to -make its case ([Adding a message](#adding-a-message)). +A live scene, each ship's place, heading and order as the game plays, comes from `Mission file` +and `State` ([OpenReliant's own messages](#openreliants-own-messages)). ### Writing a client @@ -402,10 +422,13 @@ It works the way `openreliant debug` does: 3. On tag `0x1A` (the original's), the script is held before the byte at the offset it gives: send tag `0x15` (the original's) with 1 to run on, 2 to step into the next statement, or 3 to step over it; or with 0 and 1 to pause the mission, and 0 and 0 to let it go on. -4. Send `Get state` (OpenReliant's own) for the script's threads, globals and variables. - -`engine/link.zig` has a record for each of OpenReliant's own messages, which writes its payload and -reads it back: `Hello`, `MissionStarted` and `State`. The game writes them, and `openreliant debug` -reads them. A client that builds missions from source places its breakpoints with the compiler's -map from the source's lines to the script's statements -([Building a mission](../formats/mission-source.md#building-a-mission)). +4. Send `Get state` (OpenReliant's own) for the script's threads, globals and variables, the ships' + orders, and what the script did since the last state. +5. Send `Console line` (OpenReliant's own) to run a line in the game's console, and read what it + answers in `openreliant.log`. + +`engine/link.zig` has a record for each of OpenReliant's own messages with a payload, which writes +the payload and reads it back: `Hello`, `MissionStarted`, `State` and `ConsoleLine`. The game writes +the first three and reads the last, and `openreliant debug` reads the first three. A client that +builds missions from source places its breakpoints with the compiler's map from the source's lines +to the script's statements ([Building a mission](../formats/mission-source.md#building-a-mission)). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c006aaf7..2578bd84 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -82,6 +82,7 @@ player doesn't launch, the launch counts as over 600 ticks, six seconds, into th | Option | Description | |---|---| | `--no-mods` | Start without the mods in the game's `mods` folder ([Modding](modding.md)) | +| `--mods ` | Start with only these mods, up to 64, loaded in this order, whatever the mods screen has on, and leave its list as it is: each by its name in the game's `mods` folder, or by a path to a mod's folder or `.hog` file elsewhere. A mod that needs another loads only where that one comes before it. `--no-mods` takes over ([The mods screen](modding.md#the-mods-screen)) | | `--no-intro` | Start without the three movies the game plays as it starts, as `--mission` and `--screenshot` do | | `--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)) | diff --git a/docs/guide/modding.md b/docs/guide/modding.md index 6e1d66c5..b133fe34 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -182,6 +182,10 @@ The mods the section doesn't list are on, and load after the listed ones, in the Only 0 turns a mod off. To go back to loading every mod in the order of its name, delete the section. You can also edit it by hand. +For a single run, `--mods` loads only the mods it names, in that order, and leaves the section as it +is: `--mods bananas` plays a mod on its own, and a path, such as `--mods dist/bananas.hog`, loads a +mod from outside the `mods` folder ([Configuration](configuration.md)). + - A mod whose name has an equals sign, starts with a bracket or has spaces at either end can't be listed. It stays on and loads with the unlisted mods. - The screen lists up to 255 mods, and leaves out the ones that need a newer OpenReliant diff --git a/docs/guide/scripting.md b/docs/guide/scripting.md index 15930c51..a3ae9564 100644 --- a/docs/guide/scripting.md +++ b/docs/guide/scripting.md @@ -2781,6 +2781,9 @@ wingmen global> exit the mouse wheel and the arrows scroll the output. - Any other line goes to the player and menu scripts' `on_console_command(text)`, so that a mod can add commands of its own. +- What the console shows of its own goes to the log too, under `console`, so that an editor linked + to the game, which sends it lines to run, can read what it answers + ([The editor link](../engine/editor-link.md#openreliants-own-messages)). ### Reloading diff --git a/src/engine/game/bigfile/mods.zig b/src/engine/game/bigfile/mods.zig index a6abf3b8..581ca5d9 100644 --- a/src/engine/game/bigfile/mods.zig +++ b/src/engine/game/bigfile/mods.zig @@ -540,46 +540,34 @@ pub const Mods = struct { } }.lessThan); - var list: std.ArrayList(Mod) = .empty; - defer list.deinit(gpa); - errdefer for (list.items) |*mod| mod.close(gpa); - var off: std.ArrayList(Mod) = .empty; - defer off.deinit(gpa); - errdefer for (off.items) |*mod| mod.close(gpa); - var damaged: std.ArrayList(Mod) = .empty; - defer damaged.deinit(gpa); - errdefer for (damaged.items) |*mod| mod.close(gpa); - var unmet: std.ArrayList(Mod) = .empty; - defer unmet.deinit(gpa); - errdefer for (unmet.items) |*mod| mod.close(gpa); + var sorting: Sorting = .{}; + errdefer sorting.deinit(gpa); for (entries.items) |entry| { // A mod that is off isn't checked against its checksum, as none of it is used. const on = order.isOn(entry.name); const recorded = order.installedFrom(entry.name) != null; - var mod = try Mod.open(gpa, io, folder, entry.name, entry.kind, on, recorded) orelse continue; - if (running) |version| if (mod.needsLater(version)) |needed| { - log.warn("skipping the mod {f}: it needs OpenReliant {f}, and this is {f}", .{ mod, needed, version }); - mod.close(gpa); - continue; - }; - if (!on) log.info("the mod {f} is off", .{mod}); - const into = if (mod.checksum == .mismatch) &damaged else if (!on) &off else if (mod.missing(Earlier{ .mods = list.items })) |needed| unmet: { - log.warn("skipping the mod {f}: it needs the mod {s}, which doesn't load before it", .{ mod, needed }); - break :unmet &unmet; - } else &list; - into.append(gpa, mod) catch |err| { - mod.close(gpa); - return err; - }; + const mod = try Mod.open(gpa, io, folder, entry.name, entry.kind, on, recorded) orelse continue; + try sorting.add(gpa, mod, on, running); } - var mods: Mods = .{ .list = try list.toOwnedSlice(gpa) }; - errdefer mods.close(gpa); - mods.off = try off.toOwnedSlice(gpa); - mods.damaged = try damaged.toOwnedSlice(gpa); - mods.unmet = try unmet.toOwnedSlice(gpa); - try mods.makeIndex(gpa); - if (report_files) try mods.report(gpa, io, game); - return mods; + return sorting.finish(gpa, io, game, report_files); + } + + /// Opens only the mods `chosen` names, in that order, all of them on, as `--mods` asks: each + /// by its name in the `mods` folder of the game folder `game`, matched ignoring case, or by a + /// path from `from` to a mod's folder or archive elsewhere, which has a slash in it. The mods + /// it doesn't name aren't opened, so the mods screen's list stays as it is. `order` says which + /// archives the GET MODS screen installed, which must match their checksum files. + pub fn openChosen(gpa: Allocator, io: Io, game: Io.Dir, running: ?std.SemanticVersion, order: Order, chosen: []const []const u8, from: Io.Dir) Allocator.Error!Mods { + var path: [files.max_path]u8 = undefined; + var folder: ?Io.Dir = if (files.find(io, game, folder_name, &path)) |found| game.openDir(io, found, .{}) catch null else null; + defer if (folder) |*opened| opened.close(io); + var sorting: Sorting = .{}; + errdefer sorting.deinit(gpa); + for (chosen) |wanted| { + const mod = try openWanted(gpa, io, folder, from, wanted, order) orelse continue; + try sorting.add(gpa, mod, true, running); + } + return sorting.finish(gpa, io, game, true); } pub fn close(mods: *Mods, gpa: Allocator) void { @@ -1101,6 +1089,86 @@ fn isArchive(name: []const u8) bool { return std.ascii.eqlIgnoreCase(std.Io.Dir.path.extension(name), archive_extension); } +/// The mods as they open, sorted by what becomes of each (`Mods.openListing`, `Mods.openChosen`): +/// loaded, off, damaged, or needing a mod that doesn't load before it. +const Sorting = struct { + list: std.ArrayList(Mod) = .empty, + off: std.ArrayList(Mod) = .empty, + damaged: std.ArrayList(Mod) = .empty, + unmet: std.ArrayList(Mod) = .empty, + + /// Sorts `opened`, which `on` says is on, into its list. One that needs an OpenReliant later + /// than `running` is skipped and closed. + fn add(sorting: *Sorting, gpa: Allocator, opened: Mod, on: bool, running: ?std.SemanticVersion) Allocator.Error!void { + var mod = opened; + if (running) |version| if (mod.needsLater(version)) |needed| { + log.warn("skipping the mod {f}: it needs OpenReliant {f}, and this is {f}", .{ mod, needed, version }); + mod.close(gpa); + return; + }; + if (!on) log.info("the mod {f} is off", .{mod}); + const into = if (mod.checksum == .mismatch) &sorting.damaged else if (!on) &sorting.off else if (mod.missing(Earlier{ .mods = sorting.list.items })) |needed| unmet: { + log.warn("skipping the mod {f}: it needs the mod {s}, which doesn't load before it", .{ mod, needed }); + break :unmet &sorting.unmet; + } else &sorting.list; + into.append(gpa, mod) catch |err| { + mod.close(gpa); + return err; + }; + } + + /// The mods as sorted, with the index of their files, and with `report_files`, what each one's + /// files replace or add written to the log (`Mods.report`). + fn finish(sorting: *Sorting, gpa: Allocator, io: Io, game: Io.Dir, report_files: bool) Allocator.Error!Mods { + var mods: Mods = .{ .list = try sorting.list.toOwnedSlice(gpa) }; + errdefer mods.close(gpa); + mods.off = try sorting.off.toOwnedSlice(gpa); + mods.damaged = try sorting.damaged.toOwnedSlice(gpa); + mods.unmet = try sorting.unmet.toOwnedSlice(gpa); + try mods.makeIndex(gpa); + if (report_files) try mods.report(gpa, io, game); + return mods; + } + + /// Closes the mods sorted so far, where opening them fails. + fn deinit(sorting: *Sorting, gpa: Allocator) void { + for ([_]*std.ArrayList(Mod){ &sorting.list, &sorting.off, &sorting.damaged, &sorting.unmet }) |kept| { + for (kept.items) |*mod| mod.close(gpa); + kept.deinit(gpa); + } + } +}; + +/// The mod `wanted` names for `Mods.openChosen`, opened on: a path from `from` to a mod's folder or +/// archive where it has a slash in it, and otherwise a mod's name in the `mods` folder `folder`, +/// matched ignoring case. Null, with a message in the log, where there's no such mod. +fn openWanted(gpa: Allocator, io: Io, folder: ?Io.Dir, from: Io.Dir, wanted: []const u8, order: Order) Allocator.Error!?Mod { + var buffer: [files.max_path]u8 = undefined; + const elsewhere = std.mem.findAny(u8, wanted, "/\\") != null; + const parent: Io.Dir, const name: []const u8 = if (elsewhere) .{ + from.openDir(io, Io.Dir.path.dirname(wanted) orelse ".", .{}) catch { + log.warn("skipping the mod {s}: there's no such folder or archive", .{wanted}); + return null; + }, + Io.Dir.path.basename(wanted), + } else in_folder: { + const mods_folder = folder orelse { + log.warn("skipping the mod {s}: the game's folder has no {s} folder", .{ wanted, folder_name }); + return null; + }; + break :in_folder .{ mods_folder, files.find(io, mods_folder, wanted, &buffer) orelse { + log.warn("skipping the mod {s}: the {s} folder has no such mod", .{ wanted, folder_name }); + return null; + } }; + }; + defer if (elsewhere) parent.close(io); + const kind = (parent.statFile(io, name, .{}) catch { + log.warn("skipping the mod {s}: there's no such folder or archive", .{wanted}); + return null; + }).kind; + return Mod.open(gpa, io, parent, name, kind, true, !elsewhere and order.installedFrom(name) != null); +} + /// Whether a file or folder name is hidden, such as `.DS_Store` or `.git`. Mods skip them. fn hidden(name: []const u8) bool { return std.mem.startsWith(u8, name, "."); @@ -1258,6 +1326,41 @@ test "a mod that needs a newer version is skipped" { try std.testing.expectEqual(5, every.list.len); } +test "the mods --mods chooses load alone, in its order" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + // Three mods in the mods folder, Gamma needing Alpha, and an archive elsewhere. + try tmp.dir.createDirPath(io, "mods/Alpha"); + try tmp.dir.writeFile(io, .{ .sub_path = "mods/Alpha/note.txt", .data = "alpha" }); + try tmp.dir.createDirPath(io, "mods/beta"); + try tmp.dir.writeFile(io, .{ .sub_path = "mods/beta/note.txt", .data = "beta" }); + try tmp.dir.createDirPath(io, "mods/gamma"); + try tmp.dir.writeFile(io, .{ .sub_path = "mods/gamma/mod.ini", .data = "[Mod]\nRequires=Alpha\n" }); + try tmp.dir.createDirPath(io, "dist"); + try hog.testing.write(gpa, io, tmp.dir, "dist/delta.hog", &.{.{ .name = "note.txt", .data = "delta" }}); + + // Beta isn't chosen, a name matches ignoring case, and the archive comes by its path, last, so + // its file wins. + var mods: Mods = try .openChosen(gpa, io, tmp.dir, null, .none, &.{ "alpha", "dist/delta.hog" }, tmp.dir); + defer mods.close(gpa); + try std.testing.expectEqual(2, mods.list.len); + try std.testing.expectEqualStrings("Alpha", mods.list[0].name); + try std.testing.expectEqualStrings("delta.hog", mods.list[1].name); + const note = (try mods.readFile(gpa, "note.txt")).?; + defer gpa.free(note); + try std.testing.expectEqualStrings("delta", note); + + // A mod loads only after the mods it needs, and a name that isn't there is skipped. + var needing: Mods = try .openChosen(gpa, io, tmp.dir, null, .none, &.{ "gamma", "Alpha", "missing" }, tmp.dir); + defer needing.close(gpa); + try std.testing.expectEqual(1, needing.list.len); + try std.testing.expectEqualStrings("Alpha", needing.list[0].name); + try std.testing.expectEqual(1, needing.unmet.len); + try std.testing.expectEqualStrings("gamma", needing.unmet[0].name); +} + test "a mod loads only after the mods it needs" { const gpa = std.testing.allocator; const io = std.testing.io; diff --git a/src/engine/game/mission/editor.zig b/src/engine/game/mission/editor.zig index 7a1660ef..c2d3b19f 100644 --- a/src/engine/game/mission/editor.zig +++ b/src/engine/game/mission/editor.zig @@ -14,10 +14,13 @@ const Io = std.Io; const dte = @import("../../../formats/dte.zig"); const hooks = @import("../../hooks.zig"); +const gameobj = @import("../gameobj.zig"); const link = @import("../../link.zig"); const Random = @import("../../random.zig").Random; const vm = @import("../../vm.zig"); const mission = @import("../mission.zig"); +const Order = @import("../ai/orders.zig").Order; +const ticks_per_second = @import("../main.zig").ticks_per_second; const winmain = @import("../winmain.zig"); const log = std.log.scoped(.editor); @@ -141,8 +144,13 @@ pub const RunControl = extern struct { /// script's clock that is a multiple of this (`0x004576FD`). const finished_every = 5; +/// The most events the game keeps between two states while an editor is linked (`Session.events`). +/// Past it, the oldest go first, and the state counts how many (`link.State.events_dropped`). +pub const max_events = 512; + /// The editor link as the game works it for a mission: from its script's start (`begin`) to its end -/// (`end`), the editor's messages are acted on, and in between they wait. +/// (`end`), the editor's messages are acted on. In between, the original's wait for the next +/// mission, and OpenReliant's own are taken as they come (`poll`). pub const Session = struct { link: *link.Link, /// What the script's start waits with (`vm.editor.Editor.sleep`). @@ -158,11 +166,30 @@ pub const Session = struct { finished_told: ?u32 = null, /// The tags not ported yet that the editor has sent, each logged the first time. logged: std.bit_set.Static(received.len) = .empty, + /// What the script did since the editor last asked for the state, which the state gives + /// (`link.State.events`), and how many were left out past `max_events`. + events: std.ArrayList(link.State.ScriptEvent) = .empty, + events_dropped: u32 = 0, + /// The lines for the game's console that the editor sent, in the link's allocator, which the + /// game runs after each poll and then lets go of (`clearConsoleLines`). + console_lines: std.ArrayList([]u8) = .empty, pub fn init(on: *link.Link, io: Io) Session { return .{ .link = on, .io = io }; } + pub fn deinit(session: *Session) void { + session.events.deinit(session.link.gpa); + session.clearConsoleLines(); + session.console_lines.deinit(session.link.gpa); + } + + /// Lets go of the console lines the editor sent, once the game has run them. + pub fn clearConsoleLines(session: *Session) void { + for (session.console_lines.items) |line| session.link.gpa.free(line); + session.console_lines.clearRetainingCapacity(); + } + /// As the script of `loaded`, `started`, is about to start: the editor works on it from now on, /// and hears of it. An editor that has just connected is taken first, so that it hears of the /// mission before the script's start reads its messages. @@ -174,8 +201,10 @@ pub const Session = struct { session.file_length = @min(started.file.len, session.file_buffer.len); @memcpy(session.file_buffer[0..session.file_length], started.file[0..session.file_length]); session.finished_told = null; + session.events.clearRetainingCapacity(); + session.events_dropped = 0; loaded.script.editor.present = session.link.present(); - loaded.script.editor.connection = .{ .context = session, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now } }; + loaded.script.editor.connection = .{ .context = session, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now, .happened = happened } }; session.tellMission(); } @@ -190,21 +219,22 @@ pub const Session = struct { } /// Once a frame, and wherever the script's start checks the link (`editor_link_check`): an - /// editor connecting or going, then its messages, which wait while no mission plays. Then, - /// at each fifth second of the script's clock while nothing holds the script, the editor hears - /// whether the first thread has finished. + /// editor connecting or going, then its messages. While no mission plays, the original's + /// messages wait for the next one, and so do the messages after them; a console line is taken + /// at once. Then, at each fifth second of the script's clock while nothing holds the script, + /// the editor hears whether the first thread has finished. /// /// **Improvement:** the original reads the editor's messages only while a mission's script /// starts, and so tells it of the first thread only then; OpenReliant does both every frame, /// once for each value of the clock. pub fn poll(session: *Session) void { - while (session.link.next(if (session.loaded == null) .connections else .all)) |event| switch (event) { + while (session.link.next(if (session.loaded == null) .own else .all)) |event| switch (event) { .connected => if (session.loaded) |loaded| { loaded.script.editor.present = true; session.tellMission(); }, .disconnected => if (session.loaded) |loaded| loaded.script.editor.letGo(), - .message => |message| if (session.loaded) |loaded| session.receive(loaded, message), + .message => |message| if (session.loaded) |loaded| session.receive(loaded, message) else session.between(message), }; session.tellFinished(); } @@ -214,7 +244,7 @@ pub const Session = struct { /// **Improvement:** the original stops the game on a tag past `0x17` (`fatal_error`); /// OpenReliant logs it and passes over it. fn receive(session: *Session, loaded: *mission.Loaded, message: link.Message) void { - if (link.Own.of(message.tag)) |own| return session.answer(loaded, own); + if (link.Own.of(message.tag)) |own| return session.answer(loaded, own, message.payload); if (message.tag >= received.len) { log.warn("the editor sent tag 0x{X}, which the game doesn't know, so it passes over it", .{message.tag}); return; @@ -233,24 +263,51 @@ pub const Session = struct { } } - /// Answers one of OpenReliant's own requests: the mission's file, or the script's state. - fn answer(session: *Session, loaded: *mission.Loaded, request: link.Own) void { + /// Acts on one of OpenReliant's own messages, `payload` its payload: the mission's file or the + /// script's state for the editor, or a line for the game's console. + fn answer(session: *Session, loaded: *mission.Loaded, request: link.Own, payload: []const u8) void { switch (request) { .get_mission => session.link.send(@backingInt(link.Own.mission_file), loaded.bound.image), .get_state => session.tellState(loaded), + .console_line => session.keepConsoleLine(payload), else => log.warn("the editor sent tag 0x{X}, which only the game sends, so it passes over it", .{@backingInt(request)}), } } - /// Tells the editor the script's state (`stateOf`). + /// Acts on one of OpenReliant's own messages while no mission plays: a console line is kept for + /// the game to run, and a request for the mission's file or the script's state is passed over. + fn between(session: *Session, message: link.Message) void { + switch (link.Own.of(message.tag) orelse return) { + .console_line => session.keepConsoleLine(message.payload), + else => log.debug("the editor sent tag 0x{X} with no mission playing, so the game passes over it", .{message.tag}), + } + } + + /// Keeps the console line in `payload` for the game to run (`console_lines`). + fn keepConsoleLine(session: *Session, payload: []const u8) void { + const console = link.ConsoleLine.read(payload) catch { + log.warn("the editor sent a console line without its line, so the game passes over it", .{}); + return; + }; + const gpa = session.link.gpa; + const kept = gpa.dupe(u8, console.line) catch return; + session.console_lines.append(gpa, kept) catch gpa.free(kept); + } + + /// Tells the editor the script's state (`stateOf`), with what the script did since it last + /// asked, which the game then forgets. fn tellState(session: *Session, loaded: *mission.Loaded) void { var arena: std.heap.ArenaAllocator = .init(session.link.gpa); defer arena.deinit(); - const state = stateOf(arena.allocator(), loaded) catch { + var state = stateOf(arena.allocator(), loaded) catch { log.warn("the link has no memory left for the script's state", .{}); return; }; + state.events = session.events.items; + state.events_dropped = session.events_dropped; session.link.sendRecord(.state, state); + session.events.clearRetainingCapacity(); + session.events_dropped = 0; } /// Tells the editor of the mission playing, as it starts or as an editor connects, and sends @@ -298,11 +355,34 @@ pub const Session = struct { const session: *Session = @ptrCast(@alignCast(context)); return @intCast(@max(0, Io.Clock.awake.now(session.io).toMilliseconds())); } + + /// Keeps what the script did for the editor's next state, with when it happened on the + /// script's clock: the game's ticks since the clock started, less the ticks the editor held the + /// script (`mission.Loaded.tickClock`). + fn happened(context: *anyopaque, event: vm.editor.Event) void { + const session: *Session = @ptrCast(@alignCast(context)); + const loaded = session.loaded orelse return; + const ticks = if (loaded.script.game) |playing| playing.world.clock.game_ticks else loaded.clock_at; + const milliseconds = @as(u64, ticks -% loaded.clock_from) * std.time.ms_per_s / ticks_per_second; + const time = std.math.cast(u32, milliseconds) orelse std.math.maxInt(u32); + if (session.events.items.len == max_events) { + _ = session.events.orderedRemove(0); + session.events_dropped +|= 1; + } + session.events.append(session.link.gpa, switch (event) { + .trigger => |index| .{ .kind = .trigger, .index = index, .time = time }, + .command => |number| .{ .kind = .command, .index = number, .time = time }, + }) catch { + session.events_dropped +|= 1; + }; + } }; /// The script's state for `link.Own.state` (`link.State`), its lists made in `arena`. A thread /// whose instruction is outside the script has no place in it. The game's variables come by their -/// names, and the ones without a name by their numbers, where they aren't 0. +/// names, and the ones without a name by their numbers, where they aren't 0. The ships are the +/// mission's ships in play, made and not destroyed, each with its current order, and the ship +/// that order aims at where it aims at one. fn stateOf(arena: std.mem.Allocator, loaded: *mission.Loaded) std.mem.Allocator.Error!link.State { const script = &loaded.script; const code = loaded.bound.file.entry(.script); @@ -331,6 +411,21 @@ fn stateOf(arena: std.mem.Allocator, loaded: *mission.Loaded) std.mem.Allocator. try variables.append(arena, .{ .name = name, .value = value }); } else if (value != 0) try variables.append(arena, .{ .name = try arena.print("{d}", .{number}), .value = value }); } + var ships: std.ArrayList(link.State.Ship) = .empty; + if (script.game) |playing| { + const records = loaded.bound.ships() catch &.{}; + const slots = &playing.world.objects.slots; + const count = @min(records.len, slots.len); + for (records[0..count], slots[0..count], 0..) |record, *slot, index| { + if (!slot.object.created or record.flags.destroyed) continue; + const entry = slot.current(); + try ships.append(arena, .{ + .index = @intCast(index), + .order = if (entry) |current| try orderName(arena, current.order) else "none", + .target = if (entry) |current| current.target.ship() else null, + }); + } + } return .{ .clock = script.clock, .hold = script.editor.hold, @@ -340,9 +435,16 @@ fn stateOf(arena: std.mem.Allocator, loaded: *mission.Loaded) std.mem.Allocator. .threads = threads.items, .globals = globals, .variables = variables.items, + .ships = ships.items, }; } +/// An order's name as the state gives it (`link.State.Ship.order`), or its number where it has +/// none. +fn orderName(arena: std.mem.Allocator, order: Order) std.mem.Allocator.Error![]const u8 { + return std.enums.tagName(Order, order) orelse arena.print("{d}", .{@backingInt(order)}); +} + /// `editor_table_copy` (`0x0045A770`): `payload` copied over a section of the mission, as many /// bytes as the section's records take (`tableSize`). /// @@ -411,6 +513,11 @@ const Test = struct { const store = first + machine_testing.instructionSize(.select_global) + machine_testing.instructionSize(.push_byte); fn init(t: *Test, gpa: std.mem.Allocator) !void { + return t.initWith(gpa, &.{}); + } + + /// The test's mission with the ships `ships`. + fn initWith(t: *Test, gpa: std.mem.Allocator, ships: []const dte.Ship) !void { t.pipe = .{ .gpa = gpa }; t.link = .init(gpa, t.pipe.transport(), game, "1.2.3"); t.session = .init(&t.link, std.testing.io); @@ -418,16 +525,39 @@ const Test = struct { const code = try machine_testing.twoStatements(gpa); defer gpa.free(code); const flags: [32]u8 = @splat(0); - const image = try machine_testing.Fixture.image(gpa, &.{.{ .code = code }}, .{ .globals = &.{ 0, 0 }, .script_flags = &flags }); + const image = try machine_testing.Fixture.image(gpa, &.{.{ .code = code }}, .{ .globals = &.{ 0, 0 }, .script_flags = &flags, .ships = ships }); t.loaded = try mission.Loaded.create(gpa, image, &t.random); } fn deinit(t: *Test) void { t.loaded.destroy(); + t.session.deinit(); t.link.deinit(); t.pipe.deinit(); } + /// The mission starts with an editor linked, which takes what the game tells it as it does. + fn start(t: *Test) !void { + t.session.begin(t.loaded, .{ .number = 1, .file = "mission1.dte" }); + t.pipe.connect(); + t.session.poll(); + var buffer: [128]u8 = undefined; + _ = try t.heard(&buffer); + _ = try t.heard(&buffer); + try t.expectFile(); + _ = try t.word(.first_finished); + } + + /// The state the editor asks for, read in `arena`. + fn state(t: *Test, arena: std.mem.Allocator) !link.State { + try t.pipe.sendToGame(@backingInt(link.Own.get_state), ""); + t.session.poll(); + const text = try arena.alloc(u8, 64 * 1024); + const sent = t.pipe.takeFromGame(text) orelse return error.TestExpectedMessage; + try std.testing.expectEqual(@backingInt(link.Own.state), sent.tag); + return .read(arena, sent.payload); + } + /// The editor's next message from the game, which must have sent one. fn heard(t: *Test, buffer: []u8) !link.Message { return t.pipe.takeFromGame(buffer) orelse error.TestExpectedMessage; @@ -644,3 +774,87 @@ test "the editor asks for the mission's file and the script's state" { t.session.poll(); try std.testing.expectEqual(null, t.pipe.takeFromGame(&text)); } + +test "the state gives the ships' orders, and what the script did since the last state" { + const gpa = std.testing.allocator; + var world: gameobj.testing.Mission = undefined; + try world.init(gpa); + defer world.deinit(); + const player = try world.add(.of(.predator), @splat(0)); + const sabre = try world.add(.of(.sabre), .{ 0, 0, 2000 }); + const wreck = try world.add(.of(.sabre), .{ 0, 0, 4000 }); + // The Sabre fights the player, and the other Sabre is destroyed. + const fighting = world.slot(sabre); + fighting.orders[0] = std.mem.zeroes(@TypeOf(fighting.orders[0])); + fighting.orders[0].order = .fight; + fighting.orders[0].target = .at(player, null); + fighting.object.order_count = 1; + var ships = dte.testing.ships(3, 0); + ships[wreck].flags.destroyed = true; + var t: Test = undefined; + try t.initWith(gpa, &ships); + defer t.deinit(); + t.loaded.script.game = world.orders(); + try t.start(); + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // The ships in play, the destroyed one left out, and the Sabre's order and target. + t.loaded.clock_from = 1000; + world.clock.game_ticks = 1000 + 4180; + t.loaded.script.editor.report(.{ .trigger = 1 }); + t.loaded.script.editor.report(.{ .command = 11 }); + const busy = try t.state(arena); + try std.testing.expectEqual(2, busy.ships.len); + try std.testing.expectEqual(player, busy.ships[0].index); + try std.testing.expectEqual(sabre, busy.ships[1].index); + try std.testing.expectEqualStrings("fight", busy.ships[1].order); + try std.testing.expectEqual(player, busy.ships[1].target.?); + // The events, each when it happened on the script's clock, in milliseconds. + try std.testing.expectEqualSlices(link.State.ScriptEvent, &.{ + .{ .kind = .trigger, .index = 1, .time = 41_800 }, + .{ .kind = .command, .index = 11, .time = 41_800 }, + }, busy.events); + + // The next state gives only what happened since. + try std.testing.expectEqual(0, (try t.state(arena)).events.len); + + // Past the most the game keeps, the oldest go first, counted. + for (0..max_events + 2) |number| t.loaded.script.editor.report(.{ .command = @intCast(number % 100) }); + const crowded = try t.state(arena); + try std.testing.expectEqual(max_events, crowded.events.len); + try std.testing.expectEqual(2, crowded.events[0].index); + try std.testing.expectEqual(2, crowded.events_dropped); +} + +test "the editor's console lines are taken with or without a mission" { + const gpa = std.testing.allocator; + var t: Test = undefined; + try t.init(gpa); + defer t.deinit(); + t.pipe.connect(); + t.session.poll(); + var buffer: [128]u8 = undefined; + _ = try t.heard(&buffer); + + // With no mission playing, a console line is taken at once, and a request for the state is + // passed over. + try t.pipe.sendToGame(@backingInt(link.Own.console_line), "line=help\n"); + try t.pipe.sendToGame(@backingInt(link.Own.get_state), ""); + t.session.poll(); + try std.testing.expectEqual(1, t.session.console_lines.items.len); + try std.testing.expectEqualStrings("help", t.session.console_lines.items[0]); + try std.testing.expectEqual(null, t.pipe.takeFromGame(&buffer)); + t.session.clearConsoleLines(); + + // Behind one of the original's messages, it waits with it for the mission. + try t.control(.pause, 1); + try t.pipe.sendToGame(@backingInt(link.Own.console_line), "line=reload\n"); + t.session.poll(); + try std.testing.expectEqual(0, t.session.console_lines.items.len); + t.session.begin(t.loaded, .{ .number = 1, .file = "mission1.dte" }); + t.session.poll(); + try std.testing.expect(t.loaded.script.editor.paused); + try std.testing.expectEqualStrings("reload", t.session.console_lines.items[0]); +} diff --git a/src/engine/link.zig b/src/engine/link.zig index 54c3a095..3ac879ec 100644 --- a/src/engine/link.zig +++ b/src/engine/link.zig @@ -2,8 +2,8 @@ //! mission editor and script debugger, carried over a transport that works on every system in //! place of the original's block of shared memory. This module is the part no game owns: how the //! messages travel, framed on a stream of bytes (`Header`), whether an editor is there, and the -//! records of OpenReliant's own messages (`Hello`, `MissionStarted`, `State`), which the game -//! writes and editors read. A game acts on the messages itself (StarLancer's in +//! records of OpenReliant's own messages (`Hello`, `MissionStarted`, `State`, `ConsoleLine`), which +//! one side writes and the other reads. A game acts on the messages itself (StarLancer's in //! `game.mission.editor`), and a script VM reports through it where a thread stopped //! (`vm.editor`). //! @@ -72,8 +72,10 @@ pub const Own = enum(u16) { /// From the editor: asks for the script's state, which the game answers with `state`. get_state = 0x105, /// The script's state: its clock, the editor's hold, step and pause, the threads, the globals - /// and the game's variables. + /// and the game's variables, the ships' orders, and what the script did since the last state. state = 0x106, + /// From the editor: a line for the game's console, run as if typed into it (`ConsoleLine`). + console_line = 0x107, _, /// `tag` as one of OpenReliant's own; null for one of the original's. @@ -82,8 +84,9 @@ pub const Own = enum(u16) { } }; -/// The version of the link's own messages, which `Own.hello` gives. -pub const protocol_version = 1; +/// The version of the link's own messages, which `Own.hello` gives. Version 2 adds the console +/// line, and the ships' orders and the script's events to the state. +pub const protocol_version = 2; /// A game on the engine, as the link names it to an editor (`Own.hello`). pub const Game = struct { @@ -151,6 +154,13 @@ pub const State = struct { globals: []const u32, /// The game's variables: each named one, and each other one that isn't 0, by its number. variables: []const Variable, + /// Each of the mission's ships in play, by its index: its order and its target. + ships: []const Ship = &.{}, + /// What the script did since the last state, in order: the triggers fired and the commands + /// run. + events: []const ScriptEvent = &.{}, + /// How many events were left out, past the most the game keeps between two states. + events_dropped: u32 = 0, pub const Thread = struct { slot: u32, @@ -172,10 +182,33 @@ pub const State = struct { value: u32, }; + pub const Ship = struct { + /// Its index in the mission's ships, which is also its object's slot. + index: u16, + /// Its current order, by its name, or its number where it has none; `none` for no order. + order: []const u8, + /// The slot of the ship its order aims at, where it aims at one: a ship's index for one of + /// the mission's ships. + target: ?u16, + }; + + pub const ScriptEvent = struct { + kind: Kind, + /// The trigger's index in the trigger list, or the command's number in the catalogue. + index: u32, + /// When it happened on the script's clock, in milliseconds. + time: u32, + + pub const Kind = enum { trigger, command }; + }; + /// Writes the state, a `key=value` line for each field: `clock`, `hold`, `step`, `paused` and /// `first_finished`; for each thread, by its slot, `thread.N.at` (`none` outside the script), /// `thread.N.depth`, `thread.N.wake`, `thread.N.interrupted`, and `thread.N.trigger` where a - /// trigger started it; `global.N` for each global; and `variable.NAME` for each variable. + /// trigger started it; `global.N` for each global; `variable.NAME` for each variable; for each + /// ship in play, by its index, `ship.N.order`, and `ship.N.target` where its order has one; + /// for each event, from 0, `event.N.kind`, `event.N.index` and `event.N.time`; and + /// `events_dropped` where some were left out. pub fn write(state: State, writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.print("clock={d}\nhold={t}\nstep={t}\npaused={d}\nfirst_finished={d}\n", .{ state.clock, state.hold, state.step, @intFromBool(state.paused), @intFromBool(state.first_finished), @@ -189,16 +222,31 @@ pub const State = struct { } for (state.globals, 0..) |value, index| try writer.print("global.{d}={d}\n", .{ index, value }); for (state.variables) |each| try writer.print("variable.{s}={d}\n", .{ each.name, each.value }); + for (state.ships) |ship| { + try writer.print("ship.{d}.order={s}\n", .{ ship.index, ship.order }); + if (ship.target) |target| try writer.print("ship.{d}.target={d}\n", .{ ship.index, target }); + } + for (state.events, 0..) |event, index| try writer.print("event.{d}.kind={t}\nevent.{d}.index={d}\nevent.{d}.time={d}\n", .{ + index, event.kind, index, event.index, index, event.time, + }); + if (state.events_dropped > 0) try writer.print("events_dropped={d}\n", .{state.events_dropped}); } /// The fields of the state that hold one value each. - const Scalar = enum { clock, hold, step, paused, first_finished }; + const Scalar = enum { clock, hold, step, paused, first_finished, events_dropped }; - /// The fields of a thread. + /// The fields of a thread, of a ship and of an event. const ThreadField = enum { at, depth, wake, interrupted, trigger }; + const ShipField = enum { order, target }; + const EventField = enum { kind, index, time }; + + /// An event as its lines are read: its kind is null until a line gives it. + const ReadEvent = struct { kind: ?ScriptEvent.Kind = null, index: u32 = 0, time: u32 = 0 }; /// The state in `payload`, its lists made in `arena`, its names borrowed from `payload`. A - /// global's index fits the 16 bits a mission's directory counts its globals in. + /// global's index fits the 16 bits a mission's directory counts its globals in, and a ship's the + /// 16 bits of an object's slot. The events are numbered from 0 with none missing, and each has + /// its kind. pub fn read(arena: Allocator, payload: []const u8) (Allocator.Error || ReadError)!State { var clock: ?u32 = null; var hold: ?vm_editor.Hold = null; @@ -208,6 +256,9 @@ pub const State = struct { var threads: std.ArrayList(Thread) = .empty; var globals: std.ArrayList(u32) = .empty; var variables: std.ArrayList(Variable) = .empty; + var ships: std.ArrayList(Ship) = .empty; + var events: std.ArrayList(ReadEvent) = .empty; + var events_dropped: u32 = 0; var fields: Fields = .of(payload); while (fields.next()) |found| { const key = found.key; @@ -218,9 +269,10 @@ pub const State = struct { .step => step = try named(vm_editor.Step, value), .paused => paused = try flag(value), .first_finished => first_finished = try flag(value), + .events_dropped => events_dropped = try parse(u32, value), } else if (std.mem.cutPrefix(u8, key, "thread.")) |rest| { const slot_text, const name = std.mem.cutScalar(u8, rest, '.') orelse return error.Invalid; - const thread = try threadIn(arena, &threads, try parse(u32, slot_text)); + const thread = try sortedIn(Thread, "slot", arena, &threads, .{ .slot = try parse(u32, slot_text), .at = null, .depth = 0, .wake = 0, .interrupted = false, .trigger = null }); if (std.meta.stringToEnum(ThreadField, name)) |thread_field| switch (thread_field) { .at => thread.at = if (std.mem.eql(u8, value, "none")) null else try parse(u32, value), .depth => thread.depth = try parse(u32, value), @@ -234,8 +286,28 @@ pub const State = struct { globals.items[index] = try parse(u32, value); } else if (std.mem.cutPrefix(u8, key, "variable.")) |name| { try variables.append(arena, .{ .name = name, .value = try parse(u32, value) }); + } else if (std.mem.cutPrefix(u8, key, "ship.")) |rest| { + const index_text, const name = std.mem.cutScalar(u8, rest, '.') orelse return error.Invalid; + const ship = try sortedIn(Ship, "index", arena, &ships, .{ .index = try parse(u16, index_text), .order = "none", .target = null }); + if (std.meta.stringToEnum(ShipField, name)) |ship_field| switch (ship_field) { + .order => ship.order = value, + .target => ship.target = try parse(u16, value), + }; + } else if (std.mem.cutPrefix(u8, key, "event.")) |rest| { + const index_text, const name = std.mem.cutScalar(u8, rest, '.') orelse return error.Invalid; + const index: usize = try parse(u32, index_text); + if (index > events.items.len) return error.Invalid; + if (index == events.items.len) try events.append(arena, .{}); + const event = &events.items[index]; + if (std.meta.stringToEnum(EventField, name)) |event_field| switch (event_field) { + .kind => event.kind = try named(ScriptEvent.Kind, value), + .index => event.index = try parse(u32, value), + .time => event.time = try parse(u32, value), + }; } } + const read_events = try arena.alloc(ScriptEvent, events.items.len); + for (read_events, events.items) |*event, found| event.* = .{ .kind = found.kind orelse return error.Invalid, .index = found.index, .time = found.time }; return .{ .clock = clock orelse return error.Invalid, .hold = hold orelse return error.Invalid, @@ -245,16 +317,21 @@ pub const State = struct { .threads = threads.items, .globals = globals.items, .variables = variables.items, + .ships = ships.items, + .events = read_events, + .events_dropped = events_dropped, }; } - /// The thread of `slot` in `threads`, added in the slots' order where it isn't there yet. - fn threadIn(arena: Allocator, threads: *std.ArrayList(Thread), slot: u32) Allocator.Error!*Thread { - for (threads.items) |*thread| if (thread.slot == slot) return thread; - var place: usize = threads.items.len; - while (place > 0 and threads.items[place - 1].slot > slot) place -= 1; - try threads.insert(arena, place, .{ .slot = slot, .at = null, .depth = 0, .wake = 0, .interrupted = false, .trigger = null }); - return &threads.items[place]; + /// The record in `list` whose `key` is `made`'s, where it has one, and otherwise `made`, added + /// in the order of the records' `key`. + fn sortedIn(comptime T: type, comptime key: []const u8, arena: Allocator, list: *std.ArrayList(T), made: T) Allocator.Error!*T { + const wanted = @field(made, key); + for (list.items) |*item| if (@field(item, key) == wanted) return item; + var place: usize = list.items.len; + while (place > 0 and @field(list.items[place - 1], key) > wanted) place -= 1; + try list.insert(arena, place, made); + return &list.items[place]; } /// The variable named `name`, where the state has it. @@ -264,6 +341,22 @@ pub const State = struct { } }; +/// `Own.console_line`: a line for the game's console, run as if typed into it: one of the console's +/// commands, such as `reload`, or Luau in the context the console is in. A line break ends it. +pub const ConsoleLine = struct { + line: []const u8, + + pub fn write(console: ConsoleLine, writer: *std.Io.Writer) std.Io.Writer.Error!void { + const first, _ = std.mem.cutScalar(u8, console.line, '\n') orelse .{ console.line, "" }; + try writer.print("line={s}\n", .{first}); + } + + /// The line in `payload`, whose text it borrows. + pub fn read(payload: []const u8) ReadError!ConsoleLine { + return .{ .line = try text(payload, "line") }; + } +}; + /// The `key=value` lines of one of OpenReliant's own messages, in order, passing over a line /// without `=`. const Fields = struct { @@ -385,6 +478,9 @@ pub const Link = struct { all, /// Only an editor connecting or going: the messages wait for a later call. connections, + /// Connections, and OpenReliant's own messages while one is next: the first of the + /// original's waits for a later call, and the messages after it with it. + own, }; /// What happened since the last call: an editor connecting or going, or a message. The caller @@ -417,6 +513,7 @@ pub const Link = struct { return null; } if (link.received.items.len - header_size < header.size) return null; + if (taking == .own and Own.of(header.tag) == null) return null; link.taken = header_size + header.size; return .{ .message = .{ .tag = header.tag, .payload = link.received.items[header_size..link.taken] } }; } @@ -443,7 +540,7 @@ pub const Link = struct { } /// Sends one of OpenReliant's own messages, `record` written as its `key=value` lines - /// (`Hello`, `MissionStarted`, `State`); nothing where no editor is connected. + /// (`Hello`, `MissionStarted`, `State`, `ConsoleLine`); nothing where no editor is connected. pub fn sendRecord(link: *Link, tag: Own, record: anytype) void { if (link.session == null) return; var payload: std.Io.Writer.Allocating = .init(link.gpa); @@ -628,7 +725,7 @@ test "OpenReliant's own messages are key=value lines" { var buffer: [128]u8 = undefined; const hello = pipe.takeFromGame(&buffer).?; try std.testing.expectEqual(@backingInt(Own.hello), hello.tag); - try std.testing.expectEqualStrings("1", field(hello.payload, "protocol").?); + try std.testing.expectEqual(protocol_version, try parse(u32, field(hello.payload, "protocol").?)); try std.testing.expectEqualStrings("1.2.3", field(hello.payload, "openreliant").?); try std.testing.expectEqualStrings("Example", field(hello.payload, "game").?); try std.testing.expectEqualStrings("example", field(hello.payload, "vm").?); @@ -683,6 +780,38 @@ test "the records of OpenReliant's own messages read back as written" { try std.testing.expectEqual(1, state_read.variable("mission_over").?); try std.testing.expectEqual(5, state_read.variable("200").?); try std.testing.expectEqual(null, state_read.variable("mission_success")); + try std.testing.expectEqual(0, state_read.ships.len); + try std.testing.expectEqual(0, state_read.events.len); + + // The ships' orders, and what the script did since the last state. + payload.clearRetainingCapacity(); + var busy = state; + busy.ships = &.{ + .{ .index = 2, .order = "fight", .target = 0 }, + .{ .index = 0, .order = "player_control", .target = null }, + }; + busy.events = &.{ + .{ .kind = .trigger, .index = 1, .time = 41_800 }, + .{ .kind = .command, .index = 11, .time = 41_800 }, + }; + busy.events_dropped = 3; + try busy.write(&payload.writer); + const busy_read: State = try .read(arena, payload.written()); + try std.testing.expectEqual(2, busy_read.ships.len); + // In the order of their indices. + try std.testing.expectEqual(0, busy_read.ships[0].index); + try std.testing.expectEqualStrings("player_control", busy_read.ships[0].order); + try std.testing.expectEqual(null, busy_read.ships[0].target); + try std.testing.expectEqualStrings("fight", busy_read.ships[1].order); + try std.testing.expectEqual(0, busy_read.ships[1].target.?); + try std.testing.expectEqualSlices(State.ScriptEvent, busy.events, busy_read.events); + try std.testing.expectEqual(3, busy_read.events_dropped); + + payload.clearRetainingCapacity(); + const line: ConsoleLine = .{ .line = "x = 6 * 7\nprint(x)" }; + try line.write(&payload.writer); + // A line break ends the line, and an `=` in it stays. + try std.testing.expectEqualStrings("x = 6 * 7", (try ConsoleLine.read(payload.written())).line); // A key it doesn't know is passed over. const newer: State = try .read(arena, "clock=1\nhold=none\nstep=none\npaused=0\nfirst_finished=0\nweather=fine\nthread.2.colour=red\n"); @@ -699,6 +828,32 @@ test "the records of OpenReliant's own messages read back as written" { const last: State = try .read(arena, "clock=1\nhold=none\nstep=none\npaused=0\nfirst_finished=0\nglobal.65535=3\n"); try std.testing.expectEqual(65536, last.globals.len); try std.testing.expectEqual(3, last.globals[65535]); + // The events come numbered from 0, each with its kind. + const start = "clock=1\nhold=none\nstep=none\npaused=0\nfirst_finished=0\n"; + try std.testing.expectError(error.Invalid, State.read(arena, start ++ "event.1.kind=trigger\n")); + try std.testing.expectError(error.Invalid, State.read(arena, start ++ "event.0.index=4\n")); + try std.testing.expectError(error.Invalid, State.read(arena, start ++ "event.0.kind=parade\n")); + try std.testing.expectError(error.Invalid, State.read(arena, start ++ "ship.70000.order=fight\n")); + try std.testing.expectError(error.Invalid, ConsoleLine.read("text=reload\n")); +} + +test "taking OpenReliant's own messages leaves the original's waiting" { + const gpa = std.testing.allocator; + var pipe: testing.Pipe = .{ .gpa = gpa }; + defer pipe.deinit(); + var link: Link = .init(gpa, pipe.transport(), example, "1.2.3"); + defer link.deinit(); + pipe.connect(); + _ = link.next(.all); + try pipe.sendToGame(@backingInt(Own.console_line), "line=reload\n"); + try pipe.sendToGame(0x15, &.{ 0, 0, 0, 0, 1, 0, 0, 0 }); + try pipe.sendToGame(@backingInt(Own.console_line), "line=help\n"); + try std.testing.expectEqual(Own.console_line, Own.of(link.next(.own).?.message.tag).?); + // The original's tag waits, and the console line after it with it. + try std.testing.expectEqual(null, link.next(.own)); + try std.testing.expectEqual(null, link.next(.own)); + try std.testing.expectEqual(0x15, link.next(.all).?.message.tag); + try std.testing.expectEqual(Own.console_line, Own.of(link.next(.own).?.message.tag).?); } test "an editor writes and reads messages framed" { diff --git a/src/engine/vm/editor.zig b/src/engine/vm/editor.zig index 8e452558..7eb35752 100644 --- a/src/engine/vm/editor.zig +++ b/src/engine/vm/editor.zig @@ -2,8 +2,9 @@ //! for the editor, and how `vm_run` (`0x0045C980`) stops and steps a thread for it. The editor's //! messages set the hold and the step (`editor_run_control`, `0x0045A900`, ported as the game's //! handler of tag `0x15`), and the VM reaches the editor through `Editor.connection`, which tells -//! it where a thread stopped and reads its messages while the script starts. None of it is a -//! game's: a game's link fills `Editor.present` and `Editor.connection` from its own connection. +//! it where a thread stopped and what the script did, and reads its messages while the script +//! starts. None of it is a game's: a game's link fills `Editor.present` and `Editor.connection` from +//! its own connection. const std = @import("std"); @@ -29,6 +30,14 @@ pub const Step = enum(i8) { over = 3, }; +/// OpenReliant's: what the script did, which the editor's timeline shows (`Editor.report`). +pub const Event = union(enum) { + /// A trigger fired, by its index in the trigger list. + trigger: u32, + /// A command of the catalogue ran, by its number. + command: u8, +}; + /// How the VM reaches the editor, through the game's link. pub const Connection = struct { context: *anyopaque, @@ -46,6 +55,8 @@ pub const Connection = struct { /// The time in milliseconds on a clock that only goes forward, for how long the script's /// start has waited for the editor (`Editor.waitForFlags`). now: *const fn (context: *anyopaque) u64, + /// OpenReliant's: tells the editor what the script did (`Editor.report`). + happened: *const fn (context: *anyopaque, event: Event) void, }; }; @@ -115,6 +126,12 @@ pub const Editor = struct { if (editor.connection) |connection| connection.vtable.stopped(connection.context, offset); } + /// OpenReliant's: tells the editor what the script did, while one is there. + pub fn report(editor: *Editor, event: Event) void { + if (!editor.present) return; + if (editor.connection) |connection| connection.vtable.happened(connection.context, event); + } + /// Acts on the editor's messages waiting (`editor_link_check`). pub fn check(editor: *Editor) void { if (editor.connection) |connection| connection.vtable.check(connection.context); @@ -177,9 +194,17 @@ pub const testing = struct { slept: u32 = 0, /// The editor that `check` flags and releases. editor: ?*Editor = null, + /// What it heard the script do, in order. + events: [8]Event = undefined, + event_count: usize = 0, pub fn connection(listener: *Listener) Connection { - return .{ .context = listener, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now } }; + return .{ .context = listener, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now, .happened = happened } }; + } + + /// What it heard the script do. + pub fn heard(listener: *const Listener) []const Event { + return listener.events[0..listener.event_count]; } pub fn last(listener: Listener) u32 { @@ -211,6 +236,13 @@ pub const testing = struct { const listener: *Listener = @ptrCast(@alignCast(context)); return listener.slept; } + + fn happened(context: *anyopaque, event: Event) void { + const listener: *Listener = @ptrCast(@alignCast(context)); + if (listener.event_count == listener.events.len) return; + listener.events[listener.event_count] = event; + listener.event_count += 1; + } }; }; @@ -231,7 +263,12 @@ test Editor { try std.testing.expect(editor.holds() and editor.leavesFrame()); editor.sleep(5); try std.testing.expectEqual(5, listener.slept); + // The editor hears what the script does while it's there. + editor.report(.{ .trigger = 2 }); + try std.testing.expectEqualSlices(Event, &.{.{ .trigger = 2 }}, listener.heard()); editor.present = false; + editor.report(.{ .command = 11 }); + try std.testing.expectEqual(1, listener.heard().len); try std.testing.expect(!editor.leavesFrame()); // With no editor there, nothing waits for one. editor.sleep(5); diff --git a/src/engine/vm/machine.zig b/src/engine/vm/machine.zig index fe713de8..6935ed1f 100644 --- a/src/engine/vm/machine.zig +++ b/src/engine/vm/machine.zig @@ -1085,7 +1085,8 @@ pub const Machine = struct { /// `vm_command` (`0x0045BEA0`): runs a command of the catalogue on its arguments, the top of /// the stack, which it pops (`runCommand`). Its result takes the first argument's place, above /// the stack, and is the thread's result. A command a hook holds (`CommandResult.hold`) puts - /// its arguments back instead, and the thread waits to run it again. + /// its arguments back instead, and the thread waits to run it again. The editor hears of each + /// command that ran (`editor.Editor.report`). fn command(machine: *Machine, index: u8, number: u8) Fault!u32 { const thread = &machine.threads[index]; if (number >= executor.commands.table.len) return error.OutOfRange; @@ -1101,6 +1102,7 @@ pub const Machine = struct { const call: Call = .{ .machine = machine, .thread = index, .args = &.{} }; return call.againItself(); } + machine.editor.report(.{ .command = number }); return machine.commandResult(index, @backingInt(result)); } diff --git a/src/engine/vm/triggers.zig b/src/engine/vm/triggers.zig index 1680054f..62fc7bde 100644 --- a/src/engine/vm/triggers.zig +++ b/src/engine/vm/triggers.zig @@ -55,12 +55,13 @@ pub fn raise(machine: *Machine, object: ?u16, event: Event) void { /// on its block, the one whose locals have the event's values, at once or for the scheduler as the /// trigger says, unless a thread it started still runs (`threadRunning`). It is disarmed as its /// repeat mode says (`disarm`), whether or not a thread started. Mods' scripts are told of each -/// trigger that fires (`trigger_fired`). +/// trigger that fires (`trigger_fired`), and so is the editor (`editor.Editor.report`). fn match(machine: *Machine, object: u16, event: Event) void { var each = firings(machine, object, event); while (each.next()) |firing| { const trigger = firing.trigger; log.debug("trigger {d} of object {d} fires on {f}", .{ firing.index, object, event.condition }); + machine.editor.report(.{ .trigger = @intCast(firing.index) }); if (machine.game) |game| hooks.tell(game, .trigger_fired, .{ .trigger = firing.index, .condition = event.condition, diff --git a/src/openreliant/main.zig b/src/openreliant/main.zig index ba72d4f9..d9ece555 100644 --- a/src/openreliant/main.zig +++ b/src/openreliant/main.zig @@ -267,9 +267,16 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] defer update_check.close(); // OpenReliant's mods, whose files take priority over the game's files wherever they are; none // with `--no-mods`. The mods screen sets which are on and the order they load in, for a - // screenshot too. + // screenshot too, and `--mods` sets them for the run alone. const mods_order: game.bigfile.mods.Order = .{ .profile = settings_file.profile }; - var mods: game.bigfile.Mods = if (options.mods) try .openOrdered(arena, io, directory, version.semantic, mods_order) else .none; + var chosen_names: [options_page.max_chosen_mods][]const u8 = undefined; + const chosen_mods = options.chosenMods(&chosen_names); + var mods: game.bigfile.Mods = if (!options.mods) + .none + else if (chosen_mods.len > 0) + try .openChosen(arena, io, directory, version.semantic, mods_order, chosen_mods, .cwd()) + else + try .openOrdered(arena, io, directory, version.semantic, mods_order); defer mods.close(arena); // The ship types, guns, missiles and pilots the mods add, each numbered from where the game's // end. @@ -933,12 +940,24 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] .reload => reloading = true, }; } else if (ScriptConsole.asked(&devices.keyboard)) try shown.bringUp(pausing, mission, &typed); + // The lines an editor sent run as if typed into the console (`engine.link.ConsoleLine`). + if (editor) |session| { + for (session.console_lines.items) |line| { + if (try shown.console.runText(game_scripts.reachable(), line)) |request| switch (request) { + .reload => reloading = true, + }; + } + session.clearConsoleLines(); + } if (reloading) try game_scripts.reload(if (mission) .{ .orders = .{ .world = world, .devices = &devices }, .mission = game.main.scriptMission(&reload_file, objects, play.number, play.file_number), .seed = game.main.scriptSeed(world.random, play.number), } else null); - } + } else if (editor) |session| if (session.console_lines.items.len > 0) { + std.log.warn("the editor sent a line for the console, which needs --developer-mode and a mod with scripts, so the game passes over it", .{}); + session.clearConsoleLines(); + }; const console_up = if (console) |*shown| shown.isUp() else false; world.view = view.view; @@ -2128,6 +2147,7 @@ const EditorLink = struct { } fn close(editor: *EditorLink) void { + editor.session.deinit(); editor.link.deinit(); editor.server.stop(); } diff --git a/src/openreliant/options.zig b/src/openreliant/options.zig index e78f42eb..991ec711 100644 --- a/src/openreliant/options.zig +++ b/src/openreliant/options.zig @@ -54,6 +54,7 @@ pub const Arg = enum { @"--no-compressor", @"--no-sound", @"--no-mods", + @"--mods", @"--no-intro", @"--developer-mode", @"--editor-link", @@ -143,6 +144,7 @@ const docs: std.enums.EnumArray(Arg, Doc) = .init(.{ .@"--no-compressor" = .{ .section = .sound, .text = "leave the mix's loudness as it is, only keeping its peaks in check" }, .@"--no-sound" = .{ .section = .sound, .text = "play without sound" }, .@"--no-mods" = .{ .section = .other, .text = "start without the mods in the game's mods folder" }, + .@"--mods" = .{ .section = .other, .value = "", .text = "start with only these mods, up to 64, loaded in this order, whatever the mods screen has on, and leave its list as it is: each by its name in the game's mods folder, or by a path to a mod's folder or .hog file elsewhere. A mod that needs another loads only where that one comes before it. --no-mods takes over" }, .@"--no-intro" = .{ .section = .other, .text = "start without the three movies the game plays as it starts, as --mission and --screenshot do" }, .@"--developer-mode" = .{ .section = .other, .text = "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" }, .@"--editor-link" = .{ .section = .other, .text = "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" }, @@ -262,6 +264,9 @@ pub const Options = struct { intro: bool = true, /// Whether to load the mods in the game's `mods` folder (`game.bigfile.Mods`). mods: bool = true, + /// The mods `--mods` names, separated by commas, which are the only ones loaded + /// (`chosenMods`); null for the mods screen's. + mods_chosen: ?[]const u8 = null, /// Whether the tools for writing mods' scripts are on: the scripting console, and folder mods' /// scripts reloading as they're saved (`console.Driver`). developer_mode: bool = false, @@ -366,6 +371,20 @@ pub const Options = struct { return options.part_names[0..options.part_count]; } + /// The mods `--mods` names, in the order given, into `names`: each with the spaces around it + /// trimmed, and the empty ones left out. Parsing has made sure they fit. + pub fn chosenMods(options: *const Options, names: *[max_chosen_mods][]const u8) []const []const u8 { + var count: usize = 0; + var each = std.mem.splitScalar(u8, options.mods_chosen orelse return &.{}, ','); + while (each.next()) |name| { + const trimmed = std.mem.trim(u8, name, " "); + if (trimmed.len == 0 or count == names.len) continue; + names[count] = trimmed; + count += 1; + } + return names[0..count]; + } + /// The seed each mission's random numbers start from, where the run fixes one: `--seed`'s, or /// with `--screenshot`, so that its frame comes out the same each time, the runtime's first /// seed. Null leaves each start to take the clock's. @@ -482,6 +501,11 @@ pub const Options = struct { if (axes.next() != null) return error.BadValue; }, .@"--no-mods" => options.mods = false, + .@"--mods" => { + if (std.mem.trim(u8, value, ", ").len == 0) return error.BadValue; + if (std.mem.count(u8, value, ",") >= max_chosen_mods) return error.BadValue; + options.mods_chosen = value; + }, .@"--no-intro" => options.intro = false, .@"--developer-mode" => options.developer_mode = true, .@"--editor-link" => options.editor_link = true, @@ -595,6 +619,9 @@ const minimum_screenshot_ticks = 2; /// The most script parts `--part` names. const max_parts = 8; +/// The most mods `--mods` names. +pub const max_chosen_mods = 64; + /// Reading the options, for the tests. pub const testing = struct { /// The options `args` play with. @@ -649,9 +676,20 @@ test Options { try std.testing.expectError(error.Usage, parsed(&.{ "--seed", "-1" })); try std.testing.expect((try parsed(&.{})).intro); try std.testing.expect(!(try parsed(&.{"--no-intro"})).intro); - // Mods are loaded unless `--no-mods` is given. + // Mods are loaded unless `--no-mods` is given, the mods screen's unless `--mods` chooses them. try std.testing.expect((try parsed(&.{})).mods); try std.testing.expect(!(try parsed(&.{"--no-mods"})).mods); + try std.testing.expectEqual(null, (try parsed(&.{})).mods_chosen); + var names: [max_chosen_mods][]const u8 = undefined; + const with_mods = try parsed(&.{ "--mods", "bananas, dist/music.hog,,teapot" }); + const chosen_mods = with_mods.chosenMods(&names); + try std.testing.expectEqual(3, chosen_mods.len); + try std.testing.expectEqualStrings("bananas", chosen_mods[0]); + try std.testing.expectEqualStrings("dist/music.hog", chosen_mods[1]); + try std.testing.expectEqualStrings("teapot", chosen_mods[2]); + try std.testing.expectError(error.Usage, parsed(&.{ "--mods", " , " })); + const too_many_mods: [max_chosen_mods]u8 = @splat(','); + try std.testing.expectError(error.Usage, parsed(&.{ "--mods", "a" ++ too_many_mods })); // The check for a newer release is on, with `--original` too, until `--no-update-check`. try std.testing.expect((try parsed(&.{"--original"})).update_check); try std.testing.expect(!(try parsed(&.{"--no-update-check"})).update_check); diff --git a/src/scripting/console.zig b/src/scripting/console.zig index f1b5a8c3..e3c04642 100644 --- a/src/scripting/console.zig +++ b/src/scripting/console.zig @@ -23,6 +23,10 @@ const Game = @import("game.zig").Game; const Presentation = @import("presentation.zig").Presentation; const reference = @import("reference.zig"); +/// The log the console's own lines go to as well as to its screen (`Console.say`). A failure goes +/// to it as a warning: it's the line's, not the game's. +const console_log = std.log.scoped(.console); + pub const screen = @import("console/screen.zig"); test { @@ -121,6 +125,18 @@ pub const Output = struct { /// The console's output, which the log writes to (`log`). pub var output: Output = .{}; +/// Writes `text`, the console's own, to its output, and to the log too, a message for each of its +/// lines, under `console` (`console_log`), so that an editor that sends the console lines reads +/// what it answers. +fn answer(tone: Tone, text: []const u8) void { + output.add(tone, text); + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| switch (tone) { + .info, .typed => console_log.info("{s}", .{line}), + .warning, .failure => console_log.warn("{s}", .{line}), + }; +} + /// Writes a message of the log to the console's output, as the driver's log function passes on the /// scripts' messages: errors and warnings in their tones. pub fn log(comptime level: std.log.Level, comptime format: []const u8, args: anytype) void { @@ -284,9 +300,16 @@ pub const Console = struct { console.typed.len = 0; console.recalled = null; console.view.back = 0; + return console.runText(scripts, utf8); + } + + /// Runs `text`, a line of UTF-8, as `run` runs the line typed: one of the console's commands, + /// Luau, or a line for the scripts. An editor's console lines come this way + /// (`link.ConsoleLine`). + pub fn runText(console: *Console, scripts: Scripts, text: []const u8) Allocator.Error!?Request { var prompt_buffer: [prompt_room]u8 = undefined; - console.say(.typed, "{s} {s}", .{ console.prompt(&prompt_buffer), utf8 }); - const line = std.mem.trim(u8, utf8, " "); + console.say(.typed, "{s} {s}", .{ console.prompt(&prompt_buffer), text }); + const line = std.mem.trim(u8, text, " "); if (line.len == 0) return null; switch (console.mode) { .commands => return console.command(line, scripts), @@ -333,7 +356,7 @@ pub const Console = struct { defer text.deinit(); const found = writeHelp(&text.writer, name) catch return error.OutOfMemory; if (!found) return console.say(.warning, "there's no command, package, engine handler or hook named {s}", .{name}); - output.add(.info, std.mem.trimEnd(u8, text.written(), "\n")); + answer(.info, std.mem.trimEnd(u8, text.written(), "\n")); } /// Writes what `help` says of `name`: the commands where it's empty, a command, or what the @@ -356,7 +379,7 @@ pub const Console = struct { var text: Io.Writer.Allocating = .init(console.gpa); defer text.deinit(); console.writeMods(&text.writer, scripts) catch return error.OutOfMemory; - output.add(.info, std.mem.trimEnd(u8, text.written(), "\n")); + answer(.info, std.mem.trimEnd(u8, text.written(), "\n")); } fn writeMods(console: *Console, w: *Io.Writer, scripts: Scripts) (Allocator.Error || Io.Writer.Error)!void { @@ -431,15 +454,15 @@ pub const Console = struct { const context = contextOf(target, scripts) orelse return console.notRunning(target); var result: Io.Writer.Allocating = .init(console.gpa); defer result.deinit(); - if (try context.runtime.evaluate(context, line, &result.writer) and result.written().len > 0) output.add(.info, result.written()); + if (try context.runtime.evaluate(context, line, &result.writer) and result.written().len > 0) answer(.info, result.written()); } - /// Writes a line of the console's own to the output. + /// Writes a line of the console's own (`answer`). fn say(_: *const Console, tone: Tone, comptime format: []const u8, args: anytype) void { var buffer: [max_line]u8 = undefined; var w: Io.Writer = .fixed(&buffer); w.print(format, args) catch {}; - output.add(tone, w.buffered()); + answer(tone, w.buffered()); } }; From d849ac1f06e1d53afa11684148b3aabc1c42b3b6 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Thu, 15 Oct 2026 07:45:08 +0200 Subject: [PATCH 2/2] fix: keep the mods screen shut with --mods, and review fixes With --mods, the mods screen opened on the chosen mods only, and saving there, or CANCEL CHANGES, wrote that short list to starlancer.ini. The screen now stays shut with --mods, as it does with --no-mods. Review fixes: --mods tells a path from a name by the platform's separators, the editor's console lines run through the console driver, the script's clock in ticks has one helper, the session's tests share their setup, and the new docs and comments are reworded plainly. --- docs/engine/editor-link.md | 38 ++++++++++----------- docs/guide/configuration.md | 2 +- docs/guide/modding.md | 7 ++-- docs/guide/scripting.md | 4 +-- src/engine/game/bigfile/mods.zig | 42 ++++++++++++----------- src/engine/game/mission.zig | 8 ++++- src/engine/game/mission/editor.zig | 54 +++++++++++------------------- src/engine/link.zig | 9 ++--- src/engine/vm/editor.zig | 8 ++--- src/openreliant/console.zig | 10 ++++++ src/openreliant/main.zig | 19 +++++------ src/openreliant/options.zig | 11 +++--- src/scripting/console.zig | 13 ++++--- 13 files changed, 113 insertions(+), 112 deletions(-) diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index 6b85ce78..3e6315f7 100644 --- a/docs/engine/editor-link.md +++ b/docs/engine/editor-link.md @@ -268,10 +268,10 @@ takes none. ### What it does -- The original's messages wait while no mission plays, as they wait in the original's block for the - next mission's start, and the messages after one of them wait with it. OpenReliant's own are - taken as they come: a console line runs, and a request for the mission's file or the script's - state is passed over. Each attempt reads the mission's file again, so the flags last for one +- While no mission plays, the original's messages wait for the next mission's start, as they wait + in the original's block, and any message after one of them waits too. OpenReliant's own messages + are read as they come: a console line runs, and a request for the mission's file or the script's + state is ignored. Each attempt reads the mission's file again, so the flags last for one attempt: an editor sends them again once the file comes with each `Mission started`, and the start parts wait for them. - The link opens as the game starts, so that an editor waiting for the game is there before the @@ -344,26 +344,26 @@ still read. | `thread.N.trigger` | The trigger that started it, where one did | | `global.N` | Global `N`'s value | | `variable.NAME` | The game's variable `NAME`, such as `mission_over`; one with no name by its number, where it isn't 0 | -| `ship.N.order` | The current order of the mission's ship `N`, by its name, such as `fight`, or its number where it has none; `none` for no order. Only the ships in play are given: made, and not destroyed | -| `ship.N.target` | The slot of the ship that order aims at, where it aims at one, which is a ship's index for the mission's ships | +| `ship.N.order` | The current order of the mission's ship `N`: its name, such as `fight`, its number if it has no name, or `none`. Only the ships in play are listed: created, and not destroyed | +| `ship.N.target` | The slot of the ship the order targets, if it targets one. For the mission's ships, the slot is the ship's index | | `event.N.kind`, `event.N.index` | What the script did since the last `State`, in order from 0: a trigger that fired (`trigger`), by its index in the trigger list, or a command that ran (`command`), by its number in the catalogue | | `event.N.time` | When it happened, on the script's clock, in milliseconds | -| `events_dropped` | How many events were left out, where more than 512 came between two states. The oldest go first | +| `events_dropped` | How many events were left out because more than 512 came between two states. The oldest are dropped first | -Values are the script's words, unsigned, except the orders' names. The game keeps the events only -while an editor is linked, and forgets them once a `State` has given them. +Values are the script's words, unsigned, except the names of orders and of the events' kinds. The +game keeps events only while an editor is linked, and clears them once a `State` has sent them. -`Mission file` gives where the ships are: the game keeps each of the mission's ships' run-time -place and heading in its record, from its object, every frame, as the original does -(`mission_ships_sync`), and marks a destroyed one in its flags +`Mission file` gives where the ships are. Every frame, the game copies each of the mission's ships' +position and heading from its object into its record, as the original does +(`mission_ships_sync`), and marks a destroyed ship in its flags ([The mission file](../formats/dte.md)). With the orders from `State`, that's the live scene. -`Console line` runs its line in the game's console as if typed into it +`Console line` runs its line in the game's console, as if typed there ([The console](../guide/scripting.md#the-console)): one of the console's commands, such as -`reload`, or Luau in the context the console is in. The console is there in the developer mode, -where a mod has scripts; without it, the game logs that it passes over the line. What the console -answers goes to the game's log, `openreliant.log` in the game's folder, under `console`, next to -what the scripts print ([Log messages](../guide/modding.md#log-messages)). +`reload`, or Luau. The console exists only with `--developer-mode` and a mod with scripts; +otherwise the game logs that it ignores the line. The console's answers go to the game's log, +`openreliant.log` in the game's folder, under `console`, next to what the scripts print +([Log messages](../guide/modding.md#log-messages)). ### Adding a message @@ -403,8 +403,8 @@ needs that OpenReliant doesn't carry yet: - the other games' script dialects, which `Hello`'s `vm` names ([#1017](https://github.com/OpenReliant/openreliant/issues/1017)). -A live scene, each ship's place, heading and order as the game plays, comes from `Mission file` -and `State` ([OpenReliant's own messages](#openreliants-own-messages)). +The live scene, each ship's position, heading and order as the game plays, comes from +`Mission file` and `State` ([OpenReliant's own messages](#openreliants-own-messages)). ### Writing a client diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2578bd84..ff9578cd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -82,7 +82,7 @@ player doesn't launch, the launch counts as over 600 ticks, six seconds, into th | Option | Description | |---|---| | `--no-mods` | Start without the mods in the game's `mods` folder ([Modding](modding.md)) | -| `--mods ` | Start with only these mods, up to 64, loaded in this order, whatever the mods screen has on, and leave its list as it is: each by its name in the game's `mods` folder, or by a path to a mod's folder or `.hog` file elsewhere. A mod that needs another loads only where that one comes before it. `--no-mods` takes over ([The mods screen](modding.md#the-mods-screen)) | +| `--mods ` | Start with only these mods, up to 64, in this order, whatever the mods screen has on. Give the name of a mod in the game's `mods` folder, or the path to a mod's folder or `.hog` file elsewhere. A mod that needs another loads only if that one comes before it. The mods screen stays shut, so its list doesn't change. `--no-mods` overrides it ([The mods screen](modding.md#the-mods-screen)) | | `--no-intro` | Start without the three movies the game plays as it starts, as `--mission` and `--screenshot` do | | `--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)) | diff --git a/docs/guide/modding.md b/docs/guide/modding.md index b133fe34..26445cf7 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -182,9 +182,10 @@ The mods the section doesn't list are on, and load after the listed ones, in the Only 0 turns a mod off. To go back to loading every mod in the order of its name, delete the section. You can also edit it by hand. -For a single run, `--mods` loads only the mods it names, in that order, and leaves the section as it -is: `--mods bananas` plays a mod on its own, and a path, such as `--mods dist/bananas.hog`, loads a -mod from outside the `mods` folder ([Configuration](configuration.md)). +For a single run, `--mods` loads only the mods it names, in that order, and keeps the screen shut, +so the section doesn't change. `--mods bananas` plays a mod on its own, and a path, such as +`--mods dist/bananas.hog`, loads a mod from outside the `mods` folder +([Configuration](configuration.md)). - A mod whose name has an equals sign, starts with a bracket or has spaces at either end can't be listed. It stays on and loads with the unlisted mods. diff --git a/docs/guide/scripting.md b/docs/guide/scripting.md index a3ae9564..217e9684 100644 --- a/docs/guide/scripting.md +++ b/docs/guide/scripting.md @@ -2781,8 +2781,8 @@ wingmen global> exit the mouse wheel and the arrows scroll the output. - Any other line goes to the player and menu scripts' `on_console_command(text)`, so that a mod can add commands of its own. -- What the console shows of its own goes to the log too, under `console`, so that an editor linked - to the game, which sends it lines to run, can read what it answers +- The console's own answers also go to the log, under `console`, so that a linked editor that sends + the console lines can read the answers ([The editor link](../engine/editor-link.md#openreliants-own-messages)). ### Reloading diff --git a/src/engine/game/bigfile/mods.zig b/src/engine/game/bigfile/mods.zig index 581ca5d9..9ae86159 100644 --- a/src/engine/game/bigfile/mods.zig +++ b/src/engine/game/bigfile/mods.zig @@ -552,11 +552,11 @@ pub const Mods = struct { return sorting.finish(gpa, io, game, report_files); } - /// Opens only the mods `chosen` names, in that order, all of them on, as `--mods` asks: each - /// by its name in the `mods` folder of the game folder `game`, matched ignoring case, or by a - /// path from `from` to a mod's folder or archive elsewhere, which has a slash in it. The mods - /// it doesn't name aren't opened, so the mods screen's list stays as it is. `order` says which - /// archives the GET MODS screen installed, which must match their checksum files. + /// Opens only the mods named in `chosen`, in that order and all on, as `--mods` asks + /// (`openWanted`): a name is a mod in the `mods` folder of the game folder `game`, and a path + /// leads from `from` to a mod's folder or archive elsewhere. `order` says which archives the + /// GET MODS screen installed, which must match their checksum files. The log lists each mod and + /// what each of its files replaces or adds (`report`). pub fn openChosen(gpa: Allocator, io: Io, game: Io.Dir, running: ?std.SemanticVersion, order: Order, chosen: []const []const u8, from: Io.Dir) Allocator.Error!Mods { var path: [files.max_path]u8 = undefined; var folder: ?Io.Dir = if (files.find(io, game, folder_name, &path)) |found| game.openDir(io, found, .{}) catch null else null; @@ -1089,16 +1089,16 @@ fn isArchive(name: []const u8) bool { return std.ascii.eqlIgnoreCase(std.Io.Dir.path.extension(name), archive_extension); } -/// The mods as they open, sorted by what becomes of each (`Mods.openListing`, `Mods.openChosen`): -/// loaded, off, damaged, or needing a mod that doesn't load before it. +/// The mods as they open, sorted into lists by what happens to each (`Mods.openListing`, +/// `Mods.openChosen`): loaded, off, damaged, or needing a mod that doesn't load before it. const Sorting = struct { list: std.ArrayList(Mod) = .empty, off: std.ArrayList(Mod) = .empty, damaged: std.ArrayList(Mod) = .empty, unmet: std.ArrayList(Mod) = .empty, - /// Sorts `opened`, which `on` says is on, into its list. One that needs an OpenReliant later - /// than `running` is skipped and closed. + /// Sorts `opened` into its list, `on` saying whether it's on. A mod that needs a newer + /// OpenReliant than `running` is skipped and closed. fn add(sorting: *Sorting, gpa: Allocator, opened: Mod, on: bool, running: ?std.SemanticVersion) Allocator.Error!void { var mod = opened; if (running) |version| if (mod.needsLater(version)) |needed| { @@ -1117,8 +1117,8 @@ const Sorting = struct { }; } - /// The mods as sorted, with the index of their files, and with `report_files`, what each one's - /// files replace or add written to the log (`Mods.report`). + /// The mods as sorted, with the index of their files. With `report_files`, the log lists what + /// each mod's files replace or add (`Mods.report`). fn finish(sorting: *Sorting, gpa: Allocator, io: Io, game: Io.Dir, report_files: bool) Allocator.Error!Mods { var mods: Mods = .{ .list = try sorting.list.toOwnedSlice(gpa) }; errdefer mods.close(gpa); @@ -1130,7 +1130,7 @@ const Sorting = struct { return mods; } - /// Closes the mods sorted so far, where opening them fails. + /// Closes the mods sorted so far, when opening the mods fails. fn deinit(sorting: *Sorting, gpa: Allocator) void { for ([_]*std.ArrayList(Mod){ &sorting.list, &sorting.off, &sorting.damaged, &sorting.unmet }) |kept| { for (kept.items) |*mod| mod.close(gpa); @@ -1139,12 +1139,14 @@ const Sorting = struct { } }; -/// The mod `wanted` names for `Mods.openChosen`, opened on: a path from `from` to a mod's folder or -/// archive where it has a slash in it, and otherwise a mod's name in the `mods` folder `folder`, -/// matched ignoring case. Null, with a message in the log, where there's no such mod. +/// Opens the mod that `wanted` names, on, for `Mods.openChosen`. A path, with a slash in it, leads +/// from `from` to a mod's folder or archive; a name is a mod in the `mods` folder `folder`, matched +/// ignoring case. Returns null, and logs why, when there's no such mod. fn openWanted(gpa: Allocator, io: Io, folder: ?Io.Dir, from: Io.Dir, wanted: []const u8, order: Order) Allocator.Error!?Mod { var buffer: [files.max_path]u8 = undefined; - const elsewhere = std.mem.findAny(u8, wanted, "/\\") != null; + const elsewhere = for (wanted) |byte| { + if (Io.Dir.path.isSep(byte)) break true; + } else false; const parent: Io.Dir, const name: []const u8 = if (elsewhere) .{ from.openDir(io, Io.Dir.path.dirname(wanted) orelse ".", .{}) catch { log.warn("skipping the mod {s}: there's no such folder or archive", .{wanted}); @@ -1326,12 +1328,12 @@ test "a mod that needs a newer version is skipped" { try std.testing.expectEqual(5, every.list.len); } -test "the mods --mods chooses load alone, in its order" { +test "only the chosen mods load, in their order" { const gpa = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - // Three mods in the mods folder, Gamma needing Alpha, and an archive elsewhere. + // Three mods in the mods folder, where gamma needs Alpha, and an archive elsewhere. try tmp.dir.createDirPath(io, "mods/Alpha"); try tmp.dir.writeFile(io, .{ .sub_path = "mods/Alpha/note.txt", .data = "alpha" }); try tmp.dir.createDirPath(io, "mods/beta"); @@ -1341,8 +1343,8 @@ test "the mods --mods chooses load alone, in its order" { try tmp.dir.createDirPath(io, "dist"); try hog.testing.write(gpa, io, tmp.dir, "dist/delta.hog", &.{.{ .name = "note.txt", .data = "delta" }}); - // Beta isn't chosen, a name matches ignoring case, and the archive comes by its path, last, so - // its file wins. + // Beta isn't chosen, alpha finds Alpha, and the archive, given by its path, loads last, so its + // note wins. var mods: Mods = try .openChosen(gpa, io, tmp.dir, null, .none, &.{ "alpha", "dist/delta.hog" }, tmp.dir); defer mods.close(gpa); try std.testing.expectEqual(2, mods.list.len); diff --git a/src/engine/game/mission.zig b/src/engine/game/mission.zig index b95cd32e..2357a1e0 100644 --- a/src/engine/game/mission.zig +++ b/src/engine/game/mission.zig @@ -134,10 +134,16 @@ pub const Loaded = struct { /// and leaves out the hold's (`holdClock`). pub fn tickClock(loaded: *Loaded, game_ticks: u32) void { loaded.clock_at = game_ticks; - const seconds = (game_ticks -% loaded.clock_from) / ticks_per_second; + const seconds = loaded.scriptTicks(game_ticks) / ticks_per_second; while (loaded.script.clock < seconds) executor.clockTick(&loaded.script); } + /// The script's clock at `game_ticks`, in the game's ticks: the ticks since the clock started, + /// less the ticks the editor held the script. + pub fn scriptTicks(loaded: *const Loaded, game_ticks: u32) u32 { + return game_ticks -% loaded.clock_from; + } + /// The script's clock held, as the game's ticks run on to `game_ticks` while the editor holds /// the script: they don't count toward its seconds. fn holdClock(loaded: *Loaded, game_ticks: u32) void { diff --git a/src/engine/game/mission/editor.zig b/src/engine/game/mission/editor.zig index c2d3b19f..ea4c32c7 100644 --- a/src/engine/game/mission/editor.zig +++ b/src/engine/game/mission/editor.zig @@ -145,12 +145,12 @@ pub const RunControl = extern struct { const finished_every = 5; /// The most events the game keeps between two states while an editor is linked (`Session.events`). -/// Past it, the oldest go first, and the state counts how many (`link.State.events_dropped`). +/// Past it, the oldest are dropped first, and the state counts them (`link.State.events_dropped`). pub const max_events = 512; /// The editor link as the game works it for a mission: from its script's start (`begin`) to its end -/// (`end`), the editor's messages are acted on. In between, the original's wait for the next -/// mission, and OpenReliant's own are taken as they come (`poll`). +/// (`end`), the editor's messages are acted on. In between, the original's messages wait for the +/// next mission, and OpenReliant's own are read as they come (`poll`). pub const Session = struct { link: *link.Link, /// What the script's start waits with (`vm.editor.Editor.sleep`). @@ -234,7 +234,7 @@ pub const Session = struct { session.tellMission(); }, .disconnected => if (session.loaded) |loaded| loaded.script.editor.letGo(), - .message => |message| if (session.loaded) |loaded| session.receive(loaded, message) else session.between(message), + .message => |message| if (session.loaded) |loaded| session.receive(loaded, message) else session.receiveBetween(message), }; session.tellFinished(); } @@ -274,9 +274,10 @@ pub const Session = struct { } } - /// Acts on one of OpenReliant's own messages while no mission plays: a console line is kept for - /// the game to run, and a request for the mission's file or the script's state is passed over. - fn between(session: *Session, message: link.Message) void { + /// Acts on one of OpenReliant's own messages between missions, while no mission plays: a + /// console line is kept for the game to run, and a request for the mission's file or the + /// script's state is passed over. + fn receiveBetween(session: *Session, message: link.Message) void { switch (link.Own.of(message.tag) orelse return) { .console_line => session.keepConsoleLine(message.payload), else => log.debug("the editor sent tag 0x{X} with no mission playing, so the game passes over it", .{message.tag}), @@ -286,7 +287,7 @@ pub const Session = struct { /// Keeps the console line in `payload` for the game to run (`console_lines`). fn keepConsoleLine(session: *Session, payload: []const u8) void { const console = link.ConsoleLine.read(payload) catch { - log.warn("the editor sent a console line without its line, so the game passes over it", .{}); + log.warn("the editor sent a console line with no line in it, so the game passes over it", .{}); return; }; const gpa = session.link.gpa; @@ -357,13 +358,12 @@ pub const Session = struct { } /// Keeps what the script did for the editor's next state, with when it happened on the - /// script's clock: the game's ticks since the clock started, less the ticks the editor held the - /// script (`mission.Loaded.tickClock`). + /// script's clock (`mission.Loaded.scriptTicks`). fn happened(context: *anyopaque, event: vm.editor.Event) void { const session: *Session = @ptrCast(@alignCast(context)); const loaded = session.loaded orelse return; const ticks = if (loaded.script.game) |playing| playing.world.clock.game_ticks else loaded.clock_at; - const milliseconds = @as(u64, ticks -% loaded.clock_from) * std.time.ms_per_s / ticks_per_second; + const milliseconds = @as(u64, loaded.scriptTicks(ticks)) * std.time.ms_per_s / ticks_per_second; const time = std.math.cast(u32, milliseconds) orelse std.math.maxInt(u32); if (session.events.items.len == max_events) { _ = session.events.orderedRemove(0); @@ -677,14 +677,8 @@ test "the game tells the editor of the first thread once a fifth second, while n var t: Test = undefined; try t.init(gpa); defer t.deinit(); - t.session.begin(t.loaded, .{ .number = 1, .file = "mission1.dte" }); - t.pipe.connect(); - t.session.poll(); + try t.start(); var buffer: [128]u8 = undefined; - _ = try t.heard(&buffer); - _ = try t.heard(&buffer); - try t.expectFile(); - _ = try t.word(.first_finished); const script = &t.loaded.script; for ([_]struct { clock: u32, told: bool }{ .{ .clock = 0, .told = false }, @@ -732,14 +726,7 @@ test "the editor asks for the mission's file and the script's state" { var t: Test = undefined; try t.init(gpa); defer t.deinit(); - t.session.begin(t.loaded, .{ .number = 1, .file = "mission1.dte" }); - t.pipe.connect(); - t.session.poll(); - var buffer: [128]u8 = undefined; - _ = try t.heard(&buffer); - _ = try t.heard(&buffer); - try t.expectFile(); - _ = try t.word(.first_finished); + try t.start(); // Asked again, the mission's file comes back whole. try t.pipe.sendToGame(@backingInt(link.Own.get_mission), ""); @@ -754,14 +741,9 @@ test "the editor asks for the mission's file and the script's state" { t.loaded.script.runPart((try t.loaded.bound.file.parts())[0]); _ = try t.word(.stopped); t.loaded.script.variables.mission_over = 1; - try t.pipe.sendToGame(@backingInt(link.Own.get_state), ""); - t.session.poll(); - var text: [4096]u8 = undefined; - const state = t.pipe.takeFromGame(&text) orelse return error.TestExpectedMessage; - try std.testing.expectEqual(@backingInt(link.Own.state), state.tag); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - const state_read: link.State = try .read(arena.allocator(), state.payload); + const state_read = try t.state(arena.allocator()); try std.testing.expectEqual(vm.editor.Hold.held, state_read.hold); try std.testing.expectEqual(Test.first, state_read.threads[0].at.?); try std.testing.expectEqual(0, state_read.threads[0].depth); @@ -772,7 +754,8 @@ test "the editor asks for the mission's file and the script's state" { // A tag only the game sends is passed over. try t.pipe.sendToGame(@backingInt(link.Own.state), ""); t.session.poll(); - try std.testing.expectEqual(null, t.pipe.takeFromGame(&text)); + var buffer: [128]u8 = undefined; + try std.testing.expectEqual(null, t.pipe.takeFromGame(&buffer)); } test "the state gives the ships' orders, and what the script did since the last state" { @@ -800,18 +783,19 @@ test "the state gives the ships' orders, and what the script did since the last defer arena_state.deinit(); const arena = arena_state.allocator(); - // The ships in play, the destroyed one left out, and the Sabre's order and target. + // Trigger 1 fires and command 11 runs, 41.8 seconds into the mission. t.loaded.clock_from = 1000; world.clock.game_ticks = 1000 + 4180; t.loaded.script.editor.report(.{ .trigger = 1 }); t.loaded.script.editor.report(.{ .command = 11 }); const busy = try t.state(arena); + // The ships in play, the destroyed one left out, and the Sabre's order and target. try std.testing.expectEqual(2, busy.ships.len); try std.testing.expectEqual(player, busy.ships[0].index); try std.testing.expectEqual(sabre, busy.ships[1].index); try std.testing.expectEqualStrings("fight", busy.ships[1].order); try std.testing.expectEqual(player, busy.ships[1].target.?); - // The events, each when it happened on the script's clock, in milliseconds. + // The events, each with when it happened on the script's clock, in milliseconds. try std.testing.expectEqualSlices(link.State.ScriptEvent, &.{ .{ .kind = .trigger, .index = 1, .time = 41_800 }, .{ .kind = .command, .index = 11, .time = 41_800 }, diff --git a/src/engine/link.zig b/src/engine/link.zig index 3ac879ec..de89fefc 100644 --- a/src/engine/link.zig +++ b/src/engine/link.zig @@ -244,9 +244,9 @@ pub const State = struct { const ReadEvent = struct { kind: ?ScriptEvent.Kind = null, index: u32 = 0, time: u32 = 0 }; /// The state in `payload`, its lists made in `arena`, its names borrowed from `payload`. A - /// global's index fits the 16 bits a mission's directory counts its globals in, and a ship's the - /// 16 bits of an object's slot. The events are numbered from 0 with none missing, and each has - /// its kind. + /// global's index must fit the 16 bits a mission's directory counts its globals in, and a + /// ship's index the 16 bits of an object's slot. The events must be numbered from 0 with none + /// missing, and each must have its kind. pub fn read(arena: Allocator, payload: []const u8) (Allocator.Error || ReadError)!State { var clock: ?u32 = null; var hold: ?vm_editor.Hold = null; @@ -849,7 +849,8 @@ test "taking OpenReliant's own messages leaves the original's waiting" { try pipe.sendToGame(0x15, &.{ 0, 0, 0, 0, 1, 0, 0, 0 }); try pipe.sendToGame(@backingInt(Own.console_line), "line=help\n"); try std.testing.expectEqual(Own.console_line, Own.of(link.next(.own).?.message.tag).?); - // The original's tag waits, and the console line after it with it. + // The original's tag waits however often the link is asked, and the console line after it + // waits with it. try std.testing.expectEqual(null, link.next(.own)); try std.testing.expectEqual(null, link.next(.own)); try std.testing.expectEqual(0x15, link.next(.all).?.message.tag); diff --git a/src/engine/vm/editor.zig b/src/engine/vm/editor.zig index 7eb35752..cae10228 100644 --- a/src/engine/vm/editor.zig +++ b/src/engine/vm/editor.zig @@ -1,10 +1,10 @@ //! The script VM's side of the editor link (docs/engine/editor-link.md): what the VM's globals keep //! for the editor, and how `vm_run` (`0x0045C980`) stops and steps a thread for it. The editor's //! messages set the hold and the step (`editor_run_control`, `0x0045A900`, ported as the game's -//! handler of tag `0x15`), and the VM reaches the editor through `Editor.connection`, which tells -//! it where a thread stopped and what the script did, and reads its messages while the script -//! starts. None of it is a game's: a game's link fills `Editor.present` and `Editor.connection` from -//! its own connection. +//! handler of tag `0x15`). The VM talks to the editor through `Editor.connection`: it tells the +//! editor where a thread stopped and what the script did, and reads the editor's messages while the +//! script starts. Nothing here belongs to one game: the game's link fills in `Editor.present` and +//! `Editor.connection` from its own connection. const std = @import("std"); diff --git a/src/openreliant/console.zig b/src/openreliant/console.zig index 16cf24f3..a33b8966 100644 --- a/src/openreliant/console.zig +++ b/src/openreliant/console.zig @@ -106,6 +106,16 @@ pub const Driver = struct { }; } + /// Runs the lines an editor sent, in `scripts`, as if they were typed into the console + /// (`engine.link.ConsoleLine`). Returns whether one of them asked to reload the scripts. + pub fn runLines(driver: *Driver, lines: []const []const u8, scripts: console_module.Scripts) Allocator.Error!bool { + var reload = false; + for (lines) |line| if (try driver.console.runText(scripts, line)) |request| switch (request) { + .reload => reload = true, + }; + return reload; + } + /// Draws the console over the frame drawn into `target`, a window `window` pixels across and /// down, while it's up, over `backdrop`. pub fn draw(driver: *Driver, target: device.Device, window: [2]u32, strings: *const game.language.Language, backdrop: screen.Backdrop) Allocator.Error!void { diff --git a/src/openreliant/main.zig b/src/openreliant/main.zig index d9ece555..6383fa54 100644 --- a/src/openreliant/main.zig +++ b/src/openreliant/main.zig @@ -830,8 +830,9 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] .own = own.interface(), .video = video_settings, .saves = .{ .gpa = gpa, .folder = saving.folder, .game = saving.gameOf(&flow.loading), .strings = &strings, .local_time = localDate }, - // The mods screen, which with `--no-mods` stays shut. - .mods = if (options.mods) .{ .loaded = &mods, .gpa = gpa, .io = io, .game = directory, .version = version.semantic, .pages = option_pages.pages() } else null, + // The mods screen, which stays shut with `--no-mods`, and with `--mods`, so that it can't + // save the chosen mods as the list in the settings file. + .mods = if (options.mods and chosen_mods.len == 0) .{ .loaded = &mods, .gpa = gpa, .io = io, .game = directory, .version = version.semantic, .pages = option_pages.pages() } else null, .modes = game_modes.shown.items, // The menu scripts' screens that stand in for the front end's own. .scripted = if (presentation) |shown| shown.scripted() else null, @@ -928,8 +929,9 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] try game.winmain.followActivation(&app, pausing, play.loaded != null); if (output) |open| open.update(); const size = presenter.size(); - // The scripting console, which takes the keys while it's up. The scripts reload when it - // asks, and when a folder mod's script or shader is saved. + // The scripting console, which takes the keys while it's up, and runs the lines an editor + // sends. The scripts reload when it asks, and when a folder mod's script or shader is + // saved. if (console) |*shown| { devices.keyboard.read(); const mission = !flow.in_front_end and play.loaded != null; @@ -940,13 +942,8 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] .reload => reloading = true, }; } else if (ScriptConsole.asked(&devices.keyboard)) try shown.bringUp(pausing, mission, &typed); - // The lines an editor sent run as if typed into the console (`engine.link.ConsoleLine`). if (editor) |session| { - for (session.console_lines.items) |line| { - if (try shown.console.runText(game_scripts.reachable(), line)) |request| switch (request) { - .reload => reloading = true, - }; - } + if (try shown.runLines(session.console_lines.items, game_scripts.reachable())) reloading = true; session.clearConsoleLines(); } if (reloading) try game_scripts.reload(if (mission) .{ @@ -955,7 +952,7 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] .seed = game.main.scriptSeed(world.random, play.number), } else null); } else if (editor) |session| if (session.console_lines.items.len > 0) { - std.log.warn("the editor sent a line for the console, which needs --developer-mode and a mod with scripts, so the game passes over it", .{}); + std.log.warn("ignoring the editor's console line: the console needs --developer-mode and a mod with scripts", .{}); session.clearConsoleLines(); }; const console_up = if (console) |*shown| shown.isUp() else false; diff --git a/src/openreliant/options.zig b/src/openreliant/options.zig index 991ec711..e9852ab8 100644 --- a/src/openreliant/options.zig +++ b/src/openreliant/options.zig @@ -144,7 +144,7 @@ const docs: std.enums.EnumArray(Arg, Doc) = .init(.{ .@"--no-compressor" = .{ .section = .sound, .text = "leave the mix's loudness as it is, only keeping its peaks in check" }, .@"--no-sound" = .{ .section = .sound, .text = "play without sound" }, .@"--no-mods" = .{ .section = .other, .text = "start without the mods in the game's mods folder" }, - .@"--mods" = .{ .section = .other, .value = "", .text = "start with only these mods, up to 64, loaded in this order, whatever the mods screen has on, and leave its list as it is: each by its name in the game's mods folder, or by a path to a mod's folder or .hog file elsewhere. A mod that needs another loads only where that one comes before it. --no-mods takes over" }, + .@"--mods" = .{ .section = .other, .value = "", .text = "start with only these mods, up to 64, in this order, whatever the mods screen has on. Give the name of a mod in the game's mods folder, or the path to a mod's folder or .hog file elsewhere. A mod that needs another loads only if that one comes before it. The mods screen stays shut, so its list doesn't change. --no-mods overrides it" }, .@"--no-intro" = .{ .section = .other, .text = "start without the three movies the game plays as it starts, as --mission and --screenshot do" }, .@"--developer-mode" = .{ .section = .other, .text = "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" }, .@"--editor-link" = .{ .section = .other, .text = "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" }, @@ -265,7 +265,7 @@ pub const Options = struct { /// Whether to load the mods in the game's `mods` folder (`game.bigfile.Mods`). mods: bool = true, /// The mods `--mods` names, separated by commas, which are the only ones loaded - /// (`chosenMods`); null for the mods screen's. + /// (`chosenMods`); null to load the ones the mods screen has on. mods_chosen: ?[]const u8 = null, /// Whether the tools for writing mods' scripts are on: the scripting console, and folder mods' /// scripts reloading as they're saved (`console.Driver`). @@ -371,8 +371,8 @@ pub const Options = struct { return options.part_names[0..options.part_count]; } - /// The mods `--mods` names, in the order given, into `names`: each with the spaces around it - /// trimmed, and the empty ones left out. Parsing has made sure they fit. + /// The mods `--mods` names, in the order given, put into `names`: each with the spaces around + /// it trimmed, and the empty ones left out. Parsing allows no more than `names` holds. pub fn chosenMods(options: *const Options, names: *[max_chosen_mods][]const u8) []const []const u8 { var count: usize = 0; var each = std.mem.splitScalar(u8, options.mods_chosen orelse return &.{}, ','); @@ -676,7 +676,8 @@ test Options { try std.testing.expectError(error.Usage, parsed(&.{ "--seed", "-1" })); try std.testing.expect((try parsed(&.{})).intro); try std.testing.expect(!(try parsed(&.{"--no-intro"})).intro); - // Mods are loaded unless `--no-mods` is given, the mods screen's unless `--mods` chooses them. + // Mods are loaded unless `--no-mods` is given: the ones the mods screen has on, or the ones + // `--mods` names. try std.testing.expect((try parsed(&.{})).mods); try std.testing.expect(!(try parsed(&.{"--no-mods"})).mods); try std.testing.expectEqual(null, (try parsed(&.{})).mods_chosen); diff --git a/src/scripting/console.zig b/src/scripting/console.zig index e3c04642..3e48545a 100644 --- a/src/scripting/console.zig +++ b/src/scripting/console.zig @@ -23,8 +23,8 @@ const Game = @import("game.zig").Game; const Presentation = @import("presentation.zig").Presentation; const reference = @import("reference.zig"); -/// The log the console's own lines go to as well as to its screen (`Console.say`). A failure goes -/// to it as a warning: it's the line's, not the game's. +/// The log that the console's own lines also go to (`answer`). A failure is logged as a warning: +/// the line failed, not the game. const console_log = std.log.scoped(.console); pub const screen = @import("console/screen.zig"); @@ -125,9 +125,8 @@ pub const Output = struct { /// The console's output, which the log writes to (`log`). pub var output: Output = .{}; -/// Writes `text`, the console's own, to its output, and to the log too, a message for each of its -/// lines, under `console` (`console_log`), so that an editor that sends the console lines reads -/// what it answers. +/// Writes `text`, the console's own answer, to its output, and logs each of its lines under +/// `console` (`console_log`), so that an editor that sends the console lines can read the answers. fn answer(tone: Tone, text: []const u8) void { output.add(tone, text); var lines = std.mem.splitScalar(u8, text, '\n'); @@ -303,8 +302,8 @@ pub const Console = struct { return console.runText(scripts, utf8); } - /// Runs `text`, a line of UTF-8, as `run` runs the line typed: one of the console's commands, - /// Luau, or a line for the scripts. An editor's console lines come this way + /// Runs `text`, a line of UTF-8, as `run` runs a typed line: one of the console's commands, + /// Luau, or a line for the scripts. An editor's console lines run this way /// (`link.ConsoleLine`). pub fn runText(console: *Console, scripts: Scripts, text: []const u8) Allocator.Error!?Request { var prompt_buffer: [prompt_room]u8 = undefined;