diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index f4e1043d..3e6315f7 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 +- 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 @@ -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`: 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 because more than 512 came between two states. The oldest are dropped first | + +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. 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 there +([The console](../guide/scripting.md#the-console)): one of the console's commands, such as +`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 @@ -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)). +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 @@ -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..ff9578cd 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, 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 6e1d66c5..26445cf7 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -182,6 +182,11 @@ 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 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. - 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..217e9684 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. +- 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 a6abf3b8..9ae86159 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 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; + 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,88 @@ fn isArchive(name: []const u8) bool { return std.ascii.eqlIgnoreCase(std.Io.Dir.path.extension(name), archive_extension); } +/// 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` 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| { + 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. 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); + 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, 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); + kept.deinit(gpa); + } + } +}; + +/// 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 = 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}); + 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 +1328,41 @@ test "a mod that needs a newer version is skipped" { try std.testing.expectEqual(5, every.list.len); } +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, 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"); + 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, 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); + 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.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 7a1660ef..ea4c32c7 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 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, and in between they wait. +/// (`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`). @@ -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.receiveBetween(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,52 @@ 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 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}), + } + } + + /// 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 with no line in it, 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 +356,33 @@ 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 (`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, 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); + 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; @@ -547,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 }, @@ -602,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), ""); @@ -624,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); @@ -642,5 +754,91 @@ 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" { + 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(); + + // 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 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 }, + }, 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..de89fefc 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 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; @@ -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,33 @@ 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 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); + 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..cae10228 100644 --- a/src/engine/vm/editor.zig +++ b/src/engine/vm/editor.zig @@ -1,9 +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 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"); @@ -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/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 ba72d4f9..6383fa54 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. @@ -823,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, @@ -921,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; @@ -933,12 +942,19 @@ 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); + if (editor) |session| { + if (try shown.runLines(session.console_lines.items, game_scripts.reachable())) 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("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; world.view = view.view; @@ -2128,6 +2144,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..e9852ab8 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, 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" }, @@ -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 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`). 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, 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 &.{}, ','); + 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,21 @@ 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 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); + 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..3e48545a 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 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"); test { @@ -121,6 +125,17 @@ pub const Output = struct { /// The console's output, which the log writes to (`log`). pub var output: Output = .{}; +/// 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'); + 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 +299,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 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; - 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 +355,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 +378,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 +453,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()); } };