From 9faca9cfa8d184af252aed19be9610ecd16e838f Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Wed, 14 Oct 2026 17:28:01 +0200 Subject: [PATCH 1/3] feat: what the editor's debugger needs from the link and the compiler The editor's debugger sets breakpoints on source lines, shows the line the script is held at, and reads the script's state. - The compiler can give a map from the source's lines to the script's statements (compileMapped, source_parser.buildMapped): each statement's offset, its place in the source, and whether the VM stops before it. - OpenReliant's own messages are records in the library (link.Hello, link.MissionStarted, link.State), which the game writes and openreliant debug reads, so every client reads them the same way. - While an editor is linked, the mission's start waits for the editor's flags, for a second at most, instead of a fixed fifth of a second, so a breakpoint in a start part stops it with a slower editor too. Closes #1206 --- docs/engine/editor-link.md | 23 +- docs/formats/mission-source.md | 7 + docs/guide/debugging.md | 8 +- src/engine/game/mission/editor.zig | 108 +++++---- src/engine/link.zig | 294 +++++++++++++++++++++--- src/engine/vm/editor.zig | 81 ++++++- src/engine/vm/machine.zig | 22 +- src/formats/dte/source/compile.zig | 56 ++++- src/formats/dte/source/compile/code.zig | 10 +- src/formats/dte/statements.zig | 55 ++++- src/openreliant/debug.zig | 80 +++---- src/source_parser.zig | 57 +++++ 12 files changed, 628 insertions(+), 173 deletions(-) diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index 944f3414..c3edea18 100644 --- a/docs/engine/editor-link.md +++ b/docs/engine/editor-link.md @@ -283,8 +283,8 @@ Values are the script's words, unsigned. ([#1057](https://github.com/OpenReliant/openreliant/issues/1057)). It sends tags `0x1A` and `0x1B`. - 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 - attempt: an editor sends them again once the file comes with each `Mission started`, a fifth of a - second before the start parts run. + 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 first mission's script starts. - The script's start waits on the editor as the original's does, drawing nothing until the editor @@ -305,8 +305,10 @@ Values are the script's words, unsigned. editor had its own copy of the mission and its debugger only heard where the script stopped. - As the editor goes, the script is let go: its hold, its step and the editor's pause clear. The original keeps the script held, waiting for good. -- The script's start waits for the editor only while one is there, where the original waits a fifth - of a second at every mission's start. +- While an editor is linked, the script's start waits for its flags before the start parts run, for + a second at most, and checks the link every millisecond meanwhile. With no editor linked, it + doesn't wait. The original waits a fifth of a second at every mission's start, whether an editor + is there or not, and the breakpoints of a slower editor miss the start parts. - A tag past `0x17` is logged and passed over, where the original stops the game. - While the editor holds the script or pauses the mission, OpenReliant still draws the frame, with the frame's work left out. @@ -361,10 +363,17 @@ It works the way `openreliant debug` does: 1. Connect to 127.0.0.1 at the port, and read `Hello`. 2. On `Mission file`, which comes right after `Mission started`, send tag `0x09` with the script's - flags within a fifth of a second: a byte for each of the script's bytes, rounded up to a - multiple of 4, with bit 0 set on each byte to stop before. The statements end at the opcodes - `vm_starts_statement` gives (above), which a step stops before. + flags: a byte for each of the script's bytes, rounded up to a multiple of 4, with bit 0 set on + each byte to stop before. Send them with no breakpoints too, since the start parts wait for them + for up to a second. The statements end at the opcodes `vm_starts_statement` gives (above), which + a step stops before. 3. On tag `0x1A`, the script is held before the byte at the offset it gives: send tag `0x15` 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` 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)). diff --git a/docs/formats/mission-source.md b/docs/formats/mission-source.md index fce96d14..0c8ea00c 100644 --- a/docs/formats/mission-source.md +++ b/docs/formats/mission-source.md @@ -292,6 +292,13 @@ kind's records are written in the order of their declarations. The script holds triggers run, in the order the triggers first run them, then the parts' blocks in order, each written as the developers' compiler wrote its statements ([Statements](dte.md#statements)). +For a debugger, `compileMapped` also gives a map of the script's statements. For each statement, it +gives the offset in the script of the instruction the statement ends with, as the editor link gives +a stop, the line and column of its source, and whether the script stops before it. The script stops +before an assignment, a command, a call, a spawn and an `if`, but not before a `return` or a random +choice ([Stopping and stepping](../engine/editor-link.md#stopping-and-stepping)). +`source_parser.buildMapped` parses source and compiles it with the map. + OpenReliant's own Mission 0, the sandbox, is mission source too: [`mission0.mission.luau`](../../src/openreliant/mission0.mission.luau), which the build compiles with the same compiler, built for the host ([`build_mission.zig`](../../src/tools/build_mission.zig)). diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md index 1166ad50..5655465c 100644 --- a/docs/guide/debugging.md +++ b/docs/guide/debugging.md @@ -85,10 +85,10 @@ conditional branch, a return or a spawn, after the instructions that push its op numbers the statements at those instructions, and `break :` stops before the `n`th. Breakpoints stay set from one mission to the next. As a mission starts, the game sends the debugger -the mission's file, and the debugger places the breakpoints in its script before the start parts -run, so a breakpoint set before the game starts can stop them. Each attempt at a mission reads its -file again, and the breakpoints are placed again for it. A breakpoint whose part the mission doesn't -have waits for one that has it. +the mission's file, and waits for the debugger to place the breakpoints in its script before the +start parts run, so a breakpoint set before the game starts can stop them. Each attempt at a +mission reads its file again, and the breakpoints are placed again for it. A breakpoint whose part +the mission doesn't have waits for one that has it. `step` and `next` work from where the script stopped. From a running script, they stop at the next statement any thread runs. diff --git a/src/engine/game/mission/editor.zig b/src/engine/game/mission/editor.zig index e8872df3..0df54e11 100644 --- a/src/engine/game/mission/editor.zig +++ b/src/engine/game/mission/editor.zig @@ -186,7 +186,7 @@ pub const Session = struct { loaded.session = null; loaded.script.editor.connection = null; session.loaded = null; - session.link.sendFields(.mission_ended, &.{}); + session.link.send(@backingInt(link.Own.mission_ended), ""); } /// Once a frame, and wherever the script's start checks the link (`editor_link_check`): an @@ -220,7 +220,10 @@ pub const Session = struct { return; } switch (received[message.tag].action) { - .copy => |section| copy(&loaded.bound, section, message.payload), + .copy => |section| { + copy(&loaded.bound, section, message.payload); + if (section == .script_flags) loaded.script.editor.flagged = true; + }, .run_control => runControl(loaded, message.payload), .unread, .nothing => {}, .not_ported => if (!session.logged.isSet(message.tag)) { @@ -239,26 +242,23 @@ pub const Session = struct { } } - /// Tells the editor the script's state (`writeState`). + /// Tells the editor the script's state (`stateOf`). fn tellState(session: *Session, loaded: *mission.Loaded) void { - var text: std.Io.Writer.Allocating = .init(session.link.gpa); - defer text.deinit(); - writeState(&text.writer, loaded) catch { + var arena: std.heap.ArenaAllocator = .init(session.link.gpa); + defer arena.deinit(); + const state = stateOf(arena.allocator(), loaded) catch { log.warn("the link has no memory left for the script's state", .{}); return; }; - session.link.send(@backingInt(link.Own.state), text.written()); + session.link.sendRecord(.state, state); } /// Tells the editor of the mission playing, as it starts or as an editor connects, and sends /// it the mission's file, which it places its breakpoints by before the start parts run. fn tellMission(session: *Session) void { const loaded = session.loaded orelse return; - var number: [std.fmt.count("{d}", .{std.math.maxInt(u16)})]u8 = undefined; - session.link.sendFields(.mission_started, &.{ - .{ .key = "number", .value = number[0..std.fmt.printInt(&number, session.number, 10, .lower, .{})] }, - .{ .key = "file", .value = session.file_buffer[0..session.file_length] }, - }); + const started: link.MissionStarted = .{ .number = session.number, .file = session.file_buffer[0..session.file_length] }; + session.link.sendRecord(.mission_started, started); session.link.send(@backingInt(link.Own.mission_file), loaded.bound.image); } @@ -294,46 +294,47 @@ pub const Session = struct { } }; -/// The script's state as `link.Own.state` gives it, a `key=value` line each: its clock (`clock`), -/// the editor's hold, step and pause (`hold`, `step`, `paused`), and whether the pool's first -/// thread has finished (`first_finished`); for each thread running, by its slot, where it is in the -/// script (`thread.N.at`, none outside it), its call depth, the clock value it waits for, 0 for -/// none, whether it waits for its trigger, and the trigger that started it, if one did; each -/// global's value (`global.N`); and the game's variables (`variable.NAME`), by their names, and -/// the unnamed ones by their numbers where they aren't 0. Values are the script's words, unsigned. -fn writeState(writer: *std.Io.Writer, loaded: *mission.Loaded) std.Io.Writer.Error!void { +/// The script's state as `link.Own.state` gives it (`link.State`), its lists made in `arena`: the +/// threads running, a thread whose instruction lies outside the script at none; each global; and +/// the game's variables, by their names, and the unnamed ones by their numbers where they aren't 0. +fn stateOf(arena: std.mem.Allocator, loaded: *mission.Loaded) std.mem.Allocator.Error!link.State { const script = &loaded.script; - const editor = script.editor; - try writer.print("clock={d}\nhold={t}\nstep={t}\npaused={d}\nfirst_finished={d}\n", .{ - script.clock, - editor.hold, - editor.step, - @intFromBool(editor.paused), - @intFromBool(script.first_finished), - }); const code = loaded.bound.file.entry(.script); - var threads = script.liveThreads(); - while (threads.next()) |found| { + var threads: std.ArrayList(link.State.Thread) = .empty; + var live = script.liveThreads(); + while (live.next()) |found| { const thread, const index = found; const ip = thread.ip orelse continue; const at = ip -% code.offset; - if (at < @as(u32, code.count) * @sizeOf(u16)) { - try writer.print("thread.{d}.at={d}\n", .{ index, at }); - } else try writer.print("thread.{d}.at=none\n", .{index}); - try writer.print("thread.{d}.depth={d}\nthread.{d}.wake={d}\nthread.{d}.interrupted={d}\n", .{ - index, thread.record.call_depth, index, thread.record.wake_time, index, @intFromBool(thread.record.interrupted), + try threads.append(arena, .{ + .slot = @intCast(index), + .at = if (at < @as(u32, code.count) * @sizeOf(u16)) at else null, + .depth = thread.record.call_depth, + .wake = thread.record.wake_time, + .interrupted = thread.record.interrupted, + .trigger = if (thread.trigger) |trigger| @intCast(trigger) else null, }); - if (thread.trigger) |trigger| try writer.print("thread.{d}.trigger={d}\n", .{ index, trigger }); - } - for (loaded.bound.file.globals() catch &.{}, 0..) |global, index| { - try writer.print("global.{d}={d}\n", .{ index, global.value }); } + const file_globals = loaded.bound.file.globals() catch &.{}; + const globals = try arena.alloc(u32, file_globals.len); + for (globals, file_globals) |*value, global| value.* = global.value; + var variables: std.ArrayList(link.State.Variable) = .empty; for (0..vm.Variables.count) |number| { const value = script.variables.slot(@intCast(number)).*; if (std.enums.tagName(vm.GameVariable, @fromBackingInt(@intCast(number)))) |name| { - try writer.print("variable.{s}={d}\n", .{ name, value }); - } else if (value != 0) try writer.print("variable.{d}={d}\n", .{ number, value }); + try variables.append(arena, .{ .name = name, .value = value }); + } else if (value != 0) try variables.append(arena, .{ .name = try arena.print("{d}", .{number}), .value = value }); } + return .{ + .clock = script.clock, + .hold = script.editor.hold, + .step = script.editor.step, + .paused = script.editor.paused, + .first_finished = script.first_finished, + .threads = threads.items, + .globals = globals, + .variables = variables.items, + }; } /// `editor_table_copy` (`0x0045A770`): `payload` copied over a section of the mission, as many @@ -468,16 +469,20 @@ test Session { try std.testing.expectEqual(@backingInt(link.Own.hello), (try t.heard(&buffer)).tag); const started = try t.heard(&buffer); try std.testing.expectEqual(@backingInt(link.Own.mission_started), started.tag); - try std.testing.expectEqualStrings("7", link.field(started.payload, "number").?); - try std.testing.expectEqualStrings("mission7.dte", link.field(started.payload, "file").?); + const started_read: link.MissionStarted = try .read(started.payload); + try std.testing.expectEqual(7, started_read.number); + try std.testing.expectEqualStrings("mission7.dte", started_read.file); try t.expectFile(); try std.testing.expectEqual(0, try t.word(.first_finished)); - // It flags the part's first instruction, and the part stops before it. + // It flags the part's first instruction, which the script's start waits for, and the part + // stops before it. var flags: [32]u8 = @splat(0); flags[Test.first] = 1; try t.pipe.sendToGame(0x09, &flags); + try std.testing.expect(!t.loaded.script.editor.flagged); t.session.poll(); + try std.testing.expect(t.loaded.script.editor.flagged); const part = (try t.loaded.bound.file.parts())[0]; t.loaded.script.runPart(part); try std.testing.expectEqual(Test.first, try t.word(.stopped)); @@ -618,12 +623,15 @@ test "the editor asks for the mission's file and the script's state" { 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); - try std.testing.expectEqualStrings("held", link.field(state.payload, "hold").?); - try std.testing.expectEqualStrings(std.fmt.comptimePrint("{d}", .{Test.first}), link.field(state.payload, "thread.0.at").?); - try std.testing.expectEqualStrings("0", link.field(state.payload, "thread.0.depth").?); - try std.testing.expectEqualStrings("0", link.field(state.payload, "global.1").?); - try std.testing.expectEqualStrings("1", link.field(state.payload, "variable.mission_over").?); - try std.testing.expectEqual(null, link.field(state.payload, "variable.200")); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const state_read: link.State = try .read(arena.allocator(), state.payload); + 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); + try std.testing.expectEqual(0, state_read.globals[1]); + try std.testing.expectEqual(1, state_read.variable("mission_over").?); + try std.testing.expectEqual(null, state_read.variable("200")); // A tag only the game sends is passed over. try t.pipe.sendToGame(@backingInt(link.Own.state), ""); diff --git a/src/engine/link.zig b/src/engine/link.zig index fb15ecce..709d3a34 100644 --- a/src/engine/link.zig +++ b/src/engine/link.zig @@ -1,9 +1,11 @@ //! OpenReliant's editor link (docs/engine/editor-link.md): the link the original keeps with its own //! 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`), and whether an editor is there. 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`). +//! 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 +//! `game.mission.editor`), and a script VM reports through it where a thread stopped +//! (`vm.editor`). //! //! **Improvement:** the original reads the editor's messages only while a mission's script starts //! (`editor_link_check`, `0x00457670`); OpenReliant reads them every frame. @@ -12,6 +14,8 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const vm_editor = @import("vm/editor.zig"); + const log = std.log.scoped(.link); /// A message: its tag, and its payload. @@ -85,6 +89,193 @@ pub const Game = struct { vm: []const u8, }; +/// Why a client can't read one of OpenReliant's own messages: a field it needs is missing, or isn't +/// what the field holds. +pub const ReadError = error{Invalid}; + +/// `Own.hello`: the link's protocol (`protocol_version`), OpenReliant's version, and the game with +/// its script VM's dialect. +pub const Hello = struct { + protocol: u32, + openreliant: []const u8, + game: Game, + + pub fn write(hello: Hello, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("protocol={d}\nopenreliant={s}\ngame={s}\nvm={s}\n", .{ hello.protocol, hello.openreliant, hello.game.name, hello.game.vm }); + } + + /// The hello in `payload`, whose text it borrows. + pub fn read(payload: []const u8) ReadError!Hello { + return .{ + .protocol = try number(u32, payload, "protocol"), + .openreliant = try text(payload, "openreliant"), + .game = .{ .name = try text(payload, "game"), .vm = try text(payload, "vm") }, + }; + } +}; + +/// `Own.mission_started`: the mission's number, and its file's name, such as `mission1.dte`. +pub const MissionStarted = struct { + number: u16, + file: []const u8, + + pub fn write(started: MissionStarted, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("number={d}\nfile={s}\n", .{ started.number, started.file }); + } + + /// The start in `payload`, whose text it borrows. + pub fn read(payload: []const u8) ReadError!MissionStarted { + return .{ .number = try number(u16, payload, "number"), .file = try text(payload, "file") }; + } +}; + +/// `Own.state`: the script's state as the game holds it. Its values are the script's words, +/// unsigned. +pub const State = struct { + /// The script's clock, in seconds. + clock: u32, + /// The editor's hold on the script, its step and its pause of the mission. + hold: vm_editor.Hold, + step: vm_editor.Step, + paused: bool, + /// Whether the thread pool's first thread has finished: the start parts are done. + first_finished: bool, + /// The threads running, in the order of their slots. + threads: []const Thread, + /// Each of the mission's globals, by its index. + globals: []const u32, + /// The game's variables: each named one, and each other one that isn't 0, by its number. + variables: []const Variable, + + pub const Thread = struct { + slot: u32, + /// Where it is in the script, as an offset; null outside it. + at: ?u32, + /// Its call depth. + depth: u32, + /// The clock value it waits for; 0 for none. + wake: u32, + /// Whether it waits for its trigger. + interrupted: bool, + /// The trigger that started it, where one did. + trigger: ?u32, + }; + + pub const Variable = struct { + /// The game's name for it, such as `mission_over`, or its number where it has none. + name: []const u8, + value: u32, + }; + + /// 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. + 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), + }); + for (state.threads) |thread| { + if (thread.at) |at| try writer.print("thread.{d}.at={d}\n", .{ thread.slot, at }) else try writer.print("thread.{d}.at=none\n", .{thread.slot}); + try writer.print("thread.{d}.depth={d}\nthread.{d}.wake={d}\nthread.{d}.interrupted={d}\n", .{ + thread.slot, thread.depth, thread.slot, thread.wake, thread.slot, @intFromBool(thread.interrupted), + }); + if (thread.trigger) |trigger| try writer.print("thread.{d}.trigger={d}\n", .{ thread.slot, trigger }); + } + 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 }); + } + + /// The state in `payload`, its lists made in `arena`, its names borrowed from `payload`. + pub fn read(arena: Allocator, payload: []const u8) (Allocator.Error || ReadError)!State { + var threads: std.ArrayList(Thread) = .empty; + var globals: std.ArrayList(u32) = .empty; + var variables: std.ArrayList(Variable) = .empty; + var lines = std.mem.splitScalar(u8, payload, '\n'); + while (lines.next()) |line| { + const key, const value = std.mem.cutScalar(u8, line, '=') orelse continue; + if (std.mem.cutPrefix(u8, key, "thread.")) |rest| { + const slot_text, const name = std.mem.cutScalar(u8, rest, '.') orelse return error.Invalid; + const slot = parse(u32, slot_text) catch return error.Invalid; + const thread = try threadIn(arena, &threads, slot); + if (std.mem.eql(u8, name, "at")) { + thread.at = if (std.mem.eql(u8, value, "none")) null else try parse(u32, value); + } else if (std.mem.eql(u8, name, "depth")) { + thread.depth = try parse(u32, value); + } else if (std.mem.eql(u8, name, "wake")) { + thread.wake = try parse(u32, value); + } else if (std.mem.eql(u8, name, "interrupted")) { + thread.interrupted = try flag(value); + } else if (std.mem.eql(u8, name, "trigger")) { + thread.trigger = try parse(u32, value); + } + } else if (std.mem.cutPrefix(u8, key, "global.")) |index_text| { + const index = parse(u32, index_text) catch return error.Invalid; + if (index >= globals.items.len) try globals.appendNTimes(arena, 0, index + 1 - globals.items.len); + 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) }); + } + } + return .{ + .clock = try number(u32, payload, "clock"), + .hold = std.meta.stringToEnum(vm_editor.Hold, try text(payload, "hold")) orelse return error.Invalid, + .step = std.meta.stringToEnum(vm_editor.Step, try text(payload, "step")) orelse return error.Invalid, + .paused = try flag(try text(payload, "paused")), + .first_finished = try flag(try text(payload, "first_finished")), + .threads = threads.items, + .globals = globals.items, + .variables = variables.items, + }; + } + + /// 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 variable named `name`, where the state has it. + pub fn variable(state: State, name: []const u8) ?u32 { + for (state.variables) |each| if (std.mem.eql(u8, each.name, name)) return each.value; + return null; + } +}; + +/// The text of field `key` of one of OpenReliant's own messages, null where it has none. +fn field(payload: []const u8, key: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, payload, '\n'); + while (lines.next()) |line| { + const key_found, const value = std.mem.cutScalar(u8, line, '=') orelse continue; + if (std.mem.eql(u8, key_found, key)) return value; + } + return null; +} + +/// The text of field `key`, which a message must have. +fn text(payload: []const u8, key: []const u8) ReadError![]const u8 { + return field(payload, key) orelse error.Invalid; +} + +/// The number in field `key`, which a message must have. +fn number(comptime T: type, payload: []const u8, key: []const u8) ReadError!T { + return parse(T, try text(payload, key)); +} + +fn parse(comptime T: type, value: []const u8) ReadError!T { + return std.fmt.parseInt(T, value, 10) catch error.Invalid; +} + +/// A field that is 1 or 0. +fn flag(value: []const u8) ReadError!bool { + if (std.mem.eql(u8, value, "1")) return true; + if (std.mem.eql(u8, value, "0")) return false; + return error.Invalid; +} + /// What the link reads from and writes to: a connection to at most one editor at a time. pub const Transport = struct { context: *anyopaque, @@ -198,12 +389,8 @@ pub const Link = struct { /// Greets an editor that connected (`Own.hello`). fn hello(link: *Link) void { - link.sendFields(.hello, &.{ - .{ .key = "protocol", .value = std.fmt.comptimePrint("{d}", .{protocol_version}) }, - .{ .key = "openreliant", .value = link.version }, - .{ .key = "game", .value = link.game.name }, - .{ .key = "vm", .value = link.game.vm }, - }); + const greeting: Hello = .{ .protocol = protocol_version, .openreliant = link.version, .game = link.game }; + link.sendRecord(.hello, greeting); } /// Sends a message to the editor connected; nothing where none is. @@ -214,23 +401,19 @@ pub const Link = struct { if (payload.len > 0) link.transport.vtable.write(link.transport.context, session, payload); } - /// Sends one of OpenReliant's own messages, `fields` as its `key=value` lines. - pub fn sendFields(link: *Link, tag: Own, fields: []const Field) void { - var buffer: [fields_size]u8 = undefined; - var text: std.Io.Writer = .fixed(&buffer); - for (fields) |line| text.print("{s}={s}\n", .{ line.key, line.value }) catch { - log.warn("the link's message {d} is too long for its buffer, so it's cut short", .{@backingInt(tag)}); - break; + /// Sends one of OpenReliant's own messages, `record` written as its `key=value` lines + /// (`Hello`, `MissionStarted`, `State`); 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); + defer payload.deinit(); + record.write(&payload.writer) catch { + log.warn("the link has no memory left for its message 0x{X}", .{@backingInt(tag)}); + return; }; - link.send(@backingInt(tag), text.buffered()); + link.send(@backingInt(tag), payload.written()); } - /// A field of one of OpenReliant's own messages. - pub const Field = struct { key: []const u8, value: []const u8 }; - - /// The most text one of OpenReliant's own messages takes. - const fields_size = 1024; - fn dropTaken(link: *Link) void { link.received.replaceRangeAssumeCapacity(0, link.taken, &.{}); link.taken = 0; @@ -263,16 +446,6 @@ pub fn readMessage(reader: *std.Io.Reader, gpa: Allocator) std.Io.Reader.ReadAll return .{ .tag = header.tag, .payload = try reader.readAllocAll(gpa, header.size) }; } -/// The text field `key` of one of OpenReliant's own messages, null where it has none. -pub fn field(payload: []const u8, key: []const u8) ?[]const u8 { - var lines = std.mem.splitScalar(u8, payload, '\n'); - while (lines.next()) |line| { - const equals = std.mem.findScalar(u8, line, '=') orelse continue; - if (std.mem.eql(u8, line[0..equals], key)) return line[equals + 1 ..]; - } - return null; -} - pub const testing = struct { /// A transport in memory: what an editor sends waits in `to_game`, and what the game sends goes /// into `to_editor`. @@ -421,6 +594,61 @@ test "OpenReliant's own messages are key=value lines" { try std.testing.expectEqual(null, field(hello.payload, "mission")); } +test "the records of OpenReliant's own messages read back as written" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var payload: std.Io.Writer.Allocating = .init(arena); + + const hello: Hello = .{ .protocol = protocol_version, .openreliant = "1.2.3", .game = example }; + try hello.write(&payload.writer); + const hello_read: Hello = try .read(payload.written()); + try std.testing.expectEqual(protocol_version, hello_read.protocol); + try std.testing.expectEqualStrings("1.2.3", hello_read.openreliant); + try std.testing.expectEqualStrings("Example", hello_read.game.name); + try std.testing.expectEqualStrings("example", hello_read.game.vm); + + payload.clearRetainingCapacity(); + const started: MissionStarted = .{ .number = 90, .file = "mission90.dte" }; + try started.write(&payload.writer); + const started_read: MissionStarted = try .read(payload.written()); + try std.testing.expectEqual(90, started_read.number); + try std.testing.expectEqualStrings("mission90.dte", started_read.file); + + payload.clearRetainingCapacity(); + const state: State = .{ + .clock = 41, + .hold = .held, + .step = .over, + .paused = false, + .first_finished = true, + .threads = &.{ + .{ .slot = 0, .at = 10, .depth = 1, .wake = 0, .interrupted = false, .trigger = 0 }, + .{ .slot = 3, .at = null, .depth = 0, .wake = 45, .interrupted = true, .trigger = null }, + }, + .globals = &.{ 7, 0xFFFFFFFF }, + .variables = &.{ .{ .name = "mission_over", .value = 1 }, .{ .name = "200", .value = 5 } }, + }; + try state.write(&payload.writer); + const state_read: State = try .read(arena, payload.written()); + try std.testing.expectEqual(41, state_read.clock); + try std.testing.expectEqual(vm_editor.Hold.held, state_read.hold); + try std.testing.expectEqual(vm_editor.Step.over, state_read.step); + try std.testing.expect(!state_read.paused and state_read.first_finished); + try std.testing.expectEqual(2, state_read.threads.len); + try std.testing.expectEqual(state.threads[0], state_read.threads[0]); + try std.testing.expectEqual(state.threads[1], state_read.threads[1]); + try std.testing.expectEqualSlices(u32, state.globals, state_read.globals); + 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")); + + // A field missing, or not what it holds, makes the message unreadable. + try std.testing.expectError(error.Invalid, Hello.read("protocol=1\ngame=Example\n")); + try std.testing.expectError(error.Invalid, MissionStarted.read("number=ninety\nfile=x.dte\n")); + try std.testing.expectError(error.Invalid, State.read(arena, "clock=1\nhold=gripped\nstep=none\npaused=0\nfirst_finished=0\n")); +} + test "an editor writes and reads messages framed" { const gpa = std.testing.allocator; var bytes: [64]u8 = undefined; diff --git a/src/engine/vm/editor.zig b/src/engine/vm/editor.zig index 8bfac8b8..e5cf2725 100644 --- a/src/engine/vm/editor.zig +++ b/src/engine/vm/editor.zig @@ -46,9 +46,12 @@ pub const Connection = struct { }; }; -/// How long the script's start waits for the editor's messages before it runs the start parts -/// (`mission_script_start`, `0x0045CCA0`). -pub const start_wait_ms = 200; +/// The longest the script's start waits for the editor's flags before it runs the start parts, +/// while an editor is linked (`Editor.waitForFlags`). +pub const flags_wait_ms = 1000; + +/// How often the script's start checks the link while it waits for the editor's flags. +pub const flags_check_ms = 1; /// How long the script's start waits after each start part, and each time round while the editor /// holds it (`0x0045CCDB`). @@ -67,6 +70,9 @@ pub const Editor = struct { /// `editor_paused` (`0x00537581`): the editor's pause of the mission. `mission_frame` leaves /// out its work, and the timer runs none of its routines. paused: bool = false, + /// OpenReliant's: whether the editor has sent the script's flags since the script started, + /// which the start waits for (`waitForFlags`). A game's link sets it as it copies them. + flagged: bool = false, /// The game's link to the editor; null for none. connection: ?Connection = null, @@ -83,12 +89,13 @@ pub const Editor = struct { } /// `mission_script_start`'s part (`0x0045CC22`, `0x0045CC7B`, `0x0045CC81`): the mission's - /// pause lifted, and the hold and the step cleared. Whether the editor is there, and the - /// connection, are the link's, and stay. + /// pause lifted, and the hold and the step cleared, with the editor's flags still to come. + /// Whether the editor is there, and the connection, are the link's, and stay. pub fn reset(editor: *Editor) void { editor.paused = false; editor.hold = .none; editor.step = .none; + editor.flagged = false; } /// `editor_run_control` (`0x0045A900`) for its commands 1 to 3: the script released, to go on @@ -112,13 +119,29 @@ pub const Editor = struct { /// Waits `milliseconds` for the editor, while one is there. /// - /// **Improvement:** the original waits whether an editor is there or not, a fifth of a second - /// as each mission's script starts. + /// **Improvement:** the original waits whether an editor is there or not. pub fn sleep(editor: *Editor, milliseconds: u32) void { if (!editor.present) return; if (editor.connection) |connection| connection.vtable.sleep(connection.context, milliseconds); } + /// The script's start before the start parts run (`mission_script_start`, `0x0045CC95`): the + /// editor's messages are read, and while an editor is there and hasn't sent the script's flags, + /// read again every `flags_check_ms`, for `flags_wait_ms` at most. A breakpoint in a start part + /// then stops it, however long the editor takes to answer the mission's file. + /// + /// **Improvement:** the original reads the messages, waits a fifth of a second whether an + /// editor is there or not (`0x0045CCA0`), and reads them again. An editor slower than that + /// misses the start parts. + pub fn waitForFlags(editor: *Editor) void { + editor.check(); + var waited: u32 = 0; + while (editor.present and !editor.flagged and waited < flags_wait_ms) : (waited += flags_check_ms) { + editor.sleep(flags_check_ms); + editor.check(); + } + } + /// OpenReliant's: as the editor goes, the script is let go, with no step. /// /// **Improvement:** the original keeps a script the editor held as it was, so the mission's @@ -132,16 +155,22 @@ pub const Editor = struct { }; pub const testing = struct { - /// An editor in memory, for the tests: the offsets it heard of the stops, and the releases it - /// gives as the script's start checks its messages. + /// An editor in memory, for the tests: the offsets it heard of the stops, and the flags and + /// the releases it gives as the script's start checks its messages. pub const Listener = struct { offsets: [8]u32 = undefined, count: usize = 0, - /// The steps the editor releases the script with, one for each check, in order. + /// The steps the editor releases the script with, one for each check that finds the script + /// held, in order. releases: []const Step = &.{}, + /// How many releases it gave. checks: usize = 0, + /// The check that brings the editor's flags, counting from 0; null for none. + flags_at: ?usize = 0, + /// How many times its messages were checked. + checked: usize = 0, slept: u32 = 0, - /// The editor that `check` releases. + /// The editor that `check` flags and releases. editor: ?*Editor = null, pub fn connection(listener: *Listener) Connection { @@ -160,7 +189,9 @@ pub const testing = struct { fn check(context: *anyopaque) void { const listener: *Listener = @ptrCast(@alignCast(context)); + defer listener.checked += 1; const editor = listener.editor orelse return; + if (listener.flags_at == listener.checked) editor.flagged = true; if (!editor.holds() or listener.checks >= listener.releases.len) return; editor.release(listener.releases[listener.checks]); listener.checks += 1; @@ -200,3 +231,31 @@ test Editor { editor.reset(); try std.testing.expect(!editor.leavesFrame() and editor.step == .none); } + +test "the script's start waits for the editor's flags" { + const Case = struct { + present: bool, + flags_at: ?usize, + slept: u32, + }; + for ([_]Case{ + // Flags waiting at the first check need no wait, and those at the third take two. + .{ .present = true, .flags_at = 0, .slept = 0 }, + .{ .present = true, .flags_at = 2, .slept = 2 * flags_check_ms }, + // An editor that sends none holds the start up for the longest wait. + .{ .present = true, .flags_at = null, .slept = flags_wait_ms }, + // With no editor, the start doesn't wait. + .{ .present = false, .flags_at = null, .slept = 0 }, + }) |case| { + var listener: testing.Listener = .{ .flags_at = case.flags_at }; + var editor: Editor = .{ .present = case.present, .connection = listener.connection() }; + listener.editor = &editor; + editor.waitForFlags(); + try std.testing.expectEqual(case.slept, listener.slept); + try std.testing.expectEqual(case.flags_at != null, editor.flagged); + } + // Each start waits for flags of its own. + var editor: Editor = .{ .flagged = true }; + editor.reset(); + try std.testing.expect(!editor.flagged); +} diff --git a/src/engine/vm/machine.zig b/src/engine/vm/machine.zig index 75e8ae21..7d036aa2 100644 --- a/src/engine/vm/machine.zig +++ b/src/engine/vm/machine.zig @@ -415,12 +415,13 @@ pub const Machine = struct { /// `mission_script_start` (`0x0045CBC0`), as the mission's binding ends: every object's kept /// events emptied, the clock, the timers, the threads and the tags reset, the handlers' verdict /// and the last jump's time too, the editor's pause, hold and step cleared, and the timers set - /// running. Then the editor's messages are read, and read again a fifth of a second later - /// (`0x0045CC95`), before each start part runs (`startPart`). Then every object's triggers are - /// armed, and each ship's Destroyed flag is cleared and its components all intact. Where a - /// curve starts at the ship, the first such curve's ships are made (`0x0045CD71`): the ship it - /// starts at, those its tangents are drawn to, and the ship it ends at, each where its slot - /// holds no object made yet, which the start part may have left (`executor.createShip`). + /// running. Then, while an editor is linked, the start waits for its flags + /// (`vm.editor.Editor.waitForFlags`), before each start part runs (`startPart`). Then every + /// object's triggers are armed, and each ship's Destroyed flag is cleared and its components + /// all intact. Where a curve starts at the ship, the first such curve's ships are made + /// (`0x0045CD71`): the ship it starts at, those its tangents are drawn to, and the ship it ends + /// at, each where its slot holds no object made yet, which the start part may have left + /// (`executor.createShip`). /// /// **Fix:** the game takes the object past its array for a curve that names no ship; /// OpenReliant passes over it. @@ -442,9 +443,7 @@ pub const Machine = struct { machine.timer_count = 0; machine.ticked = false; machine.timers_running = true; - machine.editor.check(); - machine.editor.sleep(vm.editor.start_wait_ms); - machine.editor.check(); + machine.editor.waitForFlags(); for (try file.parts()) |part| { if (part.flags.start and !part.isEmpty()) machine.startPart(part); } @@ -2683,8 +2682,9 @@ test "the script's start waits on the editor, and runs a start part it held on f try std.testing.expectEqual(2, fixture.global(1)); try std.testing.expectEqual(0, fixture.machine.thread_count); try std.testing.expectEqual(vm.editor.Hold.none, fixture.machine.editor.hold); - // A fifth of a second before the start parts, then a millisecond each time round. - try std.testing.expectEqual(vm.editor.start_wait_ms + 3 * vm.editor.part_wait_ms, listener.slept); + // The editor's flags were there at the first check, and the start waited a millisecond each + // time round. + try std.testing.expectEqual(3 * vm.editor.part_wait_ms, listener.slept); } test "a mission without section 24 takes the flags the mission editor writes" { diff --git a/src/formats/dte/source/compile.zig b/src/formats/dte/source/compile.zig index 67bdbecc..757797e6 100644 --- a/src/formats/dte/source/compile.zig +++ b/src/formats/dte/source/compile.zig @@ -45,13 +45,43 @@ pub const Error = Allocator.Error || error{ /// Compiles `tree` into a mission file, allocated with `arena`. Where the source has an error, the /// parser's or the compiler's, `failure` says what and where, and the error is `Invalid`. pub fn compile(arena: Allocator, tree: syntax.Tree, failure: *Failure) Error![]u8 { + return (try compileMapped(arena, tree, failure)).file; +} + +/// A mission file compiled, with the map of its script's statements to the source's lines. +pub const Compiled = struct { + file: []u8, + /// Each statement of the script, by the offset of the instruction it ends with, in order. + statements: []const Statement, + + /// A statement of the script: where its code ends in the script, and the line of the source + /// it comes from. A debugger holds before the instruction, once its operands are pushed. + pub const Statement = struct { + /// The instruction's offset in the script, in bytes, as the editor link gives a stop. + offset: u32, + at: syntax.Position, + /// Whether the VM stops before the instruction, as a breakpoint or a step asks: it doesn't + /// before a `return` or a random choice's roll. + stops: bool, + }; +}; + +/// `compile`, with the map of the script's statements to the source's lines, for a debugger: where +/// a breakpoint on a line goes, and which line a stop in the script is on. +pub fn compileMapped(arena: Allocator, tree: syntax.Tree, failure: *Failure) Error!Compiled { if (tree.errors.len > 0) { failure.* = .{ .at = tree.errors[0].at, .message = tree.errors[0].message }; return error.Invalid; } - var compiler: Compiler = .{ .arena = arena, .tree = tree, .failure = failure }; + var compiler: Compiler = .{ .arena = arena, .tree = tree, .failure = failure, .marks = .{ .gpa = arena } }; try compiler.declare(); - return compiler.build(); + const file = try compiler.build(); + std.mem.sort(Compiled.Statement, compiler.mapped.items, {}, struct { + fn before(_: void, a: Compiled.Statement, b: Compiled.Statement) bool { + return a.offset < b.offset; + } + }.before); + return .{ .file = file, .statements = compiler.mapped.items }; } /// The kinds of record a name can declare. @@ -107,10 +137,21 @@ pub const Compiler = struct { object_count: u32 = 0, /// The object ID of each ship, flight group and squad, by its index (`numberObjects`). object_ids: std.EnumArray(ObjectKind, []u32) = .initFill(&.{}), + /// The node each statement of the code comes from (`code.function`). + statement_nodes: std.AutoHashMapUnmanaged(*const statements.Statement, Index) = .empty, + /// Where the statements of the routine being written went (`routine`). + marks: statements.Marks, + /// The map of the script's statements to the source's lines (`compileMapped`). + mapped: std.ArrayList(Compiled.Statement) = .empty, /// The kinds of record the object table numbers. const ObjectKind = enum { ship, flight_group, squad }; + /// Notes the node a statement of the code comes from, for the map of the lines. + pub fn noteStatement(c: *Compiler, statement: *const statements.Statement, node: Index) Allocator.Error!void { + try c.statement_nodes.put(c.arena, statement, node); + } + /// Records why the compilation fails, at `node`, and gives `Invalid`. pub fn fail(c: *Compiler, node: Index, comptime message: []const u8, args: anytype) Error { const text = c.arena.print(message, args) catch return error.OutOfMemory; @@ -849,7 +890,10 @@ pub const Compiler = struct { /// halfwords. fn routine(c: *Compiler, bytes: *std.ArrayList(u8), node: Index, parameters: code.Parameters, context: statements.Context) Error!u16 { const body = try code.function(c, node, parameters); - const made = statements.bytes(c.arena, body, context) catch |err| return switch (err) { + var marked = context; + marked.marks = &c.marks; + c.marks.list.clearRetainingCapacity(); + const made = statements.bytes(c.arena, body, marked) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, error.BranchTooFar => c.fail(node, "an `if` or a random choice here holds more code than a branch reaches", .{}), error.InlineTooLong => c.fail(node, "a text here is longer than {d} bytes", .{assemble.Routine.longest_text}), @@ -861,6 +905,12 @@ pub const Compiler = struct { const start = bytes.items.len / 2; if (start + made.len / 2 >= dte.Part.no_block) return c.fail(node, "the script is longer than a mission's {d} halfwords", .{dte.Part.no_block - 1}); try bytes.appendSlice(c.arena, made); + // The marks count from the routine's code, past the block's length word. + const code_start: u32 = @intCast(start * 2 + dte.BlockReader.header_len); + for (c.marks.list.items) |made_mark| { + const from = c.statement_nodes.get(made_mark.statement) orelse unreachable; + try c.mapped.append(c.arena, .{ .offset = code_start + made_mark.at, .at = c.tree.at(from), .stops = made_mark.stops }); + } return @intCast(start); } diff --git a/src/formats/dte/source/compile/code.zig b/src/formats/dte/source/compile/code.zig index f01f361c..5894b971 100644 --- a/src/formats/dte/source/compile/code.zig +++ b/src/formats/dte/source/compile/code.zig @@ -55,9 +55,12 @@ const Reader = struct { fn body(r: Reader, block: Index) Error![]const Statement { const lines = r.get(block).block; var list: std.ArrayList(Statement) = .empty; + // The node each statement comes from, for the compiler's map of the lines. + var nodes: std.ArrayList(Index) = .empty; var at: usize = 0; while (at < lines.len) : (at += 1) { const line = lines[at]; + try nodes.append(r.c.arena, line); if (r.get(line) == .local) { // A random choice: its roll, then the `if` over its arms. const next = if (at + 1 < lines.len) lines[at + 1] else return r.fail(line, "a random choice's roll is followed by the `if` over its arms", .{}); @@ -67,6 +70,7 @@ const Reader = struct { } try list.append(r.c.arena, try r.statement(line)); } + for (list.items, nodes.items) |*made, node| try r.c.noteStatement(made, node); return list.items; } @@ -100,7 +104,11 @@ const Reader = struct { fn ifStatement(r: Reader, branch: syntax.Syntax.If) Error!statements.If { const otherwise: ?[]const Statement = if (branch.@"else") |node| switch (r.get(node)) { // `elseif`: an `else` that holds one `if`. - .@"if" => try r.c.arena.dupe(Statement, &.{try r.statement(node)}), + .@"if" => elseif: { + const lone = try r.c.arena.dupe(Statement, &.{try r.statement(node)}); + try r.c.noteStatement(&lone[0], node); + break :elseif lone; + }, else => try r.body(node), } else null; return .{ .condition = try r.expression(branch.condition), .then = try r.body(branch.then), .otherwise = otherwise }; diff --git a/src/formats/dte/statements.zig b/src/formats/dte/statements.zig index 55ce0868..d569c80c 100644 --- a/src/formats/dte/statements.zig +++ b/src/formats/dte/statements.zig @@ -227,6 +227,8 @@ pub const Context = struct { /// The values the event carries, for the block a trigger runs: how a number compared with one /// of them is pushed depends on its kinds. event: []const conditions.Value = &.{}, + /// Where each statement's code goes, for a debugger, where the caller asks for it. + marks: ?*Marks = null, /// The context of `routine` of `mission`: a trigger's block takes the values of its first /// trigger's condition. @@ -286,9 +288,42 @@ pub const arm_room = 10; /// count. pub const Error = assemble.Error; +/// Where each statement lowered went, for a debugger: the instruction the statement ends with, after +/// its operands, which a step or a breakpoint of the editor link stops before. An `if` ends with its +/// branch, before the statements it holds; a random choice with its `random_branch`, before its +/// arms. +pub const Marks = struct { + gpa: Allocator, + list: std.ArrayList(Mark) = .empty, + + pub const Mark = struct { + statement: *const Statement, + /// The instruction's offset in the routine's code, past the block's length word. + at: u32, + /// Whether the VM stops before it (`opcodes.Info.starts_statement`): it doesn't before + /// `return` or `random_branch`, so a breakpoint can't hold there. + stops: bool, + }; + + pub fn deinit(marks: *Marks) void { + marks.list.deinit(marks.gpa); + } + + /// Notes that `statement` ends with `opcode`, the next instruction `routine` writes. + fn add(marks: *Marks, statement: *const Statement, routine: *const assemble.Routine, opcode: Opcode) Allocator.Error!void { + const info = opcode.info() orelse unreachable; + try marks.list.append(marks.gpa, .{ .statement = statement, .at = @intCast(routine.code.items.len), .stops = info.starts_statement }); + } +}; + /// Writes `body` into `routine` as the developers' compiler did. pub fn lower(routine: *assemble.Routine, body: []const Statement, context: Context) Error!void { - for (body) |statement| try lowerStatement(routine, statement, context); + for (body) |*statement| try lowerStatement(routine, statement, context); +} + +/// Notes in the context's marks, where it has them, that `statement` ends with `opcode`. +fn mark(routine: *const assemble.Routine, statement: *const Statement, context: Context, opcode: Opcode) Allocator.Error!void { + if (context.marks) |marks| try marks.add(statement, routine, opcode); } /// The bytes of a routine of `body`: its block and its constant table (`assemble.Routine.finish`). @@ -299,22 +334,26 @@ pub fn bytes(gpa: Allocator, body: []const Statement, context: Context) Error![] return routine.finish(); } -fn lowerStatement(routine: *assemble.Routine, statement: Statement, context: Context) Error!void { - switch (statement) { +fn lowerStatement(routine: *assemble.Routine, statement: *const Statement, context: Context) Error!void { + switch (statement.*) { .command => |command| { const params = if (commands.find(command.index)) |entry| entry.params else &.{}; for (command.arguments, 0..) |argument, index| { try push(routine, argument, .{ .kinds = if (index < params.len) params[index].kinds else .{} }, context); } + try mark(routine, statement, context, .command); try routine.op(.command, &.{command.index}); }, .call, .spawn => |call| { for (call.arguments) |argument| try push(routine, argument, .{}, context); - try routine.op(if (statement == .call) .call_part else .spawn_part, &.{call.part}); + const opcode: Opcode = if (statement.* == .call) .call_part else .spawn_part; + try mark(routine, statement, context, opcode); + try routine.op(opcode, &.{call.part}); }, .assign => |assign| { try routine.op(assign.target.select(), &.{assign.target.index()}); try push(routine, assign.value, .{}, context); + try mark(routine, statement, context, assign.update.opcode()); try routine.op(assign.update.opcode(), &.{}); }, .@"if" => |branch| { @@ -322,6 +361,7 @@ fn lowerStatement(routine: *assemble.Routine, statement: Statement, context: Con const past_then = try routine.label(); if (branch.otherwise) |otherwise| { const end = try routine.label(); + try mark(routine, statement, context, .branch_if_zero_alt); try routine.branch(.branch_if_zero_alt, past_then); try lower(routine, branch.then, context); try routine.branch(.jump, end); @@ -329,14 +369,19 @@ fn lowerStatement(routine: *assemble.Routine, statement: Statement, context: Con try lower(routine, otherwise, context); routine.place(end); } else { + try mark(routine, statement, context, .branch_if_zero); try routine.branch(.branch_if_zero, past_then); try lower(routine, branch.then, context); routine.place(past_then); } }, - .random => |random| try lowerRandom(routine, random, context), + .random => |random| { + try mark(routine, statement, context, .random_branch); + try lowerRandom(routine, random, context); + }, .@"return" => |value| { try push(routine, value, .{ .returned = true }, context); + try mark(routine, statement, context, .@"return"); try routine.op(.@"return", &.{}); }, } diff --git a/src/openreliant/debug.zig b/src/openreliant/debug.zig index 6137cdc2..0d2ad849 100644 --- a/src/openreliant/debug.zig +++ b/src/openreliant/debug.zig @@ -299,18 +299,18 @@ const Debugger = struct { } fn hello(debugger: *Debugger, payload: []const u8) void { - debugger.say("Connected to {s}, OpenReliant {s}.\n", .{ link.field(payload, "game") orelse "a game", link.field(payload, "openreliant") orelse "?" }); - const vm = link.field(payload, "vm") orelse ""; - if (!std.mem.eql(u8, vm, editor.game.vm)) { - debugger.say("Its scripts are in the {s} dialect, and this debugger reads only {s}: breakpoints by offset still work.\n", .{ vm, editor.game.vm }); + const greeting = link.Hello.read(payload) catch return debugger.say("The game's greeting can't be read.\n", .{}); + debugger.say("Connected to {s}, OpenReliant {s}.\n", .{ greeting.game.name, greeting.openreliant }); + if (!std.mem.eql(u8, greeting.game.vm, editor.game.vm)) { + debugger.say("Its scripts are in the {s} dialect, and this debugger reads only {s}: breakpoints by offset still work.\n", .{ greeting.game.vm, editor.game.vm }); } } fn missionStarted(debugger: *Debugger, payload: []const u8) void { debugger.forgetMission(); - const number = std.fmt.parseInt(u16, link.field(payload, "number") orelse "", 10) catch 0; - debugger.mission = number; - debugger.say("Mission {d} starts ({s}).\n", .{ number, link.field(payload, "file") orelse "?" }); + const started = link.MissionStarted.read(payload) catch return debugger.say("The mission's start can't be read.\n", .{}); + debugger.mission = started.number; + debugger.say("Mission {d} starts ({s}).\n", .{ started.number, started.file }); } /// The mission's file: its script read, and the breakpoints' flags sent. @@ -464,9 +464,9 @@ const Debugger = struct { debugger.say("\n", .{}); } - /// Says where a thread is, from the offset in the script `text` gives, where it gives one. - fn sayAt(debugger: *Debugger, text: []const u8) void { - const offset = std.fmt.parseInt(u32, text, 10) catch return debugger.say("{s}", .{text}); + /// Says where a thread is in the script: its place, or none outside the script. + fn sayAt(debugger: *Debugger, at: ?u32) void { + const offset = at orelse return debugger.say("none", .{}); const found = if (debugger.script) |*have| have else return debugger.say("{d}", .{offset}); debugger.sayLocation(found, offset); } @@ -551,61 +551,45 @@ const Debugger = struct { /// Shows the script's state as the game sent it (`link.Own.state`). fn sayState(debugger: *Debugger, payload: []const u8) void { - const hold = link.field(payload, "hold") orelse "none"; - debugger.say("Clock {s} s. The script is {s}", .{ link.field(payload, "clock") orelse "?", if (std.mem.eql(u8, hold, "held")) "held" else "running" }); - const step = link.field(payload, "step") orelse "none"; - if (!std.mem.eql(u8, step, "none")) debugger.say(", stepping ({s})", .{step}); - if (std.mem.eql(u8, link.field(payload, "paused") orelse "0", "1")) debugger.say(", and the mission is paused", .{}); + var arena: std.heap.ArenaAllocator = .init(debugger.gpa); + defer arena.deinit(); + const state = link.State.read(arena.allocator(), payload) catch return debugger.say("The script's state can't be read.\n", .{}); + debugger.say("Clock {d} s. The script is {s}", .{ state.clock, if (state.hold == .held) "held" else "running" }); + if (state.step != .none) debugger.say(", stepping ({t})", .{state.step}); + if (state.paused) debugger.say(", and the mission is paused", .{}); debugger.say(".\nThreads:\n", .{}); - for (0..engine.vm.max_threads) |thread| { - var key: [32]u8 = undefined; - const at = link.field(payload, keyOf(&key, "thread.{d}.at", thread)) orelse continue; - debugger.say("{d:>4} at ", .{thread}); - debugger.sayAt(at); - if (link.field(payload, keyOf(&key, "thread.{d}.wake", thread))) |wake| if (!std.mem.eql(u8, wake, "0")) debugger.say(", waits for second {s}", .{wake}); - if (std.mem.eql(u8, link.field(payload, keyOf(&key, "thread.{d}.interrupted", thread)) orelse "0", "1")) debugger.say(", waits for its trigger", .{}); + for (state.threads) |thread| { + debugger.say("{d:>4} at ", .{thread.slot}); + debugger.sayAt(thread.at); + if (thread.wake != 0) debugger.say(", waits for second {d}", .{thread.wake}); + if (thread.interrupted) debugger.say(", waits for its trigger", .{}); debugger.say("\n", .{}); } debugger.say("Globals:\n", .{}); const found: ?*Script = if (debugger.script) |*have| have else null; const globals = if (found) |have| have.mission.globals() catch &.{} else &.{}; - var lines = std.mem.splitScalar(u8, payload, '\n'); - while (lines.next()) |line| { - const equals = std.mem.findScalar(u8, line, '=') orelse continue; - const key = line[0..equals]; - const value = line[equals + 1 ..]; - if (std.mem.startsWith(u8, key, "global.")) { - const index = std.fmt.parseInt(usize, key["global.".len..], 10) catch continue; - const name = if (found) |have| (if (index < globals.len) have.mission.name(globals[index].name) else "") else ""; - debugger.say("{d:>4} {s}{s}= ", .{ index, name, if (name.len == 0) "" else " " }); - debugger.sayWord(value); - debugger.say("\n", .{}); - } + for (state.globals, 0..) |value, index| { + const name = if (found) |have| (if (index < globals.len) have.mission.name(globals[index].name) else "") else ""; + debugger.say("{d:>4} {s}{s}= ", .{ index, name, if (name.len == 0) "" else " " }); + debugger.sayWord(value); + debugger.say("\n", .{}); } debugger.say("The game's variables that aren't 0:\n", .{}); - lines.reset(); - while (lines.next()) |line| { - const equals = std.mem.findScalar(u8, line, '=') orelse continue; - if (!std.mem.startsWith(u8, line, "variable.") or std.mem.eql(u8, line[equals + 1 ..], "0")) continue; - debugger.say(" {s} = ", .{line["variable.".len..equals]}); - debugger.sayWord(line[equals + 1 ..]); + for (state.variables) |variable| { + if (variable.value == 0) continue; + debugger.say(" {s} = ", .{variable.name}); + debugger.sayWord(variable.value); debugger.say("\n", .{}); } } /// Says one of the script's words as the state gives it, unsigned, as a signed number: the /// scripts count down past 0, as the countdown does. - fn sayWord(debugger: *Debugger, text: []const u8) void { - const word = std.fmt.parseInt(u32, text, 10) catch return debugger.say("{s}", .{text}); + fn sayWord(debugger: *Debugger, word: u32) void { debugger.say("{d}", .{@as(i32, @bitCast(word))}); } }; -/// The key `format` makes with `number`, written in `buffer`; empty where it doesn't fit. -fn keyOf(buffer: []u8, comptime format: []const u8, number: usize) []const u8 { - return std.mem.print(buffer, format, .{number}) catch ""; -} - fn unplacedText(err: (script.Unplaced || Allocator.Error)) []const u8 { return switch (err) { error.NoSuchPart => "no part has that number or name", @@ -665,7 +649,7 @@ test "the debugger's commands and the game's messages" { // The state names the thread's place, the globals and the variables that aren't 0. out.clearRetainingCapacity(); var state: [256]u8 = undefined; - const text = try std.mem.print(&state, "clock=3\nhold=held\nstep=into\npaused=0\nthread.0.at={d}\nthread.0.wake=0\nglobal.1=7\nvariable.mission_over=0\nvariable.backup_available=1\n", .{second}); + const text = try std.mem.print(&state, "clock=3\nhold=held\nstep=into\npaused=0\nfirst_finished=0\nthread.0.at={d}\nthread.0.wake=0\nglobal.1=7\nvariable.mission_over=0\nvariable.backup_available=1\n", .{second}); debugger.handle(.{ .tag = @backingInt(link.Own.state), .payload = text }); try std.testing.expect(std.mem.find(u8, out.written(), "Clock 3 s. The script is held, stepping (into).") != null); try std.testing.expect(std.mem.find(u8, out.written(), " 0 at ") != null); diff --git a/src/source_parser.zig b/src/source_parser.zig index f41930c7..3edb8a58 100644 --- a/src/source_parser.zig +++ b/src/source_parser.zig @@ -56,6 +56,12 @@ pub fn build(arena: Allocator, text: []const u8, failure: *compile.Failure) comp return compile.compile(arena, try parse(arena, text), failure); } +/// `build`, with the map of the script's statements to the source's lines, for a debugger +/// (`compile.compileMapped`). +pub fn buildMapped(arena: Allocator, text: []const u8, failure: *compile.Failure) compile.Error!compile.Compiled { + return compile.compileMapped(arena, try parse(arena, text), failure); +} + /// Compiles the mission source file `source` into the mission file `mission`, both in `dir`, as /// `sltool dte build` and the build's `build-mission` do. Where the source has an error, `failure` /// says what and where, the error is `Invalid`, and no file is written. @@ -542,6 +548,57 @@ test "the guide's missions build" { } } +test buildMapped { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const first = (try guideMissions(arena))[0]; + var failure: compile.Failure = undefined; + const compiled = try buildMapped(arena, first.source, &failure); + const script = try (try dte.Mission.parse(compiled.file)).script(); + try std.testing.expect(compiled.statements.len > 0); + // The statements in the script's order, each on an instruction of its kind: a stop before a + // statement, or a `return` or a random choice, which the VM doesn't stop before. + var last: ?u32 = null; + for (compiled.statements) |statement| { + if (last) |before| try std.testing.expect(statement.offset > before); + last = statement.offset; + const opcode: dte.Opcode = @fromBackingInt(script[statement.offset]); + const info = opcode.info().?; + try std.testing.expectEqual(info.starts_statement, statement.stops); + try std.testing.expect(info.starts_statement or opcode == .@"return" or opcode == .random_branch); + } + // A line's statement is found by its line: the command the Sabres attack with stops before + // `command`, the assignment that wins before its store, and the `return` after it doesn't stop. + const lines = try lineNumbers(arena, first.source, &.{ " SetAI(enemies, \"fight\", 1, player)", " game.mission_success = 3", " return 1" }); + const attack = statementOn(compiled, lines[0]).?; + try std.testing.expect(attack.stops); + try std.testing.expectEqual(dte.Opcode.command, @as(dte.Opcode, @fromBackingInt(script[attack.offset]))); + const success = statementOn(compiled, lines[1]).?; + try std.testing.expect(success.stops); + try std.testing.expectEqual(dte.Opcode.assign, @as(dte.Opcode, @fromBackingInt(script[success.offset]))); + try std.testing.expect(!statementOn(compiled, lines[2]).?.stops); +} + +/// The number, counting from 0, of the first line of `text` that is each of `wanted`, in order. +fn lineNumbers(arena: Allocator, text: []const u8, wanted: []const []const u8) ![]u32 { + const found = try arena.alloc(u32, wanted.len); + for (wanted, found) |line_text, *number| { + var lines = std.mem.splitScalar(u8, text, '\n'); + var index: u32 = 0; + number.* = while (lines.next()) |line| : (index += 1) { + if (std.mem.eql(u8, line, line_text)) break index; + } else return error.TestUnexpectedResult; + } + return found; +} + +/// The first statement of `compiled` on `line`. +fn statementOn(compiled: compile.Compiled, line: u32) ?compile.Compiled.Statement { + for (compiled.statements) |statement| if (statement.at.line == line) return statement; + return null; +} + test "the guide's mistake is reported as the guide shows it" { var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena_state.deinit(); From 218132a4d5acd52fba4c70a0acaccafc4c0fc365 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Wed, 14 Oct 2026 17:36:52 +0200 Subject: [PATCH 2/3] refactor: keep the editor link's default port in the library An editor built on the library connects to the port the game listens at, so the number belongs with the link's other definitions rather than in the game's platform module. --- docs/engine/editor-link.md | 3 ++- src/engine/link.zig | 4 ++++ src/openreliant/debug.zig | 7 +++---- src/openreliant/options.zig | 4 ++-- src/platform/link.zig | 3 --- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index c3edea18..1a05ce5b 100644 --- a/docs/engine/editor-link.md +++ b/docs/engine/editor-link.md @@ -361,7 +361,8 @@ A client in Zig can read and write missions exactly as OpenReliant does by impor ([Using the library in another project](../port/platform.md#using-the-library-in-another-project)). It works the way `openreliant debug` does: -1. Connect to 127.0.0.1 at the port, and read `Hello`. +1. Connect to 127.0.0.1 at the port, `link.default_port` unless the game was started with + another, and read `Hello`. 2. On `Mission file`, which comes right after `Mission started`, send tag `0x09` with the script's flags: a byte for each of the script's bytes, rounded up to a multiple of 4, with bit 0 set on each byte to stop before. Send them with no breakpoints too, since the start parts wait for them diff --git a/src/engine/link.zig b/src/engine/link.zig index 709d3a34..b1ada6d6 100644 --- a/src/engine/link.zig +++ b/src/engine/link.zig @@ -43,6 +43,10 @@ pub const Header = extern struct { pub const header_size = 8; +/// The port the game listens at for an editor, on its own address, 127.0.0.1, unless +/// `--editor-link-port` gives another. +pub const default_port = 22539; + /// The longest payload taken: the whole of the original's block (`editor_link_create`, /// `0x00457B90`), which no message of the original fills. A longer one drops the connection. pub const max_payload = 0xD4A08; diff --git a/src/openreliant/debug.zig b/src/openreliant/debug.zig index 0d2ad849..d474cd41 100644 --- a/src/openreliant/debug.zig +++ b/src/openreliant/debug.zig @@ -9,7 +9,6 @@ const Io = std.Io; const Allocator = std.mem.Allocator; const openreliant = @import("openreliant"); -const platform = @import("platform"); const engine = openreliant.engine; const dte = openreliant.dte; const link = engine.link; @@ -29,7 +28,7 @@ pub const usage = std.fmt.comptimePrint( \\breakpoints, steps through its statements, and shows where it stopped and what it holds. It \\waits for the game, and for it again once it goes. Type help for the commands. \\ -, .{platform.link.default_port}); +, .{link.default_port}); /// The commands, as `help` lists them. const commands_help = @@ -95,7 +94,7 @@ pub fn main(io: Io, gpa: Allocator, args: []const [:0]const u8) !u8 { /// The port `--port` gives, or the default; null for an argument it doesn't take. fn portOf(args: []const [:0]const u8) ?u16 { - var port: u16 = platform.link.default_port; + var port: u16 = link.default_port; var at: usize = 0; while (at < args.len) : (at += 1) { if (!std.mem.eql(u8, args[at], "--port") or at + 1 == args.len) return null; @@ -601,7 +600,7 @@ fn unplacedText(err: (script.Unplaced || Allocator.Error)) []const u8 { } test portOf { - try std.testing.expectEqual(platform.link.default_port, portOf(&.{}).?); + try std.testing.expectEqual(link.default_port, portOf(&.{}).?); try std.testing.expectEqual(4000, portOf(&.{ "--port", "4000" }).?); try std.testing.expectEqual(null, portOf(&.{"--port"})); try std.testing.expectEqual(null, portOf(&.{ "--port", "x" })); diff --git a/src/openreliant/options.zig b/src/openreliant/options.zig index 913a66be..e78f42eb 100644 --- a/src/openreliant/options.zig +++ b/src/openreliant/options.zig @@ -146,7 +146,7 @@ const docs: std.enums.EnumArray(Arg, Doc) = .init(.{ .@"--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" }, - .@"--editor-link-port" = .{ .section = .other, .value = "", .text = std.fmt.comptimePrint("with --editor-link, the port to listen at; {d} by default", .{platform.link.default_port}) }, + .@"--editor-link-port" = .{ .section = .other, .value = "", .text = std.fmt.comptimePrint("with --editor-link, the port to listen at; {d} by default", .{engine.link.default_port}) }, .@"--no-update-check" = .{ .section = .other, .text = "don't check for a newer release of OpenReliant. By default it checks as it starts, logs a newer release and shows it once in the main menu" }, .@"--screenshot" = .{ .section = .other, .value = "", .text = "draw one frame, with the camera settled, to a PNG, and quit; the controls, the [OpenReliant] settings and the details in [Device] are not read, so that it comes out the same each time; the mods the mods screen turned off stay off" }, .@"--screenshot-ticks" = .{ .section = .other, .value = "", .text = "with --screenshot, how many game ticks to run first, one a frame, so that the scene plays out; 2 by default" }, @@ -267,7 +267,7 @@ pub const Options = struct { developer_mode: bool = false, /// Whether an editor can link to the game (`engine.link`), and the port it connects to. editor_link: bool = false, - editor_link_port: u16 = platform.link.default_port, + editor_link_port: u16 = engine.link.default_port, /// Whether OpenReliant checks for a newer release of itself as it starts (`updates.zig`). update_check: bool = true, /// Whether the original's look and sound were taken (`--original`), which the options after it diff --git a/src/platform/link.zig b/src/platform/link.zig index f45895cf..4b7ceb3d 100644 --- a/src/platform/link.zig +++ b/src/platform/link.zig @@ -16,9 +16,6 @@ const link = @import("openreliant").engine.link; const log = std.log.scoped(.link); -/// The port an editor connects to, unless the options give another. -pub const default_port = 22539; - /// The most bytes the transport keeps for the game to take. Past it, the editor has sent more than /// a few of the longest messages the link takes while the game took none, and the transport drops /// the connection. From 70b3beab1484b3eb511dd64aaf959d3a8b5f5c15 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Wed, 14 Oct 2026 18:41:02 +0200 Subject: [PATCH 3/3] refactor: review the link's records, the start's wait and the line map - The state reads in one pass over its lines, passes over keys it doesn't know, and refuses a global past the 16 bits a mission counts them in, which could otherwise allocate without bound. - The start's wait reads a clock through the editor connection, so a second is a second however long each sleep takes. - The compiler's marks note where a statement's last instruction goes, and the map reads that instruction back from the written routine, so the two can't disagree. The statements come in the script's order, so they need no sort. - The test listener counts its releases as `released`, apart from the checks it counts as `checked`. --- docs/engine/editor-link.md | 3 +- src/engine/game/mission/editor.zig | 16 +++-- src/engine/link.zig | 110 +++++++++++++++++++++-------- src/engine/vm/editor.zig | 32 ++++++--- src/engine/vm/machine.zig | 2 +- src/formats/dte/source/compile.zig | 20 +++--- src/formats/dte/statements.zig | 42 +++++------ 7 files changed, 144 insertions(+), 81 deletions(-) diff --git a/docs/engine/editor-link.md b/docs/engine/editor-link.md index 1a05ce5b..bbd9e4d1 100644 --- a/docs/engine/editor-link.md +++ b/docs/engine/editor-link.md @@ -273,7 +273,8 @@ for each field, but the mission's file: | `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. +Values are the script's words, unsigned. A reader passes over keys it doesn't know, so that a newer +game's messages still read. ### What it does diff --git a/src/engine/game/mission/editor.zig b/src/engine/game/mission/editor.zig index 0df54e11..7a1660ef 100644 --- a/src/engine/game/mission/editor.zig +++ b/src/engine/game/mission/editor.zig @@ -175,7 +175,7 @@ pub const Session = struct { @memcpy(session.file_buffer[0..session.file_length], started.file[0..session.file_length]); session.finished_told = null; loaded.script.editor.present = session.link.present(); - loaded.script.editor.connection = .{ .context = session, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep } }; + loaded.script.editor.connection = .{ .context = session, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now } }; session.tellMission(); } @@ -254,7 +254,8 @@ pub const Session = struct { } /// Tells the editor of the mission playing, as it starts or as an editor connects, and sends - /// it the mission's file, which it places its breakpoints by before the start parts run. + /// it the mission's file. The editor answers with the script's flags, which the script's start + /// waits for. fn tellMission(session: *Session) void { const loaded = session.loaded orelse return; const started: link.MissionStarted = .{ .number = session.number, .file = session.file_buffer[0..session.file_length] }; @@ -292,11 +293,16 @@ pub const Session = struct { const session: *Session = @ptrCast(@alignCast(context)); session.io.sleep(.fromMilliseconds(milliseconds), .awake) catch {}; } + + fn now(context: *anyopaque) u64 { + const session: *Session = @ptrCast(@alignCast(context)); + return @intCast(@max(0, Io.Clock.awake.now(session.io).toMilliseconds())); + } }; -/// The script's state as `link.Own.state` gives it (`link.State`), its lists made in `arena`: the -/// threads running, a thread whose instruction lies outside the script at none; each global; and -/// the game's variables, by their names, and the unnamed ones by their numbers where they aren't 0. +/// 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. 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); diff --git a/src/engine/link.zig b/src/engine/link.zig index b1ada6d6..54c3a095 100644 --- a/src/engine/link.zig +++ b/src/engine/link.zig @@ -94,7 +94,8 @@ pub const Game = struct { }; /// Why a client can't read one of OpenReliant's own messages: a field it needs is missing, or isn't -/// what the field holds. +/// what the field holds. A reader passes over the keys it doesn't know, so that a newer game's +/// messages still read. pub const ReadError = error{Invalid}; /// `Own.hello`: the link's protocol (`protocol_version`), OpenReliant's version, and the game with @@ -190,31 +191,45 @@ pub const State = struct { for (state.variables) |each| try writer.print("variable.{s}={d}\n", .{ each.name, each.value }); } - /// The state in `payload`, its lists made in `arena`, its names borrowed from `payload`. + /// The fields of the state that hold one value each. + const Scalar = enum { clock, hold, step, paused, first_finished }; + + /// The fields of a thread. + const ThreadField = enum { at, depth, wake, interrupted, trigger }; + + /// 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. pub fn read(arena: Allocator, payload: []const u8) (Allocator.Error || ReadError)!State { + var clock: ?u32 = null; + var hold: ?vm_editor.Hold = null; + var step: ?vm_editor.Step = null; + var paused: ?bool = null; + var first_finished: ?bool = null; var threads: std.ArrayList(Thread) = .empty; var globals: std.ArrayList(u32) = .empty; var variables: std.ArrayList(Variable) = .empty; - var lines = std.mem.splitScalar(u8, payload, '\n'); - while (lines.next()) |line| { - const key, const value = std.mem.cutScalar(u8, line, '=') orelse continue; - if (std.mem.cutPrefix(u8, key, "thread.")) |rest| { + var fields: Fields = .of(payload); + while (fields.next()) |found| { + const key = found.key; + const value = found.value; + if (std.meta.stringToEnum(Scalar, key)) |scalar| switch (scalar) { + .clock => clock = try parse(u32, value), + .hold => hold = try named(vm_editor.Hold, value), + .step => step = try named(vm_editor.Step, value), + .paused => paused = try flag(value), + .first_finished => first_finished = try flag(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 slot = parse(u32, slot_text) catch return error.Invalid; - const thread = try threadIn(arena, &threads, slot); - if (std.mem.eql(u8, name, "at")) { - thread.at = if (std.mem.eql(u8, value, "none")) null else try parse(u32, value); - } else if (std.mem.eql(u8, name, "depth")) { - thread.depth = try parse(u32, value); - } else if (std.mem.eql(u8, name, "wake")) { - thread.wake = try parse(u32, value); - } else if (std.mem.eql(u8, name, "interrupted")) { - thread.interrupted = try flag(value); - } else if (std.mem.eql(u8, name, "trigger")) { - thread.trigger = try parse(u32, value); - } + const thread = try threadIn(arena, &threads, try parse(u32, slot_text)); + 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), + .wake => thread.wake = try parse(u32, value), + .interrupted => thread.interrupted = try flag(value), + .trigger => thread.trigger = try parse(u32, value), + }; } else if (std.mem.cutPrefix(u8, key, "global.")) |index_text| { - const index = parse(u32, index_text) catch return error.Invalid; + const index: usize = try parse(u16, index_text); if (index >= globals.items.len) try globals.appendNTimes(arena, 0, index + 1 - globals.items.len); globals.items[index] = try parse(u32, value); } else if (std.mem.cutPrefix(u8, key, "variable.")) |name| { @@ -222,11 +237,11 @@ pub const State = struct { } } return .{ - .clock = try number(u32, payload, "clock"), - .hold = std.meta.stringToEnum(vm_editor.Hold, try text(payload, "hold")) orelse return error.Invalid, - .step = std.meta.stringToEnum(vm_editor.Step, try text(payload, "step")) orelse return error.Invalid, - .paused = try flag(try text(payload, "paused")), - .first_finished = try flag(try text(payload, "first_finished")), + .clock = clock orelse return error.Invalid, + .hold = hold orelse return error.Invalid, + .step = step orelse return error.Invalid, + .paused = paused orelse return error.Invalid, + .first_finished = first_finished orelse return error.Invalid, .threads = threads.items, .globals = globals.items, .variables = variables.items, @@ -249,13 +264,30 @@ pub const State = struct { } }; +/// The `key=value` lines of one of OpenReliant's own messages, in order, passing over a line +/// without `=`. +const Fields = struct { + lines: std.mem.SplitIterator(u8, .scalar), + + const Field = struct { key: []const u8, value: []const u8 }; + + fn of(payload: []const u8) Fields { + return .{ .lines = std.mem.splitScalar(u8, payload, '\n') }; + } + + fn next(fields: *Fields) ?Field { + while (fields.lines.next()) |line| { + const key, const value = std.mem.cutScalar(u8, line, '=') orelse continue; + return .{ .key = key, .value = value }; + } + return null; + } +}; + /// The text of field `key` of one of OpenReliant's own messages, null where it has none. fn field(payload: []const u8, key: []const u8) ?[]const u8 { - var lines = std.mem.splitScalar(u8, payload, '\n'); - while (lines.next()) |line| { - const key_found, const value = std.mem.cutScalar(u8, line, '=') orelse continue; - if (std.mem.eql(u8, key_found, key)) return value; - } + var fields: Fields = .of(payload); + while (fields.next()) |found| if (std.mem.eql(u8, found.key, key)) return found.value; return null; } @@ -273,6 +305,11 @@ fn parse(comptime T: type, value: []const u8) ReadError!T { return std.fmt.parseInt(T, value, 10) catch error.Invalid; } +/// A field that holds the name of one of `Enum`'s values. +fn named(comptime Enum: type, value: []const u8) ReadError!Enum { + return std.meta.stringToEnum(Enum, value) orelse error.Invalid; +} + /// A field that is 1 or 0. fn flag(value: []const u8) ReadError!bool { if (std.mem.eql(u8, value, "1")) return true; @@ -647,10 +684,21 @@ test "the records of OpenReliant's own messages read back as written" { try std.testing.expectEqual(5, state_read.variable("200").?); try std.testing.expectEqual(null, state_read.variable("mission_success")); - // A field missing, or not what it holds, makes the message unreadable. + // 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"); + try std.testing.expectEqual(1, newer.threads.len); + try std.testing.expectEqual(null, newer.threads[0].at); + + // A field missing, or not what it holds, makes the message unreadable, as does a global past + // the 16 bits a mission counts them in. try std.testing.expectError(error.Invalid, Hello.read("protocol=1\ngame=Example\n")); try std.testing.expectError(error.Invalid, MissionStarted.read("number=ninety\nfile=x.dte\n")); try std.testing.expectError(error.Invalid, State.read(arena, "clock=1\nhold=gripped\nstep=none\npaused=0\nfirst_finished=0\n")); + try std.testing.expectError(error.Invalid, State.read(arena, "clock=1\nhold=none\nstep=none\npaused=0\n")); + try std.testing.expectError(error.Invalid, State.read(arena, "clock=1\nhold=none\nstep=none\npaused=0\nfirst_finished=0\nglobal.70000=1\n")); + 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]); } test "an editor writes and reads messages framed" { diff --git a/src/engine/vm/editor.zig b/src/engine/vm/editor.zig index e5cf2725..8e452558 100644 --- a/src/engine/vm/editor.zig +++ b/src/engine/vm/editor.zig @@ -43,6 +43,9 @@ pub const Connection = struct { check: *const fn (context: *anyopaque) void, /// Waits `milliseconds` for the editor (`Sleep`). sleep: *const fn (context: *anyopaque, milliseconds: u32) void, + /// 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, }; }; @@ -127,16 +130,17 @@ pub const Editor = struct { /// The script's start before the start parts run (`mission_script_start`, `0x0045CC95`): the /// editor's messages are read, and while an editor is there and hasn't sent the script's flags, - /// read again every `flags_check_ms`, for `flags_wait_ms` at most. A breakpoint in a start part - /// then stops it, however long the editor takes to answer the mission's file. + /// read again every `flags_check_ms`, for `flags_wait_ms` at most, so that a breakpoint in a + /// start part stops it. /// /// **Improvement:** the original reads the messages, waits a fifth of a second whether an - /// editor is there or not (`0x0045CCA0`), and reads them again. An editor slower than that - /// misses the start parts. + /// editor is there or not (`0x0045CCA0`), and reads them again, and the breakpoints of a + /// slower editor miss the start parts. pub fn waitForFlags(editor: *Editor) void { editor.check(); - var waited: u32 = 0; - while (editor.present and !editor.flagged and waited < flags_wait_ms) : (waited += flags_check_ms) { + const connection = editor.connection orelse return; + const started = connection.vtable.now(connection.context); + while (editor.present and !editor.flagged and connection.vtable.now(connection.context) -% started < flags_wait_ms) { editor.sleep(flags_check_ms); editor.check(); } @@ -164,17 +168,18 @@ pub const testing = struct { /// held, in order. releases: []const Step = &.{}, /// How many releases it gave. - checks: usize = 0, + released: usize = 0, /// The check that brings the editor's flags, counting from 0; null for none. flags_at: ?usize = 0, /// How many times its messages were checked. checked: usize = 0, + /// How long it waited, in milliseconds, which is its clock. slept: u32 = 0, /// The editor that `check` flags and releases. editor: ?*Editor = null, pub fn connection(listener: *Listener) Connection { - return .{ .context = listener, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep } }; + return .{ .context = listener, .vtable = &.{ .stopped = stopped, .check = check, .sleep = sleep, .now = now } }; } pub fn last(listener: Listener) u32 { @@ -192,15 +197,20 @@ pub const testing = struct { defer listener.checked += 1; const editor = listener.editor orelse return; if (listener.flags_at == listener.checked) editor.flagged = true; - if (!editor.holds() or listener.checks >= listener.releases.len) return; - editor.release(listener.releases[listener.checks]); - listener.checks += 1; + if (!editor.holds() or listener.released >= listener.releases.len) return; + editor.release(listener.releases[listener.released]); + listener.released += 1; } fn sleep(context: *anyopaque, milliseconds: u32) void { const listener: *Listener = @ptrCast(@alignCast(context)); listener.slept += milliseconds; } + + fn now(context: *anyopaque) u64 { + const listener: *Listener = @ptrCast(@alignCast(context)); + return listener.slept; + } }; }; diff --git a/src/engine/vm/machine.zig b/src/engine/vm/machine.zig index 7d036aa2..fe713de8 100644 --- a/src/engine/vm/machine.zig +++ b/src/engine/vm/machine.zig @@ -2674,7 +2674,7 @@ test "the script's start waits on the editor, and runs a start part it held on f try fixture.machine.start(); // The start waited for both releases, and the part's first statement ran once. - try std.testing.expectEqual(2, listener.checks); + try std.testing.expectEqual(2, listener.released); try std.testing.expectEqual(2, listener.count); try std.testing.expectEqual(second, listener.offsets[0]); try std.testing.expectEqual(store, listener.offsets[1]); diff --git a/src/formats/dte/source/compile.zig b/src/formats/dte/source/compile.zig index 757797e6..d03a07e8 100644 --- a/src/formats/dte/source/compile.zig +++ b/src/formats/dte/source/compile.zig @@ -76,11 +76,6 @@ pub fn compileMapped(arena: Allocator, tree: syntax.Tree, failure: *Failure) Err var compiler: Compiler = .{ .arena = arena, .tree = tree, .failure = failure, .marks = .{ .gpa = arena } }; try compiler.declare(); const file = try compiler.build(); - std.mem.sort(Compiled.Statement, compiler.mapped.items, {}, struct { - fn before(_: void, a: Compiled.Statement, b: Compiled.Statement) bool { - return a.offset < b.offset; - } - }.before); return .{ .file = file, .statements = compiler.mapped.items }; } @@ -141,7 +136,8 @@ pub const Compiler = struct { statement_nodes: std.AutoHashMapUnmanaged(*const statements.Statement, Index) = .empty, /// Where the statements of the routine being written went (`routine`). marks: statements.Marks, - /// The map of the script's statements to the source's lines (`compileMapped`). + /// The map of the script's statements to the source's lines (`compileMapped`), in the order of + /// their offsets, as the routines and their code are written one after the other. mapped: std.ArrayList(Compiled.Statement) = .empty, /// The kinds of record the object table numbers. @@ -905,11 +901,17 @@ pub const Compiler = struct { const start = bytes.items.len / 2; if (start + made.len / 2 >= dte.Part.no_block) return c.fail(node, "the script is longer than a mission's {d} halfwords", .{dte.Part.no_block - 1}); try bytes.appendSlice(c.arena, made); - // The marks count from the routine's code, past the block's length word. - const code_start: u32 = @intCast(start * 2 + dte.BlockReader.header_len); + // The marks count from the routine's code, past the block's length word. Each statement's + // node is noted as `code` makes it, for every statement it makes. + const header_len = dte.BlockReader.header_len; for (c.marks.list.items) |made_mark| { const from = c.statement_nodes.get(made_mark.statement) orelse unreachable; - try c.mapped.append(c.arena, .{ .offset = code_start + made_mark.at, .at = c.tree.at(from), .stops = made_mark.stops }); + const opcode: dte.Opcode = @fromBackingInt(made[header_len + made_mark.at]); + try c.mapped.append(c.arena, .{ + .offset = @intCast(start * 2 + header_len + made_mark.at), + .at = c.tree.at(from), + .stops = if (opcode.info()) |info| info.starts_statement else false, + }); } return @intCast(start); } diff --git a/src/formats/dte/statements.zig b/src/formats/dte/statements.zig index d569c80c..3ba414b5 100644 --- a/src/formats/dte/statements.zig +++ b/src/formats/dte/statements.zig @@ -288,10 +288,10 @@ pub const arm_room = 10; /// count. pub const Error = assemble.Error; -/// Where each statement lowered went, for a debugger: the instruction the statement ends with, after -/// its operands, which a step or a breakpoint of the editor link stops before. An `if` ends with its -/// branch, before the statements it holds; a random choice with its `random_branch`, before its -/// arms. +/// Where each statement lowered went, for a debugger: the instruction the statement ends with, +/// after its operands, which a step or a breakpoint of the editor link stops before. An `if` ends +/// with its branch, before the statements it holds; a random choice with its `random_branch`, +/// before its arms. pub const Marks = struct { gpa: Allocator, list: std.ArrayList(Mark) = .empty, @@ -300,19 +300,15 @@ pub const Marks = struct { statement: *const Statement, /// The instruction's offset in the routine's code, past the block's length word. at: u32, - /// Whether the VM stops before it (`opcodes.Info.starts_statement`): it doesn't before - /// `return` or `random_branch`, so a breakpoint can't hold there. - stops: bool, }; pub fn deinit(marks: *Marks) void { marks.list.deinit(marks.gpa); } - /// Notes that `statement` ends with `opcode`, the next instruction `routine` writes. - fn add(marks: *Marks, statement: *const Statement, routine: *const assemble.Routine, opcode: Opcode) Allocator.Error!void { - const info = opcode.info() orelse unreachable; - try marks.list.append(marks.gpa, .{ .statement = statement, .at = @intCast(routine.code.items.len), .stops = info.starts_statement }); + /// Notes that `statement` ends with the next instruction `routine` writes. + fn add(marks: *Marks, statement: *const Statement, routine: *const assemble.Routine) Allocator.Error!void { + try marks.list.append(marks.gpa, .{ .statement = statement, .at = @intCast(routine.code.items.len) }); } }; @@ -321,9 +317,10 @@ pub fn lower(routine: *assemble.Routine, body: []const Statement, context: Conte for (body) |*statement| try lowerStatement(routine, statement, context); } -/// Notes in the context's marks, where it has them, that `statement` ends with `opcode`. -fn mark(routine: *const assemble.Routine, statement: *const Statement, context: Context, opcode: Opcode) Allocator.Error!void { - if (context.marks) |marks| try marks.add(statement, routine, opcode); +/// Notes in the context's marks, where it has them, that `statement` ends with the next instruction +/// `routine` writes. +fn mark(routine: *const assemble.Routine, statement: *const Statement, context: Context) Allocator.Error!void { + if (context.marks) |marks| try marks.add(statement, routine); } /// The bytes of a routine of `body`: its block and its constant table (`assemble.Routine.finish`). @@ -341,19 +338,18 @@ fn lowerStatement(routine: *assemble.Routine, statement: *const Statement, conte for (command.arguments, 0..) |argument, index| { try push(routine, argument, .{ .kinds = if (index < params.len) params[index].kinds else .{} }, context); } - try mark(routine, statement, context, .command); + try mark(routine, statement, context); try routine.op(.command, &.{command.index}); }, .call, .spawn => |call| { for (call.arguments) |argument| try push(routine, argument, .{}, context); - const opcode: Opcode = if (statement.* == .call) .call_part else .spawn_part; - try mark(routine, statement, context, opcode); - try routine.op(opcode, &.{call.part}); + try mark(routine, statement, context); + try routine.op(if (statement.* == .call) .call_part else .spawn_part, &.{call.part}); }, .assign => |assign| { try routine.op(assign.target.select(), &.{assign.target.index()}); try push(routine, assign.value, .{}, context); - try mark(routine, statement, context, assign.update.opcode()); + try mark(routine, statement, context); try routine.op(assign.update.opcode(), &.{}); }, .@"if" => |branch| { @@ -361,7 +357,7 @@ fn lowerStatement(routine: *assemble.Routine, statement: *const Statement, conte const past_then = try routine.label(); if (branch.otherwise) |otherwise| { const end = try routine.label(); - try mark(routine, statement, context, .branch_if_zero_alt); + try mark(routine, statement, context); try routine.branch(.branch_if_zero_alt, past_then); try lower(routine, branch.then, context); try routine.branch(.jump, end); @@ -369,19 +365,19 @@ fn lowerStatement(routine: *assemble.Routine, statement: *const Statement, conte try lower(routine, otherwise, context); routine.place(end); } else { - try mark(routine, statement, context, .branch_if_zero); + try mark(routine, statement, context); try routine.branch(.branch_if_zero, past_then); try lower(routine, branch.then, context); routine.place(past_then); } }, .random => |random| { - try mark(routine, statement, context, .random_branch); + try mark(routine, statement, context); try lowerRandom(routine, random, context); }, .@"return" => |value| { try push(routine, value, .{ .returned = true }, context); - try mark(routine, statement, context, .@"return"); + try mark(routine, statement, context); try routine.op(.@"return", &.{}); }, }