diff --git a/README.md b/README.md index 6cc44c4..1c741be 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ the game as it's made. **Status:** Mission mode's Script view works. It opens a mod's missions, a mission file or one of the game's own missions as source, colours it with the game's commands, checks it as it's typed, offers the game's commands and conditions as you type, explains them on hover and in the Reference -panel, builds the mission, and plays it in OpenReliant, from its start or from a part. The rest of -the [design](design/README.md) is in place and greyed out, each part with the issue that plans it. +panel, builds the mission, and plays it in OpenReliant, from its start or from a part. Linked to the +game, it holds the mission's script at breakpoints, steps through it, and shows its threads and +values ([Debugging a mission](docs/debugging.md)). The rest of the [design](design/README.md) is in +place and greyed out, each part with the issue that plans it. The plan is OpenReliant's [#1126](https://github.com/OpenReliant/openreliant/issues/1126). ![The Script view of a mission, held at a breakpoint](design/images/mission-script.png) diff --git a/build.zig.zon b/build.zig.zon index 234d218..ed7a201 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.17.0", .dependencies = .{ .openreliant = .{ - .url = "git+https://github.com/OpenReliant/openreliant.git#fd5d7dec75b2d9a3748c39f203699d82918b254e", - .hash = "openreliant-0.14.0-0BXqcn9DEAGMfVU3sRjik-iid3jrPhuvcGUVgqUSJcTc", + .url = "git+https://github.com/OpenReliant/openreliant.git#70b3beab1484b3eb511dd64aaf959d3a8b5f5c15", + .hash = "openreliant-0.14.0-0BXqcnShEAG_H5YxFly2WGyqTSBGL6SZFbbnz4JPkD9b", }, .sdl = .{ .url = "git+https://github.com/castholm/SDL.git?ref=v0.5.4+3.4.16#b72b3671502a70e109350ecac90afdc480dcfcc6", diff --git a/design/README.md b/design/README.md index a762e3a..e595f4e 100644 --- a/design/README.md +++ b/design/README.md @@ -122,7 +122,7 @@ catalogue's descriptions, and the command's reference. The game keeps playing th [Page](screens/mission-debugging.html). The script listing beside the source, breakpoints with conditions and log points, which the editor checks each time the game holds, watches, and a -timeline of the run on the script clock. +timeline of the run on the script clock ([The debugger](#the-debugger)). ### Command palette @@ -178,6 +178,20 @@ Cmd for Ctrl. Changed keys are saved for each system on its own. [Page](screens/keys-macos.html). The same settings on macOS. +### Editor link + +![The Editor link settings](images/settings-link.png) + +[Page](screens/settings-link.html). Linking when playing, the game's port, how often the state +comes while the script runs, the breakpoints kept, and live changes once OpenReliant takes them, +with the linked game beside them. + +## The debugger + +[debugger.md](debugger.md) designs the debugger and its link to the game: what it does and shows, +the link's messages, what the editor checks itself, what OpenReliant still needs, and the stages to +make it in. + ## Libraries | Part | Library | diff --git a/design/debugger.md b/design/debugger.md new file mode 100644 index 0000000..2523c4b --- /dev/null +++ b/design/debugger.md @@ -0,0 +1,270 @@ +# The debugger + +The design of the editor's debugger and its link to the game +([#5](https://github.com/OpenReliant/openreliant-editor/issues/5)): what it does, what it shows, +how it talks to OpenReliant, what OpenReliant still needs, and the order to make it in. The screens +it fills are [Script](README.md#script), held at a breakpoint, and [Debugging](README.md#debugging). + +## What it does + +- **Links to the game.** Play starts OpenReliant linked to the editor, and Link to game links to one + already running. The link is OpenReliant's editor link, which carries the original's own + messages to its mission editor and debugger + ([The editor link](https://github.com/OpenReliant/openreliant/blob/main/docs/engine/editor-link.md)). +- **Holds the script at breakpoints.** A breakpoint on a line of the source stops the game's script + before that line's statement runs. The editor shows where it stopped, the script's threads, the + mission's globals and the game's variables. +- **Steps through the script** a statement at a time, into or over the parts it calls, or runs on + to the next breakpoint or to the cursor. +- **Checks conditions and counts hits**, and writes log points to Output without stopping. +- **Shows the script listing beside the source**, with the held instruction and the breakpoints in + both. + +Later, as OpenReliant carries them: a timeline of the run, the live scene, and live changes, which +send each build into the running game. + +## The link + +OpenReliant listens for an editor when it starts with `--editor-link`, at 127.0.0.1, port 22539 or +the port `--editor-link-port` gives. It takes one editor at a time. Each message is an 8-byte +header, its tag, a halfword of 0 and its payload's size, then the payload. The editor reads and +writes them with OpenReliant's own definitions (`engine.link`, `engine.game.mission.editor`), which +its library exports, so the two can't drift apart. + +| Tag | From | What it says | What the editor does with it | +|---|---|---|---| +| `0x100` Hello | Game | The link's protocol, OpenReliant's version, the game, its script VM | Picks the game's profile, and says it's linked | +| `0x101` Mission started | Game | The mission's number and file | Says the mission plays | +| `0x104` Mission file | Game | The mission's file as the game runs it, its globals as they stand | Matches it with the build, and sends the breakpoints | +| `0x09` Script flags | Editor | A byte for each byte of the script, bit 0 set where it stops | Sent for each attempt at the mission, and as breakpoints change | +| `0x1A` Stopped | Game | The offset in the script of the byte it stopped before | Holds: the line, then the state | +| `0x105` Get state | Editor | | Sent on each hold, and for watches while the script runs | +| `0x106` State | Game | The script's clock, hold, step and pause, threads, globals and variables | Fills Threads, Variables, Watch and the values shown inline | +| `0x15` Run control | Editor | Pause or go on, run on, step into, step over, run a part | Continue, the steps, Pause | +| `0x1B` First finished | Game | Whether the start parts' first thread has finished | Says when the start parts are done | +| `0x102` Mission ended | Game | | Says no mission plays, and keeps the link | + +### A session + +1. **Play** builds the mission, starts OpenReliant on it with `--editor-link`, and connects once the + game listens; it tries again for half a minute while the game starts. **Link to game** waits for + a game started some other way with `--editor-link`, and connects as it listens. While a game is + linked, Play only builds: the game reads the new build at the mission's next attempt. +2. The game greets the editor (`Hello`). A game the editor has no profile for gets a toast, and the + link closes. +3. As the mission starts, the game sends its file. The editor matches it with the build of its text, + and sends the script's flags at once: the game holds its start parts until they come, for a + second at most, so a breakpoint in a start part can stop it. The editor answers from the task + that reads the link, not from the frame, so a busy frame doesn't hold the game up, and answers a + mission it has no build of with no flags, so that its start goes on at once. +4. Each new attempt at the mission reads its file again, and the editor sends the flags again. +5. **Unlink** closes the link. The game lets the script go as the editor goes: its hold, its step + and the pause clear, and it plays on. The breakpoints stay in the editor, and come back with the + next Play or Link to game; the editor never links again on its own. + +### Matching the game's mission with the build + +Breakpoints and the held line need the map of the build's source lines to the script's statements +(below). The build is of the text as it is, which the editor checks as it's typed. The editor uses +the map only while the game runs that build: the mission's number is the document's, and the +scripts and their routines are the same bytes as the build's. Otherwise: + +| The game plays | Toolbar chip | Breakpoints | +|---|---|---| +| Another mission | `StarLancer · mission 12` | Not sent | +| An older build of this mission | `Older build · restart mission` | Not sent; the next attempt reads the new file | +| This build | `StarLancer · mission 90` | Sent | + +The globals the game sends are its current values, so the match leaves them out. + +## What the editor shows + +### The link's states + +| State | Toolbar chip | Status bar | Start screen's game | +|---|---|---|---| +| Not linked | `OpenReliant · not linked` | `Live changes: not linked` | `OpenReliant · not linked` | +| Waiting for the game | `OpenReliant · waiting` | | | +| Linked, no mission | `StarLancer · linked` | `Live changes: planned` | `StarLancer · linked · OpenReliant 0.15.0 · port 22539` | +| A mission playing | `StarLancer · mission 90` | | | +| Held | `Held · line 46 · 41 s` | `Held` and the debugging keys | | +| Paused | `Paused · 41 s` | `Paused` | | + +The chip of a held script names the line, and the condition where one held it: +`Held · line 53 · condition true`. The clock beside it is the script's, from the state. + +### Breakpoints + +A breakpoint stops the script before the statement it's on. The gutter draws it in red; one the +game doesn't hold yet, because no build of this mission plays, is a red ring. + +| Kind | Set by | Gutter | Sent as | +|---|---|---|---| +| Line | A click in the gutter, or F9 (Cmd+\\ on macOS) | Red dot | The flag of the line's first statement | +| Condition | The breakpoint's editor: `Break if` and an expression | Red dot with a bar | The same flag; the editor checks the condition | +| Hit count | The breakpoint's editor: `Hit count` and a number | Red dot with a number | The same flag; the editor counts | +| Log point | The breakpoint's editor: `Log point` and a message with `{expressions}` | Red diamond | The same flag; the editor logs and runs on | +| Offset | The listing's gutter, or `break @` in the palette | Red dot in the listing | The byte's flag | +| Also break on | The Breakpoints panel: trigger entry, part entry, a command | | Each matching statement's flag | + +A line holds only where a statement starts. A breakpoint set on a blank line or a comment moves to +the next line with a statement, as the build's map says. Breakpoints stay with their lines as the +text changes, and the editor keeps them for each source file in its settings folder, not in the +mod. + +The breakpoint's editor opens from the gutter's context menu or a double click on the dot, as the +[Debugging](README.md#debugging) page draws it: Always, Condition, Hit count or Log point, and the +expression or message. Expressions use mission source's own words: the globals by their names, +`game.` and the game's variables, numbers, comparisons, `and`, `or` and `not`. + +**Checked by the editor.** The game stops at every flag. On a stop, the editor asks for the state, +works out the condition or the count, and sends Continue at once where it doesn't hold; a log point +writes its message to Output and does the same. While it decides, the game holds as it does at any +stop, so a condition on a line that runs often slows the mission. The Breakpoints panel says so for +a condition that has held up the game for more than a second in all. + +### Holding and stepping + +| Action | Windows and Linux | macOS | What the game does | +|---|---|---|---| +| Play in game, linked | F5 | Cmd+R | Starts, linked | +| Continue | F5 | Ctrl+Cmd+Y | Runs on to the next breakpoint (run control 1) | +| Step over | F10 | F6 | Stops before the next statement of the same block, or the block a return goes back to (3) | +| Step into | F11 | F7 | Stops before the next statement any thread runs (2) | +| Run to cursor | Ctrl+F10 | Ctrl+Cmd+C | Runs on to the cursor's line: the editor sets a flag there, and takes it away at the next hold | +| Pause mission | F6 | F8 | Stops the whole mission, ships and all, or lets it go on (run control 0) | +| Unlink | Shift+F5 | Cmd+. | Closes the link; the script plays on | +| Link to game | | | Connects to a game already running | + +A held script holds up the mission's own work: the ships' orders, the controls, the script's other +threads, its timers and its clock wait, while the ships fly on as their orders last left them. Pause +stops the mission whole. A held start part holds up the mission's start, and the game shows its +loading screen. The status bar and the Debugging screen say this in a line, so that a held mission +doesn't look broken. + +### While the script is held + +As the [Script](README.md#script) page draws it: + +- **The source** scrolls to the held line, marked in orange in the gutter and across the line. The + breadcrumbs end with the statement and the offset: `statement 1 of 2 · script offset 10`. +- **The game's values inline**, after each line that sets or reads a global or a game variable: + `now 0`. +- **The outline** marks the trigger or part that holds with a `HELD` badge. +- **The inspector** of the held record has a line for its thread: `Thread 0 · held · statement 1`. +- **Threads**, from the state: the slot, its state (Held, Waiting, Ready), where it is (`line 46 · + offset 10`), what started it (the trigger, written as in the source, or the start part), its + depth, and what it waits for (`nothing`, `clock 45.0 s`, or `its trigger`). A last line says + whether the start parts are done. A double click goes to the thread's line. +- **Variables**: the game's variables, then the mission's globals by their names. A value that + changed since the last hold is orange. +- **Watch**: expressions, each worked out on each hold. A new watch is typed at the end of the list. +- **Output**: the link's messages and the log points' lines, with the script's clock. +- **The Blocks view**, once it's made + ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3)), marks the breakpoints and the + held statement on the blocks. + +The values are the state as of the hold. While the script runs, Variables and Watch poll the state, +4 times a second by default, which costs the game nothing it would notice. + +### The Debugging screen + +The [Debugging](README.md#debugging) layout of the same panels: the Breakpoints panel on the left, +the source and the script listing side by side, Watch, Variables and Threads on the right, and the +timeline under them. The listing marks the held instruction and the breakpoints' bytes, and a click +on a line of either side selects its counterpart in the other. Its own gutter sets breakpoints by +offset. `@@` and an offset in the command palette goes to the offset's line. + +The timeline waits for the game's events (below), and shows a note until then. + +### Settings: Editor link + +The Settings' Editor link page, which the [Keys](README.md#keys) page's layout gives: + +| Setting | Default | +|---|---| +| Link on Play: Play adds `--editor-link` | On | +| Port: `--editor-link-port` | 22539 | +| State polling: Off, 1 Hz or 4 Hz | 4 Hz | +| Breakpoints: per source file, with Remove all | | +| Live changes | Off, until OpenReliant takes them | + +## In the editor + +| Module | What it does | +|---|---| +| `link/connection.zig` | The connection: connects, reads the messages on a task of its own into a queue the frame takes from, and writes them. It answers `Mission file` with the flags itself, through the session's breakpoints. | +| `debug/session.zig` | The debugging session: the link's state, the match with the build, the flags, the holds, conditions, counts and log points, and the actions | +| `debug/breakpoints.zig` | The breakpoints of each source file: kind, condition, count, message, enabled; kept in the settings folder | +| `debug/expression.zig` | Stage 2: conditions, watches and log points' expressions, read with OpenReliant's Luau parser and worked out over the state | +| `panels.zig` | Threads, Breakpoints, Variables, Watch and Output | + +OpenReliant's library reads the text payloads of its own messages, `key=value` lines, into +records (`Hello`, `MissionStarted`, `State`), which the session takes as they are. + +What's the game's goes in its profile (`game.Profile`), so that the session never names +StarLancer's offsets or opcodes: + +- the build's map of source lines to the script's statements, which `build` gives with the file; +- the flags for a set of offsets, as the game's VM reads them; +- whether two mission files have the same script and routines; +- the statements a kind of entry starts: each trigger's first, each part's first, each command of a + kind; +- the names of the state's globals, from the outline. + +## What OpenReliant needs + +| Need | For | Issue | +|---|---|---| +| The compiler's map of source lines to the script's statements, from its library | Line breakpoints, the held line, the listing's lines | [#1206](https://github.com/OpenReliant/openreliant/issues/1206) | +| The link's text payloads read into records in its library, which `openreliant debug` reads them with now | The editor and `openreliant debug` reading them alike | [#1206](https://github.com/OpenReliant/openreliant/issues/1206) | +| The start of a mission waiting for the editor's flags, rather than for a fifth of a second | Breakpoints in the start parts, on a slow computer too | [#1206](https://github.com/OpenReliant/openreliant/issues/1206) | +| The script's events: a trigger firing, a part starting, a command running, a hold | The timeline | [#1207](https://github.com/OpenReliant/openreliant/issues/1207) | +| The live scene | The Live scene panel, the inspector's Live section, watches of ranges | [#1059](https://github.com/OpenReliant/openreliant/issues/1059) | +| The editor's live changes to the mission | Live changes, the status bar's switch | [#1057](https://github.com/OpenReliant/openreliant/issues/1057) | +| Starting a mission partway through | Play from part, linked | [#577](https://github.com/OpenReliant/openreliant/issues/577) | + +The compiler keeps each statement's place in the source until the script is written, and writes +down the offset of the instruction the VM stops before. The start's wait is an improvement on the +original's fixed one: the game waits for the editor's flags, or for a second at most, while an +editor is linked, and doesn't wait without one. + +## In stages + +Each stage is a release of its own, with OpenReliant's part merged first. + +1. **Holding** ([#5](https://github.com/OpenReliant/openreliant-editor/issues/5)). OpenReliant: the + compiler's map, the payloads' records, and the start's wait. Editor: the connection, Play + linked, Link to game, Unlink, line breakpoints, the held line, Continue, the steps, Run to cursor + and Pause, Threads, Variables and Output, the link's states, and the Editor link settings. +2. **Conditions and watches** ([#19](https://github.com/OpenReliant/openreliant-editor/issues/19)). + Conditions, hit counts, log points, breakpoints by offset and the kinds of entry, the + breakpoint's editor, Watch, the values shown inline, the Debugging screen with the listing + beside the source, and `@@` offsets in the palette. +3. **The timeline** ([#20](https://github.com/OpenReliant/openreliant-editor/issues/20)), once + OpenReliant sends the script's events. +4. **The live scene** ([#21](https://github.com/OpenReliant/openreliant-editor/issues/21)), once + OpenReliant carries it: the Live scene panel, the inspector's Live section, watches of ranges, + and the Scene view's ships where they are now in the game. +5. **Live changes** ([#22](https://github.com/OpenReliant/openreliant-editor/issues/22)), once + OpenReliant takes them: each build sent into the running game, with the status bar's switch. + +## Checking it + +- **Tests** with inputs made by hand: the payloads read into records, the flags for a set of + offsets, the map from lines to offsets and back, conditions and log messages worked out over a + state, and the session's steps from one message to the next. +- **A game made of messages**: a test that listens on a free port as OpenReliant does, greets the + editor, sends a mission's file and a stop, and checks what the editor sends back, without the + game. +- **The real game**, headless: `openreliant --editor-link --mission 90 --no-sound` with the editor + linked, for each stage's new actions, on a mission of the guide to writing missions. + +## Open questions + +- **Unlink closes the link.** It could instead keep the link and only clear the breakpoints in the + game, as a debugger's detach without leaving. +- **The editor checks conditions.** OpenReliant could check them in the game later, so that a + condition on a line that runs often costs nothing; the link would need a message for it. +- **Breakpoints are kept in the settings folder.** They could go in the mod, to share them, but a + mod would then carry the editor's files. diff --git a/design/images/keys-macos.png b/design/images/keys-macos.png index 9eed34e..aa3b1c6 100644 Binary files a/design/images/keys-macos.png and b/design/images/keys-macos.png differ diff --git a/design/images/keys.png b/design/images/keys.png index 0b2711e..a78d1ff 100644 Binary files a/design/images/keys.png and b/design/images/keys.png differ diff --git a/design/images/mission-blocks-flow.png b/design/images/mission-blocks-flow.png index 2af37b4..685bd23 100644 Binary files a/design/images/mission-blocks-flow.png and b/design/images/mission-blocks-flow.png differ diff --git a/design/images/mission-blocks.png b/design/images/mission-blocks.png index b8e45b3..ac0cc5c 100644 Binary files a/design/images/mission-blocks.png and b/design/images/mission-blocks.png differ diff --git a/design/images/mission-script.png b/design/images/mission-script.png index a928e9e..40498b6 100644 Binary files a/design/images/mission-script.png and b/design/images/mission-script.png differ diff --git a/design/images/settings-link.png b/design/images/settings-link.png new file mode 100644 index 0000000..2df49a3 Binary files /dev/null and b/design/images/settings-link.png differ diff --git a/design/screens/keys-macos.html b/design/screens/keys-macos.html index fafe386..0ef5fc5 100644 --- a/design/screens/keys-macos.html +++ b/design/screens/keys-macos.html @@ -98,7 +98,7 @@
Run to cursorCtrl+F10Ctrl+Cmd+C
Breakpoint on or offF9Cmd+\
Pause missionF6F8
-
Let the script goShift+F5Cmd+.
+
UnlinkShift+F5Cmd+.
Build and play
BuildCtrl+BCmd+B
diff --git a/design/screens/keys.html b/design/screens/keys.html index 0a93f7b..ef11424 100644 --- a/design/screens/keys.html +++ b/design/screens/keys.html @@ -98,7 +98,7 @@
Run to cursorCtrl+F10Ctrl+Cmd+C
Breakpoint on or offF9Cmd+\
Pause missionF6F8
-
Let the script goShift+F5Cmd+.
+
UnlinkShift+F5Cmd+.
Build and play
BuildCtrl+BCmd+B
diff --git a/design/screens/mission-blocks-flow.html b/design/screens/mission-blocks-flow.html index 1d890f6..826cf73 100644 --- a/design/screens/mission-blocks-flow.html +++ b/design/screens/mission-blocks-flow.html @@ -142,13 +142,13 @@ -
-Held before Set AI +Held · Set AI 41.8 s diff --git a/design/screens/mission-blocks.html b/design/screens/mission-blocks.html index b80c453..1e5eb34 100644 --- a/design/screens/mission-blocks.html +++ b/design/screens/mission-blocks.html @@ -142,13 +142,13 @@ -
-Held before Set AI +Held · Set AI 41.8 s diff --git a/design/screens/mission-debugging.html b/design/screens/mission-debugging.html index 3d4410a..516cb94 100644 --- a/design/screens/mission-debugging.html +++ b/design/screens/mission-debugging.html @@ -126,7 +126,7 @@ -
diff --git a/design/screens/mission-script.html b/design/screens/mission-script.html index c5f5419..91e0ba9 100644 --- a/design/screens/mission-script.html +++ b/design/screens/mission-script.html @@ -127,13 +127,13 @@ -
-Held before line 46 +Held · line 46 41.8 s diff --git a/design/screens/settings-link.html b/design/screens/settings-link.html new file mode 100644 index 0000000..ab3c142 --- /dev/null +++ b/design/screens/settings-link.html @@ -0,0 +1,151 @@ + + + + +Editor link settings + + + + + +
+ +
+
+OpenReliant +Editor +
+ +Settings +
+ +
+ +
+
Settings
+ +
+ +
+
+
+Editor link +Debugging · live changes +
+
+StarLancermission 90 + +
+
+
+
+
Link on PlayPlay adds --editor-link
+
+
+
+
Port--editor-link-port
+
22539
+
+
+
State pollingVariables, Watch · while running
+
+
+
+
BreakpointsPer source file
+
3 files · 7 breakpoints
+
+
+
Live changesHot-swap each build
+
Planned · #22
+
+
+
+ +
+
+
The game
+
+
Linked
StarLancer · OpenReliant 0.15.0
+
Address
127.0.0.1:22539
+
Plays
mission 90 · this build
+
Clients
1 at a time
+
+
While held
+
+
Script
Threads, timers, clock stop
+
Ships
Fly on · last orders
+
Pause
Whole mission stops
+
Start parts
Loading screen holds
+
+
+Link by hand +openreliant --editor-link +Then Game · Link to game +
+
+
+
+ +
+Editor link · port 22539 +Linked · StarLancer +Live changes: planned +
+ +
+ + diff --git a/docs/architecture.md b/docs/architecture.md index bbccbf2..bdf7fa3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,9 @@ own library, which `build.zig.zon` pins, so it reads every mission exactly as th | [`game/starlancer.zig`](../src/game/starlancer.zig) | StarLancer's profile, on OpenReliant's tables and compiler | | [`project.zig`](../src/project.zig) | A mod folder and its missions, and the mods of a game folder | | [`mission/document.zig`](../src/mission/document.zig) | An open mission: its source, its checks, its build and its breakpoints | +| [`link/connection.zig`](../src/link/connection.zig) | The editor link's connection to the game, on a task of its own | +| [`debug/session.zig`](../src/debug/session.zig) | The debugger: the link's state, the game's mission matched with the build, the holds, the state and the actions | +| [`debug/breakpoints.zig`](../src/debug/breakpoints.zig) | Where each breakpoint holds the script, and each source file's breakpoints kept in the settings folder | | [`shell.zig`](../src/shell.zig) | The workspace's menu bar, toolbar, dock space and status bar | | [`panels.zig`](../src/panels.zig) | The dockable panels: the outline, problems, the inspector and the others | | [`views/script.zig`](../src/views/script.zig) | The Script view: the source, its gutter, help and completion, and the listing | @@ -35,8 +38,9 @@ profile, a `game.Profile`: source's declarations, the game's commands and conditions with their descriptions and parameters, the game's variables, and the names of its values; - the records a mission's source declares, for the outline and the inspector; -- building source into a mission file, decompiling a mission file into source, and comparing two - missions; +- building source into a mission file, with the map of its script's statements to the source's + lines, decompiling a mission file into source, and comparing two missions and their scripts; +- the game's messages on the editor link, and the script's flags for a set of offsets; - the game's own missions in its folder, and the command line that plays one. StarLancer's profile reads all of it from OpenReliant's library: the command and condition @@ -56,6 +60,21 @@ and builds it in memory, and the problems show in the gutter, under the text and panel. While the source doesn't parse, the outline is the last one that did. Build writes the mission file beside the source and keeps its script's listing. +## The debugger + +The debugger links to the game over OpenReliant's editor link ([Debugging a mission](debugging.md)). +`link/connection.zig` connects on a task of its own, which reads the game's messages into a queue +that each frame takes from, and wakes the window as they come. The task answers each mission's file +itself with the breakpoints' flags, since the game holds the mission's start until they come. + +`debug/session.zig` takes the messages each frame. It matches the mission the game plays with the +build of the open mission's text: the same number, and the same script with the same routines +(`Profile.sameScript`). While they match, the build's map turns breakpoints' lines into the +offsets the game's flags hold at, and the offset of each hold back into a line. The session asks for +the script's state on each hold, and polls it while the script runs. It reads OpenReliant's own +messages with the records of OpenReliant's library (`engine.link`), and the game's own through its +profile (`Profile.link`), so that it never names StarLancer's tags or opcodes. + ## What isn't made yet Each shows in the interface, greyed out, with its issue: @@ -63,12 +82,15 @@ Each shows in the interface, greyed out, with its issue: - the Scene view ([#2](https://github.com/OpenReliant/openreliant-editor/issues/2)) and the Blocks view ([#3](https://github.com/OpenReliant/openreliant-editor/issues/3)); - Mod mode ([#4](https://github.com/OpenReliant/openreliant-editor/issues/4)); -- the debugger on the editor link, and the panels it fills - ([#5](https://github.com/OpenReliant/openreliant-editor/issues/5)); +- the debugger's conditions, log points, Watch, values inline and Debugging screen + ([#19](https://github.com/OpenReliant/openreliant-editor/issues/19)), its timeline + ([#20](https://github.com/OpenReliant/openreliant-editor/issues/20)), the live scene + ([#21](https://github.com/OpenReliant/openreliant-editor/issues/21)) and live changes + ([#22](https://github.com/OpenReliant/openreliant-editor/issues/22)); - the guided first mission ([#6](https://github.com/OpenReliant/openreliant-editor/issues/6)); - new mods from templates ([#7](https://github.com/OpenReliant/openreliant-editor/issues/7)); - adding and editing records with forms ([#8](https://github.com/OpenReliant/openreliant-editor/issues/8)); - opening `.hog` mods ([#9](https://github.com/OpenReliant/openreliant-editor/issues/9)); -- the General, Appearance and Editor link settings +- the General and Appearance settings ([#10](https://github.com/OpenReliant/openreliant-editor/issues/10)). diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..e299d36 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,68 @@ +# Debugging a mission + +The editor links to OpenReliant over the game's editor link +([The editor link](https://github.com/OpenReliant/openreliant/blob/main/docs/engine/editor-link.md)) +and holds the mission's script at breakpoints as the game plays it. +[The design](../design/debugger.md) describes the whole debugger and the stages it comes in. + +## Linking + +- **Play** (F5, or Cmd+R on macOS) builds the mission and starts OpenReliant on it with + `--editor-link`, then connects as the game listens. Settings · Editor link · Link on Play turns + this off. While a game is linked, Play only builds, and the game reads the new build at the + mission's next attempt. +- **Game · Link to game** connects to a game started some other way with + `openreliant --editor-link`, and waits until one listens. +- **Unlink** (Shift+F5, or Cmd+. on macOS) closes the link. The game lets the script go, and plays + on. + +The toolbar's chip shows the link: `OpenReliant · not linked` or `waiting`, then the game's name +with `linked` or the mission it plays. + +## Breakpoints + +A click in the gutter, or F9 (Cmd+\ on macOS), sets a breakpoint on a line, and the script holds +before the line's first statement. A line the script can't hold on, such as a `return`, an `end`, a +comment or a blank line, passes its breakpoint on to the next line that holds, in the same trigger +or part. + +A breakpoint is a red dot while the game plays the mission built from the text as it is, and a red +ring otherwise: when no game is linked, when it plays another mission, or when it plays an older +build. Each attempt at a mission reads the mission's file again, so a new build holds from the +mission's next attempt. + +The editor keeps each source file's breakpoints in its settings folder, in `breakpoints.ini`, not +in the mod. Settings · Editor link counts them, and removes them all. + +## While the script is held + +The source marks the held line in orange, the outline marks its trigger or part `HELD`, and the +inspector names its thread. + +| Action | Windows and Linux | macOS | What the script does | +|---|---|---|---| +| Continue | F5 | Ctrl+Cmd+Y | Runs on to the next breakpoint | +| Step over | F10 | F6 | Runs to the next statement of the same block, past the parts it calls | +| Step into | F11 | F7 | Runs to the next statement any thread runs | +| Run to cursor | Ctrl+F10 | Ctrl+Cmd+C | Runs on to the cursor's line | +| Pause | F6 | F8 | Pauses the whole mission, ships and all, or lets it go on | + +- **Threads** lists the script's threads: where each one is, what started it, its call depth and + what it waits for. A double click goes to a thread's line. +- **Variables** shows the game's variables that the source uses and the mission's globals, with + their values in the game. A value that changed since the last hold is orange. While the script + runs, the editor polls the game for them, 4 times a second by default (Settings · Editor link · + State polling). +- **Output** lists what the link says: the game linked, each mission started, each hold. + +As in the original, a held script holds up the mission's own work: the ships' orders, the script's +other threads, its timers and its clock wait, while the ships fly on as their orders last left +them. A held start part holds the mission's loading screen. + +## Not made yet + +- Conditions, hit counts, log points, Watch, the values inline and the Debugging screen + ([#19](https://github.com/OpenReliant/openreliant-editor/issues/19)). +- The timeline ([#20](https://github.com/OpenReliant/openreliant-editor/issues/20)). +- The live scene ([#21](https://github.com/OpenReliant/openreliant-editor/issues/21)). +- Live changes ([#22](https://github.com/OpenReliant/openreliant-editor/issues/22)). diff --git a/src/app.zig b/src/app.zig index b63c5f9..db8daa4 100644 --- a/src/app.zig +++ b/src/app.zig @@ -7,8 +7,11 @@ const Allocator = std.mem.Allocator; const Io = std.Io; const c = @import("c"); +const breakpoints = @import("debug/breakpoints.zig"); const commands = @import("commands.zig"); const Command = commands.Command; +const session_mod = @import("debug/session.zig"); +const Session = session_mod.Session; const document_mod = @import("mission/document.zig"); const Document = document_mod.Document; const game = @import("game.zig"); @@ -136,7 +139,7 @@ pub const Panel = enum { pub const OutputLine = struct { time: [8]u8, text: []const u8, - kind: enum { info, success, failure } = .info, + kind: toasts.Kind = .info, }; /// The outline's selection: a record, by its section and the line its declaration starts on. @@ -191,11 +194,13 @@ pub const App = struct { quit: bool = false, /// Whether the window should close once unsaved changes are dealt with. closing: bool = false, - /// The game the editor link reports in its `Hello`, whose profile the editor then uses; null - /// while no game is linked. The link comes with the debugger. - linked_game: ?[]const u8 = null, - /// The state of the debugger: whether a script is held. - held: bool = false, + /// The debugger: the editor link to the game, and what it holds. + session: Session, + /// Each source file's breakpoints, kept in the settings folder. + breakpoint_store: breakpoints.Store, + /// What the session was last aimed at (`aimSession`): the open mission, the version of its + /// text checked and its breakpoints, hashed. + aimed: u64 = 0, /// A folder chosen in the system's dialog, which may answer on another thread: the path, a /// copy SDL made, passed over once. @@ -225,6 +230,8 @@ pub const App = struct { .listing_view = listing_view, .output_arena = .init(gpa), .mods_arena = .init(gpa), + .session = .init(gpa, io, profile, wakeWindow), + .breakpoint_store = .load(gpa, io, settings.folder), }; if (settings.game_folder == null) { if (project.findGameFolder(io, app.mods_arena.allocator(), profile)) |found| settings.setGameFolder(io, found); @@ -234,9 +241,14 @@ pub const App = struct { pub fn deinit(app: *App) void { // Quitting has already asked about unsaved changes. - if (app.document) |document| document.close(); + if (app.document) |document| { + app.keepBreakpoints(); + document.close(); + } app.document = null; app.closeMod(); + app.session.deinit(); + app.breakpoint_store.deinit(); app.listing_view.deinit(); app.editor_language.deinit(); app.language_arena.deinit(); @@ -257,6 +269,7 @@ pub const App = struct { if (document.checkPending()) app.window.keepBusy(); if (document.update(c.SDL_GetTicks())) app.clampSelection(); } + app.debugFrame(); switch (app.screen) { .start => start_screen.draw(app), .settings => settings_screen.draw(app), @@ -274,8 +287,8 @@ pub const App = struct { const known = commands.info(command); switch (known.when) { .always => {}, - .held => if (!app.held) continue, - .not_held => if (app.held) continue, + .held => if (!app.held()) continue, + .not_held => if (app.held()) continue, } for (app.settings.keymap.chords(command, keys.System.current)) |chord| { const repeat: c.ImGuiInputFlags = if (repeats(command)) c.ImGuiInputFlags_Repeat else 0; @@ -305,12 +318,31 @@ pub const App = struct { .save_all => if (app.document == null) .{ .reason = needs_document } else null, .close_mod => if (app.project == null) .{ .reason = "No mod open" } else null, .go_to => if (app.document == null) .{ .reason = needs_document } else null, + .@"continue" => if (!app.held()) .{ .reason = "Not held" } else null, + .step_over, .step_into, .pause => app.debugUnavailable(), + .run_to_cursor => if (!app.held()) .{ .reason = "Not held" } else if (!app.session.mapped()) .{ .reason = "Other build playing" } else null, + .unlink => if (app.session.link_state == .off) .{ .reason = "Not linked" } else null, + .link => switch (app.session.link_state) { + .off => null, + .waiting => .{ .reason = "Waiting for game" }, + .linked => .{ .reason = "Linked" }, + }, .view_1, .view_2, .view_3, .view_4 => if (app.screen != .workspace) .{ .reason = "Open a mission first" } else app.viewUnavailable(command), .layout_full, .reset_layout => if (app.screen != .workspace) .{ .reason = "Open a mission first" } else null, else => null, }; } + /// Why the steps and Pause can't run now, or null when they can: while the game plays a + /// mission, linked. + fn debugUnavailable(app: *const App) ?widgets.Disabled { + return switch (app.session.link_state) { + .off => .{ .reason = "Not linked" }, + .waiting => .{ .reason = "Waiting for game" }, + .linked => if (app.session.playing == null) .{ .reason = "No mission" } else null, + }; + } + fn viewUnavailable(app: *const App, command: Command) ?widgets.Disabled { return switch (app.mode) { .mission => switch (command) { @@ -351,8 +383,14 @@ pub const App = struct { .new_mod, .open_hog, .switch_mode, .layout_guided, .add_record => {}, .open_file => app.chooseFile(), .edit_game_mission => app.palette.show(.game_missions), - .@"continue", .step_over, .step_into, .run_to_cursor, .pause, .let_go => {}, - .play_mod_only, .reload_scripts, .link => {}, + .@"continue" => app.session.release(.run_on), + .step_over => app.session.release(.step_over), + .step_into => app.session.release(.step_into), + .run_to_cursor => app.runToCursor(), + .pause => app.session.togglePause(), + .unlink => app.session.unlink(), + .link => app.session.connect(app.settings.link.port, .waiting), + .play_mod_only, .reload_scripts => {}, .new_mission => app.newMission(), .play => app.play(.{}), .play_from_part => app.palette.show(.play_from_part), @@ -396,10 +434,11 @@ pub const App = struct { .build => app.build(), .show_source => app.source_tab = .source, .show_listing => app.source_tab = .listing, - .toggle_breakpoint => if (app.document) |document| { - document.toggleBreakpoint(document.editor.cursor().line); + .toggle_breakpoint => if (app.document) |document| app.toggleBreakpoint(document.editor.cursor().line), + .clear_breakpoints => if (app.document) |document| { + document.clearBreakpoints(); + app.keepBreakpoints(); }, - .clear_breakpoints => if (app.document) |document| document.clearBreakpoints(), .show_mission_panel => app.showPanel(.mission), .show_find_panel => app.showPanel(.find), .show_problems => app.showPanel(.problems), @@ -430,7 +469,7 @@ pub const App = struct { c.ImGui_SetWindowFocusStr(panel.title()); } - pub fn log(app: *App, kind: @FieldType(OutputLine, "kind"), comptime format: []const u8, args: anytype) void { + pub fn log(app: *App, kind: toasts.Kind, comptime format: []const u8, args: anytype) void { const text = std.fmt.allocPrint(app.output_arena.allocator(), format, args) catch return; app.output.append(app.output_arena.allocator(), .{ .time = document_mod.clockNow(), .text = text, .kind = kind }) catch {}; } @@ -442,6 +481,8 @@ pub const App = struct { app.toasts.push(.failure, gui.print("Can't save {s}", .{document.file}), @errorName(err)); return; }; + // The breakpoints are kept on their lines as saved. + app.keepBreakpoints(); app.log(.info, "Saved {s}", .{document.file}); } @@ -457,7 +498,8 @@ pub const App = struct { switch (result) { .built => |file| { app.log(.success, "Built {s}", .{file}); - app.toasts.push(.success, gui.print("Built {s}", .{file}), "Not linked: the game reads it next time"); + const detail = if (app.session.playingMission()) "Game reads it next attempt" else "Game reads it next play"; + app.toasts.push(.success, gui.print("Built {s}", .{file}), detail); }, .problems => |found| { const first = found[0]; @@ -544,8 +586,10 @@ pub const App = struct { return number; } - /// Builds the mission, then starts OpenReliant on it. - pub fn play(app: *App, options: game.Play) void { + /// Builds the mission, then starts OpenReliant on it, linked to the editor where the settings + /// say so. A game linked already plays the new build from the mission's next attempt, and no + /// other starts; a link that waits for a game takes the one started. + pub fn play(app: *App, asked: game.Play) void { const document = app.document orelse return; const number = document.number orelse { app.toasts.push(.failure, "No mission number", "Name it like mission90.mission.luau"); @@ -553,11 +597,14 @@ pub const App = struct { }; app.build(); if (document.problems().len > 0 or document.built == null) return; + if (app.session.link_state == .linked) return; const game_folder = app.settings.game_folder orelse return; var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const program = app.findOpenReliant(arena); + var options = asked; + if (app.settings.link.when_playing) options.link = app.settings.link.port; const arguments = app.profile.playArguments(arena, game_folder, number, options) catch return; const argv = std.mem.concat(arena, []const u8, &.{ &.{program}, arguments }) catch return; _ = std.process.spawn(app.io, .{ .argv = argv, .stdin = .ignore, .stdout = .ignore, .stderr = .ignore }) catch |err| { @@ -566,7 +613,95 @@ pub const App = struct { return; }; app.log(.info, "Playing mission {d}: {s} {s}", .{ number, std.fs.path.basename(program), std.mem.join(arena, " ", arguments) catch "" }); - app.toasts.push(.success, gui.print("Playing mission {d}", .{number}), "OpenReliant started"); + app.toasts.push(.success, gui.print("Playing mission {d}", .{number}), if (options.link != null) "OpenReliant started · linking" else "OpenReliant started"); + if (options.link) |port| if (app.session.link_state == .off) app.session.connect(port, .{ .starting = starting_patience_ms }); + } + + /// How long the editor tries to link to a game it started, while the game starts. + const starting_patience_ms = 30_000; + + /// The debugger's part of the frame: the session aimed at the open mission, what the game sent + /// taken, the held line shown, and the session's lines written to Output. + fn debugFrame(app: *App) void { + app.aimSession(); + app.session.update(c.SDL_GetTicks(), app.settings.link.state_rate); + for (app.session.takeNotes()) |note| app.log(note.kind, "{s}", .{note.text}); + app.session.freeNotes(); + const document = app.document orelse return; + const held_line = if (app.session.heldStatement()) |statement| statement.at.line else null; + document.setHeldLine(held_line); + if (app.session.takeRevealed()) { + if (held_line) |line| app.goTo(line, 0); + app.window.raise(); + } + } + + /// Aims the session at the open mission whenever it, the text's build or the breakpoints + /// change. + fn aimSession(app: *App) void { + var buffer: [document_mod.max_breakpoints]usize = undefined; + var hasher: std.hash.Wyhash = .init(0); + const lines: []const usize = if (app.document) |document| document.breakpoints(&buffer) else &.{}; + if (app.document) |document| { + hasher.update(std.mem.asBytes(&@intFromPtr(document))); + hasher.update(std.mem.asBytes(&document.checked_version)); + } + hasher.update(std.mem.sliceAsBytes(lines)); + const key = hasher.final(); + if (key == app.aimed) return; + app.aimed = key; + const document = app.document orelse return app.session.aim(.{ .number = null, .mission = null, .offsets = &.{} }); + var arena: std.heap.ArenaAllocator = .init(app.gpa); + defer arena.deinit(); + const mission = document.mission(); + const offsets = if (mission) |built| breakpoints.offsets(arena.allocator(), built, document.outline(), lines) catch &.{} else &.{}; + app.session.aim(.{ .number = document.number, .mission = mission, .offsets = offsets }); + } + + /// Whether the game holds the script. + pub fn held(app: *const App) bool { + return app.session.held != null; + } + + /// Sets or clears the breakpoint on `line`. One set where the script can't hold goes to the + /// next line that holds, as the build's map says. + pub fn toggleBreakpoint(app: *App, line: usize) void { + const document = app.document orelse return; + if (document.hasBreakpoint(line)) { + document.setBreakpoint(line, false); + } else if (document.mission()) |mission| { + const statement = breakpoints.place(mission, document.outline(), @intCast(line)) orelse { + app.toasts.push(.info, "Can't hold here", "return and end can't hold"); + return; + }; + document.setBreakpoint(statement.at.line, true); + } else document.setBreakpoint(line, true); + app.keepBreakpoints(); + } + + /// Removes the breakpoints of every source file, the open mission's too. + pub fn removeAllBreakpoints(app: *App) void { + if (app.document) |document| document.clearBreakpoints(); + app.breakpoint_store.removeAll(app.io); + app.log(.info, "Removed all breakpoints", .{}); + } + + /// Keeps the open mission's breakpoints in the settings folder. + fn keepBreakpoints(app: *App) void { + const document = app.document orelse return; + var buffer: [document_mod.max_breakpoints]usize = undefined; + app.breakpoint_store.keep(app.io, document.path, document.breakpoints(&buffer)); + } + + /// Lets the held script run on to the cursor's line. + fn runToCursor(app: *App) void { + const document = app.document orelse return; + const mission = document.mission() orelse return; + const statement = breakpoints.place(mission, document.outline(), @intCast(document.editor.cursor().line)) orelse { + app.toasts.push(.info, "Can't hold here", "Cursor on a statement"); + return; + }; + app.session.runTo(statement.offset); } /// Where `openreliant` is: where the settings say, beside the editor, beside the game's folder @@ -620,6 +755,7 @@ pub const App = struct { app.document = document; app.selection = null; app.source_tab = .source; + for (app.breakpoint_store.lines(document.path)) |line| document.setBreakpoint(line, true); script_view.attach(app, document); app.updateTitle(); } @@ -629,6 +765,7 @@ pub const App = struct { fn releaseDocument(app: *App) void { const document = app.document orelse return; if (document.dirty()) app.save(); + app.keepBreakpoints(); document.close(); app.document = null; } @@ -886,6 +1023,14 @@ pub const App = struct { } }; +/// Wakes the window from its wait for events: the editor link's task calls it as the game's +/// messages come. +fn wakeWindow() void { + var event = std.mem.zeroes(c.SDL_Event); + event.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&event); +} + /// The text editor's language from the game's words: its keywords, its declarations, its /// commands, and the name its variables are read under. fn editorLanguage(arena: Allocator, language: game.Language) Allocator.Error!text_editor.Language { diff --git a/src/commands.zig b/src/commands.zig index 80efc34..5f9ca7e 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -60,7 +60,7 @@ pub const Command = enum { toggle_breakpoint, clear_breakpoints, pause, - let_go, + unlink, // Game play, play_from_part, @@ -196,13 +196,16 @@ pub const plans = struct { pub const scene: Plan = .{ .issue = 2 }; pub const blocks: Plan = .{ .issue = 3 }; pub const mod_mode: Plan = .{ .issue = 4 }; - pub const debugger: Plan = .{ .issue = 5 }; + /// Conditions, log points, Watch, the values inline, the Debugging screen and offsets. + pub const watches: Plan = .{ .issue = 19 }; + pub const timeline: Plan = .{ .issue = 20 }; + pub const live_changes: Plan = .{ .issue = 22 }; pub const guided: Plan = .{ .issue = 6 }; pub const templates: Plan = .{ .issue = 7 }; pub const records: Plan = .{ .issue = 8 }; pub const hogs: Plan = .{ .issue = 9 }; pub const settings: Plan = .{ .issue = 10 }; - pub const live_scene: Plan = .{ .issue = 1059, .repository = .openreliant }; + pub const live_scene: Plan = .{ .issue = 21 }; pub const play_from_part: Plan = .{ .issue = 577, .repository = .openreliant }; pub const chosen_mods: Plan = .{ .issue = 1202, .repository = .openreliant }; pub const reload_scripts: Plan = .{ .issue = 1201, .repository = .openreliant }; @@ -251,20 +254,20 @@ pub fn info(command: Command) Info { .show_source => .{ .label = "Mission source", .icon = .file_code }, .show_listing => .{ .label = "Script listing", .icon = .list }, - .@"continue" => .{ .label = "Continue", .icon = .step_forward, .when = .held, .keys = defaults(&.{"F5"}, &.{"Ctrl+Cmd+Y"}), .key_group = .debugging, .planned = plans.debugger }, - .step_over => .{ .label = "Step over", .icon = .redo_dot, .when = .held, .keys = defaults(&.{"F10"}, &.{"F6"}), .key_group = .debugging, .planned = plans.debugger }, - .step_into => .{ .label = "Step into", .icon = .arrow_down_to_dot, .when = .held, .keys = defaults(&.{"F11"}, &.{"F7"}), .key_group = .debugging, .planned = plans.debugger }, - .run_to_cursor => .{ .label = "Run to cursor", .icon = .arrow_right_to_line, .when = .held, .keys = defaults(&.{"Ctrl+F10"}, &.{"Ctrl+Cmd+C"}), .key_group = .debugging, .planned = plans.debugger }, + .@"continue" => .{ .label = "Continue", .icon = .step_forward, .when = .held, .keys = defaults(&.{"F5"}, &.{"Ctrl+Cmd+Y"}), .key_group = .debugging }, + .step_over => .{ .label = "Step over", .icon = .redo_dot, .when = .held, .keys = defaults(&.{"F10"}, &.{"F6"}), .key_group = .debugging }, + .step_into => .{ .label = "Step into", .icon = .arrow_down_to_dot, .when = .held, .keys = defaults(&.{"F11"}, &.{"F7"}), .key_group = .debugging }, + .run_to_cursor => .{ .label = "Run to cursor", .icon = .arrow_right_to_line, .when = .held, .keys = defaults(&.{"Ctrl+F10"}, &.{"Ctrl+Cmd+C"}), .key_group = .debugging }, .toggle_breakpoint => .{ .label = "Breakpoint on or off", .icon = .circle_dot, .keys = defaults(&.{"F9"}, &.{"Cmd+\\"}), .key_group = .debugging }, .clear_breakpoints => .{ .label = "Remove all breakpoints", .icon = .x }, - .pause => .{ .label = "Pause mission", .icon = .pause, .keys = defaults(&.{"F6"}, &.{"F8"}), .key_group = .debugging, .planned = plans.debugger }, - .let_go => .{ .label = "Let the script go", .icon = .square, .when = .held, .keys = defaults(&.{"Shift+F5"}, &.{"Cmd+."}), .key_group = .debugging, .planned = plans.debugger }, + .pause => .{ .label = "Pause mission", .icon = .pause, .keys = defaults(&.{"F6"}, &.{"F8"}), .key_group = .debugging }, + .unlink => .{ .label = "Unlink", .icon = .square, .when = .held, .keys = defaults(&.{"Shift+F5"}, &.{"Cmd+."}), .key_group = .debugging }, .play => .{ .label = "Play in game", .icon = .play, .when = .not_held, .keys = defaults(&.{"F5"}, &.{"Cmd+R"}), .key_group = .build_and_play }, .play_from_part => .{ .label = "Play from part…", .icon = .play, .keys = defaults(&.{"Ctrl+F5"}, &.{"Ctrl+Cmd+R"}), .key_group = .build_and_play }, .play_mod_only => .{ .label = "Play with this mod only", .icon = .play, .planned = plans.chosen_mods }, .reload_scripts => .{ .label = "Reload scripts", .icon = .refresh_cw, .keys = defaults(&.{"Ctrl+R"}, &.{"Cmd+Shift+R"}), .key_group = .build_and_play, .planned = plans.reload_scripts }, - .link => .{ .label = "Link to the game", .icon = .cable, .planned = plans.debugger }, + .link => .{ .label = "Link to game", .icon = .cable }, .game_settings => .{ .label = "Game settings", .icon = .sliders_horizontal }, .show_mission_panel => .{ .label = "Mission", .icon = .flag, .group = "Panel" }, @@ -308,7 +311,7 @@ const menu_entries: std.EnumArray(Menu, []const Entry) = .init(.{ .edit = &.{ cmd(.undo), cmd(.redo), .separator, cmd(.cut), cmd(.copy), cmd(.paste), cmd(.select_all), .separator, cmd(.find), cmd(.find_next), cmd(.replace), .separator, cmd(.go_to_line), cmd(.comment_line) }, .view = &.{ cmd(.palette), cmd(.go_to), .separator, cmd(.view_1), cmd(.view_2), cmd(.view_3), cmd(.view_4), .separator, cmd(.switch_mode), .separator, cmd(.layout_full), cmd(.layout_guided), .separator, cmd(.full_screen) }, .mission = &.{ cmd(.build), .separator, cmd(.add_record), .separator, cmd(.show_source), cmd(.show_listing) }, - .debug = &.{ cmd(.@"continue"), cmd(.step_over), cmd(.step_into), cmd(.run_to_cursor), .separator, cmd(.toggle_breakpoint), cmd(.clear_breakpoints), .separator, cmd(.pause), cmd(.let_go) }, + .debug = &.{ cmd(.@"continue"), cmd(.step_over), cmd(.step_into), cmd(.run_to_cursor), .separator, cmd(.toggle_breakpoint), cmd(.clear_breakpoints), .separator, cmd(.pause), cmd(.unlink) }, .game = &.{ cmd(.play), cmd(.play_from_part), cmd(.play_mod_only), .separator, cmd(.reload_scripts), .separator, cmd(.link), cmd(.game_settings) }, .window = &.{ cmd(.show_mission_panel), cmd(.show_find_panel), .separator, cmd(.show_problems), cmd(.show_output), cmd(.show_threads), cmd(.show_breakpoints), .separator, cmd(.show_variables), cmd(.show_watch), .separator, cmd(.show_inspector), cmd(.show_reference), cmd(.show_live_scene), .separator, cmd(.reset_layout) }, .help = &.{ cmd(.help_missions), cmd(.help_reference), cmd(.help_mods), cmd(.help_modding), cmd(.help_scripting), .separator, cmd(.help_keys), .separator, cmd(.about), .version }, diff --git a/src/debug/breakpoints.zig b/src/debug/breakpoints.zig new file mode 100644 index 0000000..9cd3c4e --- /dev/null +++ b/src/debug/breakpoints.zig @@ -0,0 +1,198 @@ +//! The editor's breakpoints (design/debugger.md): where each one holds the game's script, by the +//! build's map of the script's statements, and the breakpoints of each source file, kept in the +//! editor's settings folder so that they come back when the file opens again. They're the +//! editor's, not the mod's. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const game = @import("../game.zig"); +const ini = @import("../ini.zig"); + +/// Where a breakpoint on `line` holds the script in `mission`, a build of the source: before the +/// first statement on the line that the script can hold before, or on the next line that has one, +/// within the code of the record that holds the line. Outside a record, the next one's code counts. +/// Null where nothing holds. +pub fn place(mission: game.Mission, outline: game.Outline, line: u32) ?game.Statement { + const last_line = if (outline.at(line)) |record| record.last_line else std.math.maxInt(u32); + return mission.breakpointFor(line, last_line); +} + +/// The offsets in the script where the breakpoints on `lines` hold it, in order, each once. +pub fn offsets(arena: Allocator, mission: game.Mission, outline: game.Outline, lines: []const usize) Allocator.Error![]u32 { + var found: std.ArrayList(u32) = .empty; + for (lines) |line| { + const statement = place(mission, outline, @intCast(line)) orelse continue; + if (std.mem.findScalar(u32, found.items, statement.offset) == null) try found.append(arena, statement.offset); + } + std.mem.sort(u32, found.items, {}, std.sort.asc(u32)); + return found.items; +} + +/// The name of the file the breakpoints are kept in, in the settings folder. +const file_name = "breakpoints.ini"; + +/// The breakpoints of each source file, by its path, as `breakpoints.ini` keeps them: a section +/// for each file, and a line for each breakpoint, its line counted from 1. +pub const Store = struct { + gpa: Allocator, + /// The file they're kept in; null where the system gives no settings folder, and nothing is + /// kept. + path: ?[]const u8 = null, + /// The lines of each file's breakpoints, counted from 0, in order. The paths and the lines are + /// the store's. + files: std.StringArrayHashMapUnmanaged([]const u32) = .empty, + + /// The breakpoints kept in the settings folder `folder`. + pub fn load(gpa: Allocator, io: Io, folder: ?[]const u8) Store { + var store: Store = .{ .gpa = gpa }; + const settings = folder orelse return store; + store.path = std.fs.path.join(gpa, &.{ settings, file_name }) catch return store; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const text = Io.Dir.cwd().readFileAlloc(io, store.path.?, arena.allocator(), .limited(4 * 1024 * 1024)) catch return store; + store.read(arena.allocator(), text) catch {}; + return store; + } + + pub fn deinit(store: *Store) void { + store.forget(); + store.files.deinit(store.gpa); + if (store.path) |path| store.gpa.free(path); + } + + /// The lines of the breakpoints of the source file at `source`, counted from 0. + pub fn lines(store: *const Store, source: []const u8) []const u32 { + return store.files.get(source) orelse &.{}; + } + + /// Keeps `kept`, lines counted from 0, as the breakpoints of the source file at `source`, and + /// saves them all. + pub fn keep(store: *Store, io: Io, source: []const u8, kept: []const usize) void { + store.set(source, kept) catch return; + store.save(io); + } + + /// Forgets every file's breakpoints, and saves. + pub fn removeAll(store: *Store, io: Io) void { + store.forget(); + store.save(io); + } + + /// How many files have breakpoints, and how many breakpoints they have in all. + pub fn count(store: *const Store) struct { files: usize, breakpoints: usize } { + var breakpoints: usize = 0; + for (store.files.values()) |kept| breakpoints += kept.len; + return .{ .files = store.files.count(), .breakpoints = breakpoints }; + } + + fn set(store: *Store, source: []const u8, kept: []const usize) Allocator.Error!void { + const gpa = store.gpa; + if (kept.len == 0) { + const removed = store.files.fetchOrderedRemove(source) orelse return; + gpa.free(removed.key); + gpa.free(removed.value); + return; + } + const made = try gpa.alloc(u32, kept.len); + errdefer gpa.free(made); + for (made, kept) |*line, from| line.* = @intCast(from); + std.mem.sort(u32, made, {}, std.sort.asc(u32)); + const entry = try store.files.getOrPut(gpa, source); + if (entry.found_existing) { + gpa.free(entry.value_ptr.*); + } else { + entry.key_ptr.* = gpa.dupe(u8, source) catch |err| { + store.files.orderedRemoveAt(entry.index); + return err; + }; + } + entry.value_ptr.* = made; + } + + fn forget(store: *Store) void { + for (store.files.keys(), store.files.values()) |path, kept| { + store.gpa.free(path); + store.gpa.free(kept); + } + store.files.clearRetainingCapacity(); + } + + fn read(store: *Store, arena: Allocator, text: []const u8) Allocator.Error!void { + var by_file: std.StringArrayHashMapUnmanaged(std.ArrayList(usize)) = .empty; + for (try ini.parse(arena, text)) |entry| { + const line = std.fmt.parseInt(u32, entry.key, 10) catch continue; + if (line == 0 or !std.ascii.eqlIgnoreCase(entry.value, line_kind)) continue; + const found = try by_file.getOrPutValue(arena, entry.section, .empty); + try found.value_ptr.append(arena, line - 1); + } + for (by_file.keys(), by_file.values()) |source, kept| try store.set(source, kept.items); + } + + fn write(store: *const Store, out: *Io.Writer) Io.Writer.Error!void { + try out.writeAll("; OpenReliant Editor's breakpoints, by source file, each on its line counted from 1.\n"); + for (store.files.keys(), store.files.values()) |source, kept| { + try out.print("\n[{s}]\n", .{source}); + for (kept) |line| try out.print("{d}={s}\n", .{ line + 1, line_kind }); + } + } + + /// Saves the breakpoints. A failure is logged: the editor works on without them. + fn save(store: *const Store, io: Io) void { + const path = store.path orelse return; + var text: Io.Writer.Allocating = .init(store.gpa); + defer text.deinit(); + store.write(&text.writer) catch return; + Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = text.written() }) catch |err| { + std.log.warn("can't save {s}: {t}", .{ path, err }); + }; + } +}; + +/// What a line of `breakpoints.ini` says of a breakpoint: that it holds on its line. Breakpoints +/// with conditions, counts and messages come with stage 2 of the design. +const line_kind = "line"; + +test place { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const source = game.starlancer.first_mission; + const mission = (try game.starlancer.profile.build(arena.allocator(), source)).mission; + const outline = try game.starlancer.profile.outline(arena.allocator(), source); + // The Sabres' attack holds on its own line, 46 counting from 1. + try std.testing.expectEqual(45, place(mission, outline, 45).?.at.line); + // Its trigger's `return 1` and `end` can't hold, and nothing after them in the trigger does. + try std.testing.expectEqual(null, place(mission, outline, 46)); + try std.testing.expectEqual(null, place(mission, outline, 47)); + // A blank line between records takes the next record's first statement. + try std.testing.expectEqual(52, place(mission, outline, 48).?.at.line); + const found = try offsets(arena.allocator(), mission, outline, &.{ 52, 45, 48, 46 }); + try std.testing.expectEqual(2, found.len); + try std.testing.expect(found[0] < found[1]); +} + +test Store { + const gpa = std.testing.allocator; + var store: Store = .{ .gpa = gpa }; + defer store.deinit(); + try store.set("/mods/a/mission90.mission.luau", &.{ 52, 45 }); + try store.set("/mods/b/mission91.mission.luau", &.{3}); + try std.testing.expectEqualSlices(u32, &.{ 45, 52 }, store.lines("/mods/a/mission90.mission.luau")); + try std.testing.expectEqual(3, store.count().breakpoints); + // They read back as written, a file's lines counted from 1. + var text: Io.Writer.Allocating = .init(gpa); + defer text.deinit(); + try store.write(&text.writer); + try std.testing.expect(std.mem.find(u8, text.written(), "[/mods/a/mission90.mission.luau]\n46=line\n53=line\n") != null); + var again: Store = .{ .gpa = gpa }; + defer again.deinit(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + try again.read(arena.allocator(), text.written()); + try std.testing.expectEqualSlices(u32, &.{3}, again.lines("/mods/b/mission91.mission.luau")); + // A file without breakpoints is forgotten. + try again.set("/mods/b/mission91.mission.luau", &.{}); + try std.testing.expectEqual(1, again.count().files); + try std.testing.expectEqual(0, again.lines("/mods/c.mission.luau").len); +} diff --git a/src/debug/session.zig b/src/debug/session.zig new file mode 100644 index 0000000..c047517 --- /dev/null +++ b/src/debug/session.zig @@ -0,0 +1,775 @@ +//! The debugging session (design/debugger.md): the editor's side of the editor link while it +//! waits for a game or is linked to one. It takes the game's messages from the connection, matches +//! the mission the game plays with the open mission's build, keeps the game's breakpoints the +//! editor's, keeps where the script is held and the script's state, and runs the debugger's +//! actions: Continue, the steps, Run to cursor, Pause and Unlink. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const openreliant = @import("openreliant"); +const link = openreliant.engine.link; + +const game = @import("../game.zig"); +const connection_mod = @import("../link/connection.zig"); +const Connection = connection_mod.Connection; +const MessageKind = @import("../toasts.zig").Kind; + +pub const LinkState = enum { + /// No game linked, and none waited for. + off, + /// Connecting to a game as it starts, or waiting for one to listen. + waiting, + /// Linked: the game greeted the editor. + linked, +}; + +/// Whether the mission the game plays is the open mission's build. +pub const Match = enum { + /// The game plays the open mission built from its text as it is: the breakpoints hold. + this_build, + /// The game plays the open mission, built from other text: the next attempt reads the new + /// build. + older_build, + /// The game plays another mission. + other_mission, + /// Not known yet: the game hasn't sent the mission's file, or the text doesn't build. + unknown, +}; + +/// The open mission, as the session aims at it (`Session.aim`). +pub const Target = struct { + /// Its number; null for none, or a mission without one. + number: ?u32, + /// The build of its text as it is, with the map of its script's statements; null where the + /// text doesn't build. + mission: ?game.Mission, + /// Where its breakpoints hold the script (`breakpoints.offsets`). + offsets: []const u32, +}; + +/// The game's greeting. +pub const Greeting = struct { + /// The game's name, its script's dialect and OpenReliant's version, in `text`. + game: []const u8, + vm: []const u8, + openreliant: []const u8, + /// The text, the session's. + text: []const u8, +}; + +/// The mission the game plays. +pub const Playing = struct { + number: u32, + /// Its file's name, such as `mission90.dte`, the session's. + file: []const u8, + /// Its file as the game sent it, the session's; null until it comes. + image: ?[]const u8 = null, + match: Match = .unknown, +}; + +/// Where the game holds the script. +pub const Held = struct { + /// The offset in the script of the instruction it holds before. + offset: u32, +}; + +/// The script's state as the game sent it, with its text, in an arena of its own. +pub const Snapshot = struct { + arena: std.heap.ArenaAllocator, + state: link.State, + + /// The state in `payload`, which it copies; null where it can't be read. + fn read(gpa: Allocator, payload: []const u8) ?Snapshot { + var arena: std.heap.ArenaAllocator = .init(gpa); + // The state's names are its text's. + const text = arena.allocator().dupe(u8, payload) catch { + arena.deinit(); + return null; + }; + const state = link.State.read(arena.allocator(), text) catch { + arena.deinit(); + return null; + }; + return .{ .arena = arena, .state = state }; + } + + fn free(snapshot: *?Snapshot) void { + if (snapshot.*) |*kept| kept.arena.deinit(); + snapshot.* = null; + } +}; + +/// A line for Output. +pub const Note = struct { + kind: MessageKind, + text: []const u8, +}; + +/// How often the session asks for the state while the script runs. +pub const StateRate = enum { + off, + one_hz, + four_hz, + + /// The time between two asks, in milliseconds; null for never. + pub fn interval(rate: StateRate) ?u64 { + return switch (rate) { + .off => null, + .one_hz => 1000, + .four_hz => 250, + }; + } +}; + +pub const Session = struct { + gpa: Allocator, + profile: *const game.Profile, + connection: Connection, + link_state: LinkState = .off, + /// The port the session connects to. + port: u16 = link.default_port, + greeting: ?Greeting = null, + playing: ?Playing = null, + held: ?Held = null, + /// Whether the editor paused the mission (run control 0). + paused: bool = false, + first_finished: bool = false, + /// The script's state as the game last sent it. + state: ?Snapshot = null, + /// The state at the last hold, and at the hold before it, for the values that changed since + /// the last hold; and whether the next state is a new hold's. + hold_state: ?Snapshot = null, + earlier: ?Snapshot = null, + hold_state_due: bool = false, + /// When the editor last asked for the state, and whether an answer is due. + asked_ms: u64 = 0, + asking: bool = false, + /// The offset that Run to cursor flags until the next hold. + run_to: ?u32 = null, + /// The open mission (`aim`): its number, the build of its text and its breakpoints' offsets, + /// the session's. + target_number: ?u32 = null, + target_file: ?[]const u8 = null, + target_statements: []const game.Statement = &.{}, + target_offsets: []const u32 = &.{}, + /// The generation of the flags armed for the task to answer the next mission's file with. + armed: u64 = 0, + /// The lines for Output that the editor hasn't taken (`takeNotes`), in their arena. + notes: std.ArrayList(Note) = .empty, + notes_arena: std.heap.ArenaAllocator, + /// Set as the game newly holds the script, for the editor to show the line. + revealed: bool = false, + + pub fn init(gpa: Allocator, io: Io, profile: *const game.Profile, wake: *const fn () void) Session { + return .{ + .gpa = gpa, + .profile = profile, + .connection = .init(gpa, io, profile, wake), + .notes_arena = .init(gpa), + }; + } + + pub fn deinit(session: *Session) void { + session.connection.deinit(); + session.forgetLink(); + session.freeTarget(); + session.notes_arena.deinit(); + } + + // The link. + + /// Connects to the game at `port`, with `patience`: while it starts, or until it listens. + pub fn connect(session: *Session, port: u16, patience: connection_mod.Patience) void { + session.connection.close(); + session.forgetLink(); + session.port = port; + session.connection.open(port, patience) catch |err| { + session.note(.failure, "Can't link: {t}", .{err}); + return; + }; + session.link_state = .waiting; + session.armTask(); + } + + /// Unlinks: the link closes, and the game lets the script run on. The breakpoints stay in the + /// editor. + pub fn unlink(session: *Session) void { + if (session.link_state == .off) return; + const was_linked = session.link_state == .linked; + session.connection.close(); + session.forgetLink(); + session.note(.info, "{s}", .{if (was_linked) "Unlinked" else "Stopped waiting"}); + } + + /// Takes what the connection heard, and asks for the state while the script runs every + /// `rate`, `now_ms` being the time in milliseconds. + pub fn update(session: *Session, now_ms: u64, rate: StateRate) void { + while (session.connection.next()) |event| { + defer session.connection.release(event); + session.handle(event, now_ms); + } + if (session.held == null and session.playing != null and session.link_state == .linked and !session.asking) { + if (rate.interval()) |interval| if (now_ms -% session.asked_ms >= interval) session.askState(now_ms); + } + } + + fn handle(session: *Session, event: connection_mod.Event, now_ms: u64) void { + switch (event) { + .connected => session.note(.info, "Connected · 127.0.0.1:{d}", .{session.port}), + .gave_up => { + session.forgetLink(); + session.note(.failure, "No game at 127.0.0.1:{d}", .{session.port}); + }, + .disconnected => { + session.forgetLink(); + session.note(.info, "Game closed · unlinked", .{}); + }, + .message => |message| session.receive(message, now_ms), + } + } + + fn receive(session: *Session, message: connection_mod.Event.Message, now_ms: u64) void { + const tags = session.profile.link; + if (link.Own.of(message.tag)) |own| return switch (own) { + .hello => session.greet(message.payload), + .mission_started => session.missionStarted(message.payload), + .mission_file => session.missionFile(message.payload, message.answered), + .mission_ended => { + if (session.playing) |playing| session.note(.info, "Mission {d} ended", .{playing.number}); + session.forgetMission(); + }, + .state => session.takeState(message.payload), + // The editor's own requests, which the game doesn't send. + .get_mission, .get_state, _ => {}, + }; + if (message.tag == tags.stopped and message.payload.len >= @sizeOf(u32)) { + session.hold(std.mem.readInt(u32, message.payload[0..4], .little), now_ms); + } else if (message.tag == tags.first_finished and message.payload.len >= @sizeOf(u32)) { + session.first_finished = std.mem.readInt(u32, message.payload[0..4], .little) != 0; + } + } + + fn greet(session: *Session, payload: []const u8) void { + const hello = link.Hello.read(payload) catch { + session.note(.failure, "Unreadable Hello · unlinked", .{}); + return session.closeLink(); + }; + if (game.named(hello.game.name) != session.profile) { + session.note(.failure, "No profile for {s} · unlinked", .{hello.game.name}); + return session.closeLink(); + } + if (hello.protocol != link.protocol_version) { + session.note(.failure, "Link protocol {d}, editor's {d}", .{ hello.protocol, link.protocol_version }); + } + const text = std.mem.concat(session.gpa, u8, &.{ hello.game.name, hello.game.vm, hello.openreliant }) catch return; + const vm_end = hello.game.name.len + hello.game.vm.len; + const greeting: Greeting = .{ .game = text[0..hello.game.name.len], .vm = text[hello.game.name.len..vm_end], .openreliant = text[vm_end..], .text = text }; + session.freeGreeting(); + session.greeting = greeting; + session.link_state = .linked; + session.note(.success, "Linked · {s} · OpenReliant {s}", .{ greeting.game, greeting.openreliant }); + } + + fn missionStarted(session: *Session, payload: []const u8) void { + session.forgetMission(); + const started = link.MissionStarted.read(payload) catch return; + const file = session.gpa.dupe(u8, started.file) catch return; + session.playing = .{ .number = started.number, .file = file }; + session.note(.info, "Mission {d} started · {s}", .{ started.number, file }); + } + + /// The mission's file, which the task answered with the flags armed for it, `answered` their + /// generation. Where the game has the mission's build and other flags than the latest, it's + /// sent the latest. + fn missionFile(session: *Session, file: []const u8, answered: ?u64) void { + const playing = &(session.playing orelse return); + const image = session.gpa.dupe(u8, file) catch return; + if (playing.image) |old| session.gpa.free(old); + playing.image = image; + session.matchMission(); + switch (playing.match) { + .this_build => { + if (answered != session.armed) session.sendFlags(); + session.note(.info, "Mission {d} · this build · breakpoints set", .{playing.number}); + }, + .older_build => session.note(.info, "Mission {d} · older build · rebuild, restart mission", .{playing.number}), + .other_mission => session.note(.info, "Mission {d} · other mission · breakpoints wait", .{playing.number}), + .unknown => {}, + } + } + + fn hold(session: *Session, offset: u32, now_ms: u64) void { + session.held = .{ .offset = offset }; + session.revealed = true; + session.hold_state_due = true; + if (session.run_to != null) { + session.run_to = null; + session.sendFlags(); + } + session.askState(now_ms); + if (session.heldStatement()) |statement| { + session.note(.info, "Held · line {d} · offset {d}", .{ statement.at.line + 1, offset }); + } else session.note(.info, "Held · offset {d}", .{offset}); + } + + fn takeState(session: *Session, payload: []const u8) void { + session.asking = false; + const fresh = Snapshot.read(session.gpa, payload) orelse return; + if (session.hold_state_due) { + // The first state of a new hold: the last hold's becomes the one before. + session.hold_state_due = false; + if (Snapshot.read(session.gpa, payload)) |copy| { + Snapshot.free(&session.earlier); + session.earlier = session.hold_state; + session.hold_state = copy; + } + } + Snapshot.free(&session.state); + session.state = fresh; + session.paused = fresh.state.paused; + session.first_finished = fresh.state.first_finished; + } + + /// Asks the game for the script's state. + fn askState(session: *Session, now_ms: u64) void { + if (!session.connection.send(@backingInt(link.Own.get_state), "")) return; + session.asking = true; + session.asked_ms = now_ms; + } + + // The open mission. + + /// Aims the session at `target`, the open mission: the task answers its file with its + /// breakpoints, and a game that plays its build gets them now. + pub fn aim(session: *Session, target: Target) void { + session.freeTarget(); + session.target_number = target.number; + if (target.mission) |mission| { + session.target_file = session.gpa.dupe(u8, mission.file) catch null; + session.target_statements = session.gpa.dupe(game.Statement, mission.statements) catch &.{}; + } + session.target_offsets = session.gpa.dupe(u32, target.offsets) catch &.{}; + session.armTask(); + const was = if (session.playing) |playing| playing.match else .unknown; + session.matchMission(); + const now = if (session.playing) |playing| playing.match else .unknown; + if (now == .this_build) session.sendFlags(); + if (was == .this_build and now != .this_build) session.note(.info, "Script changed · rebuild, restart mission", .{}); + } + + /// Sets what the task answers the next mission's file with. + fn armTask(session: *Session) void { + if (session.link_state == .off) return; + session.armed = session.connection.arm(session.target_number, session.target_file orelse &.{}, session.target_offsets) catch return; + } + + fn matchMission(session: *Session) void { + const playing = &(session.playing orelse return); + playing.match = match: { + const image = playing.image orelse break :match .unknown; + const number = session.target_number orelse break :match .other_mission; + if (number != playing.number) break :match .other_mission; + const built = session.target_file orelse break :match .unknown; + break :match if (session.profile.sameScript(built, image)) .this_build else .older_build; + }; + } + + /// Sends the game the breakpoints' flags, with Run to cursor's, where it plays the open + /// mission's build. + fn sendFlags(session: *Session) void { + const playing = session.playing orelse return; + if (playing.match != .this_build) return; + const image = playing.image orelse return; + var arena: std.heap.ArenaAllocator = .init(session.gpa); + defer arena.deinit(); + var offsets: std.ArrayList(u32) = .empty; + offsets.appendSlice(arena.allocator(), session.target_offsets) catch return; + if (session.run_to) |offset| offsets.append(arena.allocator(), offset) catch return; + const flags = session.profile.scriptFlags(arena.allocator(), image, offsets.items) catch return orelse return; + _ = session.connection.send(session.profile.link.script_flags, flags); + } + + // The actions. + + /// Lets the script go on with `control`: on to the next breakpoint, or a step. + pub fn release(session: *Session, control: game.Control) void { + if (!session.sendControl(control)) return; + session.held = null; + } + + /// Lets the held script run on to `offset`, which holds it until the next hold. + pub fn runTo(session: *Session, offset: u32) void { + session.run_to = offset; + session.sendFlags(); + session.release(.run_on); + } + + /// Pauses the whole mission, or lets it go on. + pub fn togglePause(session: *Session) void { + if (!session.sendControl(.{ .pause = !session.paused })) return; + session.paused = !session.paused; + session.note(.info, "{s}", .{if (session.paused) "Mission paused" else "Mission resumed"}); + } + + fn sendControl(session: *Session, control: game.Control) bool { + const payload = session.profile.link.runControl(control); + return session.connection.send(session.profile.link.run_control, &payload); + } + + // What the editor shows. + + /// Whether the game plays a mission, linked. + pub fn playingMission(session: *const Session) bool { + return session.link_state == .linked and session.playing != null; + } + + /// Whether the game plays the open mission's build, whose map gives the lines. + pub fn mapped(session: *const Session) bool { + const playing = session.playing orelse return false; + return playing.match == .this_build; + } + + /// The statement the game holds the script before, where the map gives it. + pub fn heldStatement(session: *const Session) ?game.Statement { + const held = session.held orelse return null; + return session.statementAt(held.offset); + } + + /// The statement at `offset`, where the game plays the open mission's build. + pub fn statementAt(session: *const Session, offset: u32) ?game.Statement { + if (!session.mapped()) return null; + const mission: game.Mission = .{ .file = session.target_file orelse return null, .statements = session.target_statements }; + return mission.statementAt(offset); + } + + /// The line the statement holding `offset` is on, for a thread's place: the statement that + /// ends there, or else the last one before it. + pub fn lineOf(session: *const Session, offset: u32) ?u32 { + if (!session.mapped()) return null; + var line: ?u32 = null; + for (session.target_statements) |statement| { + if (statement.offset > offset) break; + line = statement.at.line; + } + return line; + } + + /// The state the values are compared with, for the ones that changed since the last hold: + /// while held, the hold before; while the script runs, the last hold. + fn lastHoldState(session: *const Session) ?link.State { + const kept = if (session.held != null) session.earlier else session.hold_state; + return if (kept) |snapshot| snapshot.state else null; + } + + /// Whether global `index` changed since the last hold. + pub fn globalChanged(session: *const Session, index: usize) bool { + const now = session.state orelse return false; + const before = session.lastHoldState() orelse return false; + if (index >= now.state.globals.len or index >= before.globals.len) return false; + return now.state.globals[index] != before.globals[index]; + } + + /// Whether the game's variable `name` changed since the last hold. + pub fn variableChanged(session: *const Session, name: []const u8) bool { + const now = session.state orelse return false; + const before = session.lastHoldState() orelse return false; + return (now.state.variable(name) orelse 0) != (before.variable(name) orelse 0); + } + + /// Takes the lines for Output since the last time, valid until the next call. + pub fn takeNotes(session: *Session) []const Note { + const taken = session.notes.items; + session.notes = .empty; + return taken; + } + + /// Lets go of the notes taken. + pub fn freeNotes(session: *Session) void { + _ = session.notes_arena.reset(.retain_capacity); + } + + /// Whether the game newly holds the script since the last call. + pub fn takeRevealed(session: *Session) bool { + defer session.revealed = false; + return session.revealed; + } + + fn note(session: *Session, kind: MessageKind, comptime format: []const u8, args: anytype) void { + const arena = session.notes_arena.allocator(); + const text = std.fmt.allocPrint(arena, format, args) catch return; + session.notes.append(arena, .{ .kind = kind, .text = text }) catch {}; + } + + // Forgetting. + + fn closeLink(session: *Session) void { + session.connection.close(); + session.forgetLink(); + } + + fn forgetLink(session: *Session) void { + session.link_state = .off; + session.freeGreeting(); + session.forgetMission(); + } + + fn forgetMission(session: *Session) void { + if (session.playing) |playing| { + session.gpa.free(playing.file); + if (playing.image) |image| session.gpa.free(image); + } + session.playing = null; + session.held = null; + session.paused = false; + session.first_finished = false; + session.run_to = null; + session.asking = false; + session.hold_state_due = false; + Snapshot.free(&session.state); + Snapshot.free(&session.hold_state); + Snapshot.free(&session.earlier); + } + + fn freeGreeting(session: *Session) void { + const greeting = session.greeting orelse return; + session.gpa.free(greeting.text); + session.greeting = null; + } + + fn freeTarget(session: *Session) void { + if (session.target_file) |file| session.gpa.free(file); + session.gpa.free(session.target_statements); + session.gpa.free(session.target_offsets); + session.target_file = null; + session.target_statements = &.{}; + session.target_offsets = &.{}; + } +}; + +/// A game made of messages, for the tests: it listens on a free port of this computer's own +/// address as OpenReliant does, and writes and reads the link's messages as the game does. +const FakeGame = struct { + io: Io, + listener: Io.net.Server, + stream: ?Io.net.Stream = null, + write_buffer: [4096]u8 = undefined, + read_buffer: [64 * 1024]u8 = undefined, + writer: Io.net.Stream.Writer = undefined, + reader: Io.net.Stream.Reader = undefined, + + fn listen(game_io: Io) !FakeGame { + const address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; + return .{ .io = game_io, .listener = try address.listen(game_io, .{ .reuse_address = true }) }; + } + + fn deinit(fake: *FakeGame) void { + if (fake.stream) |stream| stream.close(fake.io); + fake.listener.deinit(fake.io); + } + + fn port(fake: *const FakeGame) u16 { + return fake.listener.socket.address.getPort(); + } + + /// Takes the editor's connection. The game mustn't move after. + fn accept(fake: *FakeGame) !void { + const stream = try fake.listener.accept(fake.io); + fake.stream = stream; + fake.writer = stream.writer(fake.io, &fake.write_buffer); + fake.reader = stream.reader(fake.io, &fake.read_buffer); + } + + fn send(fake: *FakeGame, tag: u16, payload: []const u8) !void { + try link.writeMessage(&fake.writer.interface, tag, payload); + try fake.writer.interface.flush(); + } + + fn sendRecord(fake: *FakeGame, tag: link.Own, record: anytype) !void { + var buffer: [1024]u8 = undefined; + var text: Io.Writer = .fixed(&buffer); + try record.write(&text); + try fake.send(@backingInt(tag), text.buffered()); + } + + fn sendWord(fake: *FakeGame, tag: u16, word: u32) !void { + var payload: [4]u8 = undefined; + std.mem.writeInt(u32, &payload, word, .little); + try fake.send(tag, &payload); + } + + /// The editor's next message, which the caller frees. + fn hear(fake: *FakeGame, gpa: Allocator) !link.Message { + return link.readMessage(&fake.reader.interface, gpa); + } +}; + +/// Runs the session's frames until `done` says what the test waits for, for two seconds at most. +fn settle(session: *Session, io: Io, comptime done: fn (*const Session) bool) !void { + var frames: u32 = 0; + while (frames < 2000) : (frames += 1) { + session.update(frames, .off); + if (done(session)) return; + try io.sleep(.fromMilliseconds(1), .awake); + } + return error.TestTimedOut; +} + +test "a session with a game made of messages" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const profile = &game.starlancer.profile; + const source = game.starlancer.first_mission; + const mission = (try profile.build(arena, source)).mission; + const outline = try profile.outline(arena, source); + // A breakpoint on the Sabres' attack, line 46 counting from 1. + const attack = mission.statementOn(45).?; + const offsets = try @import("breakpoints.zig").offsets(arena, mission, outline, &.{45}); + + var fake: FakeGame = try .listen(io); + defer fake.deinit(); + var session: Session = .init(gpa, io, profile, connection_mod.testing.noWake); + defer session.deinit(); + session.aim(.{ .number = 90, .mission = mission, .offsets = offsets }); + session.connect(fake.port(), .{ .starting = 5000 }); + try std.testing.expectEqual(LinkState.waiting, session.link_state); + try fake.accept(); + + // The game greets the editor, starts mission 90 and sends its file, as it plays this build. + try fake.sendRecord(.hello, link.Hello{ .protocol = link.protocol_version, .openreliant = "0.15.0", .game = .{ .name = "StarLancer", .vm = "starlancer" } }); + try fake.sendRecord(.mission_started, link.MissionStarted{ .number = 90, .file = "mission90.dte" }); + try fake.send(@backingInt(link.Own.mission_file), mission.file); + + // The editor's task answers the file at once with the breakpoint's flag. + const flags = try fake.hear(gpa); + defer gpa.free(flags.payload); + try std.testing.expectEqual(profile.link.script_flags, flags.tag); + try std.testing.expectEqual(1, std.mem.count(u8, flags.payload, &.{1})); + try std.testing.expectEqual(1, flags.payload[attack.offset]); + + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return s.mapped(); + } + }.done); + try std.testing.expectEqual(LinkState.linked, session.link_state); + try std.testing.expectEqualStrings("0.15.0", session.greeting.?.openreliant); + + // The game holds the script before the attack, and the editor asks for the state. + try fake.sendWord(profile.link.stopped, attack.offset); + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return s.held != null; + } + }.done); + try std.testing.expectEqual(45, session.heldStatement().?.at.line); + try std.testing.expect(session.takeRevealed()); + const asked = try fake.hear(gpa); + defer gpa.free(asked.payload); + try std.testing.expectEqual(@backingInt(link.Own.get_state), asked.tag); + + // The state comes, with the thread held on the attack's line. + const thread: link.State.Thread = .{ .slot = 0, .at = attack.offset, .depth = 0, .wake = 0, .interrupted = false, .trigger = 0 }; + try fake.sendRecord(.state, link.State{ .clock = 41, .hold = .held, .step = .none, .paused = false, .first_finished = true, .threads = &.{thread}, .globals = &.{}, .variables = &.{.{ .name = "mission_success", .value = 0 }} }); + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return s.state != null; + } + }.done); + try std.testing.expectEqual(41, session.state.?.state.clock); + try std.testing.expectEqual(45, session.lineOf(attack.offset).?); + + // Step over sends run control 3, and the script runs on. + session.release(.step_over); + try std.testing.expectEqual(null, session.held); + const step = try fake.hear(gpa); + defer gpa.free(step.payload); + try std.testing.expectEqual(profile.link.run_control, step.tag); + try std.testing.expectEqualSlices(u8, &profile.link.runControl(.step_over), step.payload); + + // A breakpoint taken away goes to the game at once, as flags without it. + session.aim(.{ .number = 90, .mission = mission, .offsets = &.{} }); + const cleared = try fake.hear(gpa); + defer gpa.free(cleared.payload); + try std.testing.expectEqual(profile.link.script_flags, cleared.tag); + try std.testing.expectEqual(0, std.mem.count(u8, cleared.payload, &.{1})); + + // Text that builds another script: the game plays an older build. + const changed = try std.mem.replaceOwned(u8, arena, source, "Wait(5)", "Wait(6)"); + session.aim(.{ .number = 90, .mission = (try profile.build(arena, changed)).mission, .offsets = &.{} }); + try std.testing.expectEqual(Match.older_build, session.playing.?.match); + try std.testing.expectEqual(null, session.statementAt(attack.offset)); + + // Unlinking closes the link. + session.unlink(); + try std.testing.expectEqual(LinkState.off, session.link_state); + try std.testing.expectError(error.EndOfStream, fake.hear(gpa)); + const notes = session.takeNotes(); + try std.testing.expect(notes.len > 0); + session.freeNotes(); +} + +test "the editor answers a mission it has no build of with no flags" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const profile = &game.starlancer.profile; + const mission = (try profile.build(arena, game.starlancer.first_mission)).mission; + + var fake: FakeGame = try .listen(io); + defer fake.deinit(); + var session: Session = .init(gpa, io, profile, connection_mod.testing.noWake); + defer session.deinit(); + // The editor has mission 91 open, and the game plays mission 90. + session.aim(.{ .number = 91, .mission = mission, .offsets = &.{mission.statementOn(45).?.offset} }); + session.connect(fake.port(), .waiting); + try fake.accept(); + try fake.sendRecord(.hello, link.Hello{ .protocol = link.protocol_version, .openreliant = "0.15.0", .game = .{ .name = "StarLancer", .vm = "starlancer" } }); + try fake.sendRecord(.mission_started, link.MissionStarted{ .number = 90, .file = "mission90.dte" }); + try fake.send(@backingInt(link.Own.mission_file), mission.file); + const flags = try fake.hear(gpa); + defer gpa.free(flags.payload); + try std.testing.expectEqual(profile.link.script_flags, flags.tag); + try std.testing.expectEqual(0, std.mem.count(u8, flags.payload, &.{1})); + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return if (s.playing) |playing| playing.match != .unknown else false; + } + }.done); + try std.testing.expectEqual(Match.other_mission, session.playing.?.match); + + // The game goes: the link closes by itself. + fake.stream.?.close(io); + fake.stream = null; + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return s.link_state == .off; + } + }.done); + session.freeNotes(); +} + +test "a game that isn't there: the editor gives up" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // A port that nothing listens at: one the system gave and took back. + var fake: FakeGame = try .listen(io); + const free_port = fake.port(); + fake.deinit(); + var session: Session = .init(gpa, io, &game.starlancer.profile, connection_mod.testing.noWake); + defer session.deinit(); + session.connect(free_port, .{ .starting = 50 }); + try settle(&session, io, struct { + fn done(s: *const Session) bool { + return s.link_state == .off; + } + }.done); + try std.testing.expectEqual(MessageKind.failure, session.takeNotes()[0].kind); + session.freeNotes(); +} diff --git a/src/game.zig b/src/game.zig index f52f25d..e56cdfa 100644 --- a/src/game.zig +++ b/src/game.zig @@ -33,7 +33,7 @@ pub const Profile = struct { source_extension: []const u8, /// The words of mission source, for colours, completion and help. Made once by `language`. language: *const fn (arena: Allocator) Allocator.Error!Language, - /// Builds mission source into a mission file. + /// Builds mission source into a mission file, with the map of its script's statements. build: *const fn (arena: Allocator, text: []const u8) Allocator.Error!Built, /// The records the source declares, for the outline, with the errors Luau's parser finds. outline: *const fn (arena: Allocator, text: []const u8) Allocator.Error!Outline, @@ -57,6 +57,45 @@ pub const Profile = struct { /// How mission `b` differs from mission `a`, in a few words; null where they're the same /// mission, as the game plays them. difference: *const fn (arena: Allocator, a: []const u8, b: []const u8) ReadError!?[]const u8, + /// Whether missions `a` and `b` have the same script, with the same routines for the same + /// parts and triggers, so that a map of `a`'s statements holds for `b`. Their globals' values + /// can differ, as a game's mission file gives them as they stand. + sameScript: *const fn (a: []const u8, b: []const u8) bool, + /// The script's flags for `mission`, as its script VM reads them: a byte for each byte of the + /// script, with the flag that holds the script set on each of `offsets`. Null for a file that + /// isn't a mission the profile can read. + scriptFlags: *const fn (arena: Allocator, mission: []const u8, offsets: []const u32) Allocator.Error!?[]u8, + /// The game's own messages on the editor link. + link: Link, +}; + +/// The game's own messages on the editor link, beside OpenReliant's, by the tags its table gives +/// them: StarLancer's are OpenReliant's `engine.game.mission.editor`. +pub const Link = struct { + /// The script's flags, from the editor (`Profile.scriptFlags`). + script_flags: u16, + /// Run control, from the editor (`runControl`). + run_control: u16, + /// A thread stopped, from the game: the offset in the script it stopped before. + stopped: u16, + /// Whether the thread pool's first thread has finished, from the game. + first_finished: u16, + /// The payload of run control that asks for `control`. + runControl: *const fn (control: Control) [8]u8, +}; + +/// What the editor asks of the game's script with run control. +pub const Control = union(enum) { + /// Pauses the whole mission, or lets it go on. + pause: bool, + /// Lets the held script run on to the next breakpoint. + run_on, + /// Runs to the next statement any thread runs. + step_into, + /// Runs to the next statement of the same block, past the parts it calls. + step_over, + /// Runs a part at once, on a new thread, by its index. + run_part: u32, }; pub const ReadError = Allocator.Error || error{ @@ -95,6 +134,8 @@ pub const Play = struct { skip_launch: bool = false, /// The ship to watch, by its name. watch: ?[]const u8 = null, + /// The port the game's editor link listens at, for the editor to link to it; null for none. + link: ?u16 = null, }; /// A place in a file, counting lines and columns from 0. @@ -105,12 +146,56 @@ pub const Place = struct { /// What a build of mission source made. pub const Built = union(enum) { - /// The mission file. - mission: []const u8, + mission: Mission, /// Why there's none. problems: []const Problem, }; +/// A mission file built from source, with the map of its script's statements to the source. +pub const Mission = struct { + file: []const u8, + /// Each statement of the script, in the order of their offsets. + statements: []const Statement, + + /// The statement that ends at `offset`, the offset of a stop, if one does. + pub fn statementAt(mission: Mission, offset: u32) ?Statement { + const index = std.sort.binarySearch(Statement, mission.statements, offset, Statement.order) orelse return null; + return mission.statements[index]; + } + + /// Where a breakpoint on `line` holds the script: the first statement it can hold before on + /// that line, or on the next line that has one, up to `last_line`. Null where none does. + pub fn breakpointFor(mission: Mission, line: u32, last_line: u32) ?Statement { + var best: ?Statement = null; + for (mission.statements) |statement| { + if (!statement.stops or statement.at.line < line or statement.at.line > last_line) continue; + const better = if (best) |found| statement.at.line < found.at.line else true; + if (better) best = statement; + } + return best; + } + + /// The first statement the script can hold before on `line`. + pub fn statementOn(mission: Mission, line: u32) ?Statement { + return mission.breakpointFor(line, line); + } +}; + +/// A statement of a built mission's script. +pub const Statement = struct { + /// The offset in the script of the instruction it ends with, which the script holds before, as + /// the editor link gives a stop. + offset: u32, + /// Where it starts in the source. + at: Place, + /// Whether the script can hold before it: it can't before a `return`. + stops: bool, + + fn order(offset: u32, statement: Statement) std.math.Order { + return std.math.order(offset, statement.offset); + } +}; + pub const Problem = struct { at: Place, /// What's wrong, as OpenReliant says it. Names in it are in backticks. @@ -352,6 +437,25 @@ fn editDistance(a: []const u8, b: []const u8) usize { return previous[b.len]; } +test Mission { + const statements = [_]Statement{ + .{ .offset = 10, .at = .{ .line = 4, .column = 4 }, .stops = true }, + .{ .offset = 14, .at = .{ .line = 5, .column = 4 }, .stops = false }, + .{ .offset = 20, .at = .{ .line = 8, .column = 4 }, .stops = true }, + .{ .offset = 26, .at = .{ .line = 8, .column = 20 }, .stops = true }, + }; + const built: Mission = .{ .file = "", .statements = &statements }; + try std.testing.expectEqual(8, built.statementAt(20).?.at.line); + try std.testing.expectEqual(null, built.statementAt(12)); + // A breakpoint on a line that holds goes there, one on a `return` or a blank line to the next + // line that holds, its first statement, but no further than the line given. + try std.testing.expectEqual(10, built.breakpointFor(4, 9).?.offset); + try std.testing.expectEqual(20, built.breakpointFor(5, 9).?.offset); + try std.testing.expectEqual(null, built.breakpointFor(5, 7)); + try std.testing.expectEqual(null, built.statementOn(5)); + try std.testing.expectEqual(20, built.statementOn(8).?.offset); +} + test editDistance { try std.testing.expectEqual(3, editDistance("enemy", "enemies")); try std.testing.expectEqual(0, editDistance("nav", "nav")); diff --git a/src/game/starlancer.zig b/src/game/starlancer.zig index d941c75..f673ce4 100644 --- a/src/game/starlancer.zig +++ b/src/game/starlancer.zig @@ -14,6 +14,7 @@ const syntax = source.syntax; const commands = openreliant.engine.game.executor.commands; const conditions = openreliant.engine.vm.conditions; const vm = openreliant.engine.vm; +const mission_editor = openreliant.engine.game.mission.editor; const game = @import("../game.zig"); @@ -36,6 +37,15 @@ pub const profile: game.Profile = .{ .readGameMission = readGameMission, .decompile = decompile, .difference = difference, + .sameScript = sameScript, + .scriptFlags = scriptFlags, + .link = .{ + .script_flags = mission_editor.tagNamed("script_flags"), + .run_control = mission_editor.tagNamed("run_control"), + .stopped = @backingInt(mission_editor.Sent.stopped), + .first_finished = @backingInt(mission_editor.Sent.first_finished), + .runControl = runControl, + }, }; const bigfile = openreliant.engine.game.bigfile; @@ -115,6 +125,51 @@ fn difference(arena: Allocator, a: []const u8, b: []const u8) game.ReadError!?[] }; } +fn sameScript(a: []const u8, b: []const u8) bool { + const first = dte.Mission.parse(a) catch return false; + const second = dte.Mission.parse(b) catch return false; + const first_script = first.script() catch return false; + const second_script = second.script() catch return false; + if (!std.mem.eql(u8, first_script, second_script)) return false; + const first_parts = first.parts() catch return false; + const second_parts = second.parts() catch return false; + if (first_parts.len != second_parts.len) return false; + for (first_parts, second_parts) |one, other| if (one.block() != other.block()) return false; + const first_triggers = first.triggers() catch return false; + const second_triggers = second.triggers() catch return false; + if (first_triggers.len != second_triggers.len) return false; + for (first_triggers, second_triggers) |one, other| if (one.block() != other.block()) return false; + return true; +} + +/// The flags the editor link copies over the mission's section 10, as many bytes as +/// `dte.Mission.scriptFlagsSize` gives, with bit 0 set on the bytes the script holds before +/// (`vm_run`, docs/engine/editor-link.md in OpenReliant). An offset past the script is left out. +fn scriptFlags(arena: Allocator, image: []const u8, offsets: []const u32) Allocator.Error!?[]u8 { + const mission = dte.Mission.parse(image) catch return null; + const flags = try arena.alloc(u8, mission.scriptFlagsSize()); + @memset(flags, 0); + for (offsets) |offset| { + if (offset < flags.len) flags[offset] = holds; + } + return flags; +} + +/// The flag of a byte the script holds before. +const holds = 1; + +fn runControl(control: game.Control) [8]u8 { + const RunControl = mission_editor.RunControl; + const payload: RunControl = switch (control) { + .pause => |on| .{ .command = .pause, .argument = @intFromBool(on) }, + .run_on => .{ .command = .run_on, .argument = 0 }, + .step_into => .{ .command = .step_into, .argument = 0 }, + .step_over => .{ .command = .step_over, .argument = 0 }, + .run_part => |part| .{ .command = .run_part, .argument = part }, + }; + return std.mem.toBytes(payload); +} + /// Luau's keywords, the contextual ones too, which mission source is written in. const luau_keywords = [_][]const u8{ "and", "break", "continue", "do", "else", "elseif", "end", "export", @@ -213,7 +268,7 @@ fn watches(arena: Allocator, condition: conditions.Condition) Allocator.Error![] fn build(arena: Allocator, text: []const u8) Allocator.Error!game.Built { var failure: source.compile.Failure = undefined; - const made = source_parser.build(arena, text, &failure) catch |err| switch (err) { + const made = source_parser.buildMapped(arena, text, &failure) catch |err| switch (err) { error.OutOfMemory => |oom| return oom, error.Invalid => { const problems = try arena.alloc(game.Problem, 1); @@ -221,7 +276,13 @@ fn build(arena: Allocator, text: []const u8) Allocator.Error!game.Built { return .{ .problems = problems }; }, }; - return .{ .mission = made }; + const statements = try arena.alloc(game.Statement, made.statements.len); + for (statements, made.statements) |*statement, from| statement.* = .{ + .offset = from.offset, + .at = .{ .line = from.at.line, .column = from.at.column }, + .stops = from.stops, + }; + return .{ .mission = .{ .file = made.file, .statements = statements } }; } /// A problem from OpenReliant's message, with the kind of mistake it is where the editor can offer @@ -611,14 +672,15 @@ fn newMission(arena: Allocator, name: []const u8) Allocator.Error![]const u8 { , .{name}); } -/// `openreliant --mission `, with `--part`, `--skip-launch` and `--watch` -/// for the options asked for. +/// `openreliant --mission `, with `--part`, `--skip-launch`, `--watch` and +/// `--editor-link` for the options asked for. fn playArguments(arena: Allocator, game_folder: []const u8, number: u32, play: game.Play) Allocator.Error![]const []const u8 { var arguments: std.ArrayList([]const u8) = .empty; try arguments.appendSlice(arena, &.{ game_folder, "--mission", try arena.print("{d}", .{number}) }); if (play.part) |part| try arguments.appendSlice(arena, &.{ "--part", part }); if (play.skip_launch) try arguments.append(arena, "--skip-launch"); if (play.watch) |ship| try arguments.appendSlice(arena, &.{ "--watch", ship }); + if (play.link) |port| try arguments.appendSlice(arena, &.{ "--editor-link", "--editor-link-port", try arena.print("{d}", .{port}) }); return arguments.toOwnedSlice(arena); } @@ -743,7 +805,57 @@ test "a build of the first mission makes a mission whose listing reads" { defer arena.deinit(); const built = try build(arena.allocator(), first_mission); const mission = built.mission; - try std.testing.expect(std.mem.find(u8, try listing(arena.allocator(), mission), "routines") != null); + try std.testing.expect(std.mem.find(u8, try listing(arena.allocator(), mission.file), "routines") != null); +} + +test "the first mission's statements hold on their lines" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const mission = (try build(arena.allocator(), first_mission)).mission; + const script = try (try dte.Mission.parse(mission.file)).script(); + // The design's held line, the Sabres' attack on line 46 counting from 1, holds before its + // command. + const attack = mission.statementOn(45).?; + try std.testing.expectEqual(dte.Opcode.command, @as(dte.Opcode, @fromBackingInt(script[attack.offset]))); + try std.testing.expectEqual(attack, mission.statementAt(attack.offset).?); + // Its trigger's `return 1` can't hold, so a breakpoint there moves on, to the next trigger. + try std.testing.expectEqual(null, mission.statementOn(46)); + try std.testing.expectEqual(52, mission.breakpointFor(46, 100).?.at.line); +} + +test scriptFlags { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const mission = (try build(arena.allocator(), first_mission)).mission; + const attack = mission.statementOn(45).?.offset; + const flags = (try scriptFlags(arena.allocator(), mission.file, &.{ attack, 1_000_000 })).?; + const parsed = try dte.Mission.parse(mission.file); + try std.testing.expectEqual(parsed.scriptFlagsSize(), flags.len); + try std.testing.expectEqual(1, std.mem.count(u8, flags, &.{holds})); + try std.testing.expectEqual(holds, flags[attack]); + try std.testing.expectEqual(null, try scriptFlags(arena.allocator(), "not a mission", &.{})); +} + +test sameScript { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const built = (try build(arena.allocator(), first_mission)).mission.file; + // A comment changes nothing the game runs; another statement does. + const commented = try std.mem.concat(arena.allocator(), u8, &.{ "-- A comment.\n", first_mission }); + try std.testing.expect(sameScript(built, (try build(arena.allocator(), commented)).mission.file)); + const changed = try std.mem.replaceOwned(u8, arena.allocator(), first_mission, "Wait(5)", "Wait(6)"); + try std.testing.expect(!sameScript(built, (try build(arena.allocator(), changed)).mission.file)); + try std.testing.expect(!sameScript(built, "not a mission")); +} + +test runControl { + const RunControl = mission_editor.RunControl; + const step = std.mem.bytesToValue(RunControl, &runControl(.step_over)); + try std.testing.expectEqual(RunControl.Command.step_over, step.command); + const paused = std.mem.bytesToValue(RunControl, &runControl(.{ .pause = true })); + try std.testing.expectEqual(RunControl.Command.pause, paused.command); + try std.testing.expectEqual(1, paused.argument); + try std.testing.expectEqual(7, std.mem.bytesToValue(RunControl, &runControl(.{ .run_part = 7 })).argument); } test "a name that isn't declared is a mistake the editor can fix" { @@ -785,8 +897,8 @@ test "a new mission builds" { test playArguments { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); - const arguments = try playArguments(arena.allocator(), "StarLancer", 90, .{ .part = "(F)Start", .skip_launch = true }); - const expected = [_][]const u8{ "StarLancer", "--mission", "90", "--part", "(F)Start", "--skip-launch" }; + const arguments = try playArguments(arena.allocator(), "StarLancer", 90, .{ .part = "(F)Start", .skip_launch = true, .link = 4000 }); + const expected = [_][]const u8{ "StarLancer", "--mission", "90", "--part", "(F)Start", "--skip-launch", "--editor-link", "--editor-link-port", "4000" }; try std.testing.expectEqual(expected.len, arguments.len); for (expected, arguments) |want, got| try std.testing.expectEqualStrings(want, got); } @@ -794,9 +906,9 @@ test playArguments { test "a built mission decompiles to source that builds the same mission" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); - const built = (try build(arena.allocator(), first_mission)).mission; + const built = (try build(arena.allocator(), first_mission)).mission.file; const text = try decompile(arena.allocator(), built); - const again = (try build(arena.allocator(), text)).mission; + const again = (try build(arena.allocator(), text)).mission.file; try std.testing.expectEqual(null, try difference(arena.allocator(), built, again)); } diff --git a/src/link/connection.zig b/src/link/connection.zig new file mode 100644 index 0000000..8319d3c --- /dev/null +++ b/src/link/connection.zig @@ -0,0 +1,260 @@ +//! The editor link's connection to OpenReliant (design/debugger.md): a TCP connection to a game +//! started with `--editor-link`, on this computer's own address. A task of its own connects, +//! trying again while the game starts, and reads the game's messages into a queue that the frame +//! takes from. The task answers the mission's file itself with the breakpoints' flags, as the frame +//! last armed them, since the game holds the mission's start until they come: a busy frame, or a +//! window waiting for events, never holds up the game. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const openreliant = @import("openreliant"); +const link = openreliant.engine.link; + +const game = @import("../game.zig"); + +/// How long the task waits before it tries to connect again. +const retry_ms = 100; + +/// How many bytes a read or a write of the connection takes at most. +const read_size = 16 * 1024; +const write_size = 4096; + +/// How long the connection keeps trying to connect. +pub const Patience = union(enum) { + /// For a game being started: this many milliseconds. + starting: i64, + /// Until a game started some other way listens, or the editor lets it go. + waiting, +}; + +/// What the task heard, in order, for the frame. +pub const Event = union(enum) { + /// A game took the connection. + connected, + /// A message from the game. Its payload is the connection's until the frame lets it go + /// (`Connection.release`). + message: Message, + /// The connection ended: the game went. + disconnected, + /// No game took the connection in time. + gave_up, + + pub const Message = struct { + tag: u16, + payload: []const u8, + /// For a mission's file, the generation of the flags the task answered it with (`arm`), + /// where they were armed for it. + answered: ?u64 = null, + }; +}; + +pub const Connection = struct { + gpa: Allocator, + io: Io, + profile: *const game.Profile, + /// Called from the task as an event comes, to wake a window that waits for events. + wake: *const fn () void, + /// Guards what follows, which the task and the frame share. + mutex: Io.Mutex = .init, + /// The game's connection; null while none is. + stream: ?Io.net.Stream = null, + /// What the task heard that the frame hasn't taken. + events: std.ArrayList(Event) = .empty, + /// Set as the editor lets the connection go, which ends the task. + stopping: bool = false, + /// The flags the task answers the mission's file with. + armed: Armed = .{}, + /// Held while a message is written, so that the task's and the frame's messages don't mix, + /// and the task waits for it before it closes the connection. + writing: Io.Mutex = .init, + task: Io.Group = .init, + + /// What the task answers the mission's file with: the flags of `offsets` where the game plays + /// mission `number` with the script of `mission`, and no flags otherwise. + const Armed = struct { + number: ?u32 = null, + mission: []u8 = &.{}, + offsets: []u32 = &.{}, + /// Counts the times the frame armed the task, so that the frame can tell whether the game + /// has the flags it armed last. + generation: u64 = 0, + }; + + pub fn init(gpa: Allocator, io: Io, profile: *const game.Profile, wake: *const fn () void) Connection { + return .{ .gpa = gpa, .io = io, .profile = profile, .wake = wake }; + } + + pub fn deinit(connection: *Connection) void { + connection.close(); + connection.gpa.free(connection.armed.mission); + connection.gpa.free(connection.armed.offsets); + } + + /// Starts the task, which connects to a game at `port` with `patience`. The connection mustn't + /// move while it runs. + pub fn open(connection: *Connection, port: u16, patience: Patience) Io.ConcurrentError!void { + connection.close(); + connection.stopping = false; + try connection.task.concurrent(connection.io, run, .{ connection, port, patience }); + } + + /// Ends the task and the connection, and forgets what the frame hasn't taken. The game's + /// connection shut down ends the task's read, and its next try to connect finds it stopping. + pub fn close(connection: *Connection) void { + const io = connection.io; + connection.mutex.lockUncancelable(io); + connection.stopping = true; + if (connection.stream) |stream| stream.shutdown(io, .both) catch {}; + connection.mutex.unlock(io); + connection.task.await(io) catch {}; + for (connection.events.items) |event| connection.release(event); + connection.events.clearAndFree(connection.gpa); + } + + /// The next thing the task heard, which the frame lets go once it's done with it (`release`). + pub fn next(connection: *Connection) ?Event { + connection.mutex.lockUncancelable(connection.io); + defer connection.mutex.unlock(connection.io); + if (connection.events.items.len == 0) return null; + return connection.events.orderedRemove(0); + } + + pub fn release(connection: *Connection, event: Event) void { + switch (event) { + .message => |message| connection.gpa.free(message.payload), + .connected, .disconnected, .gave_up => {}, + } + } + + /// Sends a message to the game; false where none is connected or the connection fails. + pub fn send(connection: *Connection, tag: u16, payload: []const u8) bool { + const io = connection.io; + connection.writing.lockUncancelable(io); + defer connection.writing.unlock(io); + connection.mutex.lockUncancelable(io); + const current = connection.stream; + connection.mutex.unlock(io); + const stream = current orelse return false; + var buffer: [write_size]u8 = undefined; + var writer = stream.writer(io, &buffer); + link.writeMessage(&writer.interface, tag, payload) catch return false; + writer.interface.flush() catch return false; + return true; + } + + /// Sets what the task answers the next mission's file with: the flags of `offsets` where the + /// game plays mission `number` with the script of `mission`. Gives the generation armed. + pub fn arm(connection: *Connection, number: ?u32, mission: []const u8, offsets: []const u32) Allocator.Error!u64 { + const mission_copy = try connection.gpa.dupe(u8, mission); + errdefer connection.gpa.free(mission_copy); + const offsets_copy = try connection.gpa.dupe(u32, offsets); + connection.mutex.lockUncancelable(connection.io); + defer connection.mutex.unlock(connection.io); + const armed = &connection.armed; + connection.gpa.free(armed.mission); + connection.gpa.free(armed.offsets); + armed.* = .{ .number = number, .mission = mission_copy, .offsets = offsets_copy, .generation = armed.generation + 1 }; + return armed.generation; + } + + /// The task: connects, with `patience`, reads the game's messages until it goes, and stops. + /// The editor never links again by itself. + fn run(connection: *Connection, port: u16, patience: Patience) Io.Cancelable!void { + const io = connection.io; + const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; + const started = Io.Clock.awake.now(io); + const stream = while (true) { + if (connection.isStopping()) return; + if (address.connect(io, .{ .mode = .stream })) |stream| break stream else |_| {} + switch (patience) { + .starting => |limit_ms| if (started.durationTo(Io.Clock.awake.now(io)).toMilliseconds() >= limit_ms) { + connection.push(.gave_up); + return; + }, + .waiting => {}, + } + try io.sleep(.fromMilliseconds(retry_ms), .awake); + }; + if (!connection.attach(stream)) return stream.close(io); + connection.push(.connected); + connection.read(stream); + connection.detach(stream); + if (!connection.isStopping()) connection.push(.disconnected); + } + + fn isStopping(connection: *Connection) bool { + connection.mutex.lockUncancelable(connection.io); + defer connection.mutex.unlock(connection.io); + return connection.stopping; + } + + /// Takes `stream` as the game's connection; false as the editor lets it go. + fn attach(connection: *Connection, stream: Io.net.Stream) bool { + connection.mutex.lockUncancelable(connection.io); + defer connection.mutex.unlock(connection.io); + if (connection.stopping) return false; + connection.stream = stream; + return true; + } + + /// Lets the game's connection go once nothing writes to it, and closes it. + fn detach(connection: *Connection, stream: Io.net.Stream) void { + const io = connection.io; + connection.writing.lockUncancelable(io); + defer connection.writing.unlock(io); + connection.mutex.lockUncancelable(io); + connection.stream = null; + connection.mutex.unlock(io); + stream.close(io); + } + + /// Reads the game's messages until it goes, answering each mission's file. + fn read(connection: *Connection, stream: Io.net.Stream) void { + var buffer: [read_size]u8 = undefined; + var reader = stream.reader(connection.io, &buffer); + // The mission whose file comes next, as `Mission started` gives it. + var number: ?u32 = null; + while (true) { + const message = link.readMessage(&reader.interface, connection.gpa) catch return; + var answered: ?u64 = null; + if (link.Own.of(message.tag)) |own| switch (own) { + .mission_started => number = if (link.MissionStarted.read(message.payload)) |started| started.number else |_| null, + .mission_file => answered = connection.answer(number, message.payload), + else => {}, + }; + connection.push(.{ .message = .{ .tag = message.tag, .payload = message.payload, .answered = answered } }); + } + } + + /// Sends the game the script's flags for mission `number`'s file `file`, which the game holds + /// the mission's start for: the armed flags where they were armed for its script, and no flags + /// otherwise, which lets the start go on at once. The generation of the flags sent, where they + /// were armed for it. + fn answer(connection: *Connection, number: ?u32, file: []const u8) ?u64 { + var arena: std.heap.ArenaAllocator = .init(connection.gpa); + defer arena.deinit(); + connection.mutex.lockUncancelable(connection.io); + const armed = connection.armed; + const matches = number != null and armed.number == number and connection.profile.sameScript(armed.mission, file); + const flags = connection.profile.scriptFlags(arena.allocator(), file, if (matches) armed.offsets else &.{}) catch null; + connection.mutex.unlock(connection.io); + if (flags) |bytes| _ = connection.send(connection.profile.link.script_flags, bytes); + return if (matches) armed.generation else null; + } + + /// Queues `event` for the frame, and wakes the window. A message that can't be queued is lost. + fn push(connection: *Connection, event: Event) void { + connection.mutex.lockUncancelable(connection.io); + const kept = if (connection.events.append(connection.gpa, event)) true else |_| false; + connection.mutex.unlock(connection.io); + if (!kept) connection.release(event); + connection.wake(); + } +}; + +pub const testing = struct { + /// A wake for the tests, which have no window to wake. + pub fn noWake() void {} +}; diff --git a/src/main.zig b/src/main.zig index 83db2af..d965629 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,7 +5,10 @@ const std = @import("std"); const c = @import("c"); pub const app = @import("app.zig"); +pub const breakpoints = @import("debug/breakpoints.zig"); pub const commands = @import("commands.zig"); +pub const connection = @import("link/connection.zig"); +pub const session = @import("debug/session.zig"); pub const game = @import("game.zig"); pub const ini = @import("ini.zig"); pub const keys = @import("keys.zig"); @@ -43,7 +46,7 @@ const Options = struct { check_missions: bool = false, print_version: bool = false, - const Show = enum { start, settings, palette, keys, problems }; + const Show = enum { start, settings, palette, keys, link, problems }; const usage = \\Usage: openreliant-editor [] [options] @@ -54,7 +57,7 @@ const Options = struct { \\ --screenshot draw without a window and save a frame as a PNG file \\ --frames the frames drawn before the picture; 10 by default \\ --size x the window's size; 1440x900 by default - \\ --show start, settings, keys, palette or problems + \\ --show start, settings, keys, link, palette or problems \\ --check-missions open each of the game's missions as source, build it again, \\ and check it's the same mission \\ @@ -149,9 +152,13 @@ pub fn main(init: std.process.Init) !u8 { if (options.line) |line| editor.goTo(line -| 1, 0); if (options.show) |shown| switch (shown) { .start => editor.screen = .start, - .settings, .keys => { + .settings, .keys, .link => { editor.screen = .settings; - editor.settings_page = if (shown == .keys) .keys else .game; + editor.settings_page = switch (shown) { + .keys => .keys, + .link => .editor_link, + else => .game, + }; }, .palette => editor.palette.show(.commands), .problems => editor.showPanel(.problems), @@ -228,7 +235,7 @@ fn checkMission(arena: std.mem.Allocator, io: std.Io, profile: *const game.Profi return false; }, .mission => |again| { - if (try profile.difference(arena, image, again)) |differs| { + if (try profile.difference(arena, image, again.file)) |differs| { try out.print("differs: {s}\n", .{differs}); return false; } diff --git a/src/mission/document.zig b/src/mission/document.zig index e549db0..d5c31f5 100644 --- a/src/mission/document.zig +++ b/src/mission/document.zig @@ -1,5 +1,7 @@ //! An open mission: its source in the text editor, checked as it's typed, its outline from the -//! last source that parsed, its problems, its breakpoints, and what its last build made. +//! last source that parsed, its problems, the build of its text with the map of its script's +//! statements, its breakpoints, the line where the game holds its script, and what its last build +//! wrote. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -23,11 +25,13 @@ const LineFlags = packed struct(usize) { _: @Int(.unsigned, @bitSizeOf(usize) - 1) = 0, }; -/// A check of the source: its outline and its problems, in an arena of their own. +/// A check of the source: its outline, its problems, and its build where it has none, in an arena +/// of their own. const Check = struct { arena: std.heap.ArenaAllocator, outline: game.Outline = .{ .records = &.{}, .errors = &.{} }, problems: []const game.Problem = &.{}, + mission: ?game.Mission = null, fn deinit(check: *Check) void { check.arena.deinit(); @@ -67,6 +71,8 @@ pub const Document = struct { listing_arena: std.heap.ArenaAllocator, /// Whether the listing changed since the listing view showed it. listing_fresh: bool = false, + /// The line where the game holds the script, counting from 0, which the text tints. + held_line: ?u32 = null, /// Opens the source file at `path`. pub fn open(gpa: Allocator, io: Io, path: []const u8, profile: *const game.Profile, language: ?text_editor.Language) !*Document { @@ -120,6 +126,12 @@ pub const Document = struct { return document.good.outline; } + /// The build of the text as last checked, with the map of its script's statements; null while + /// the text has problems. + pub fn mission(document: *const Document) ?game.Mission { + return document.check.mission; + } + /// Checks the text again once it has rested for `check_delay_ms` since its last change. True /// when it checked. pub fn update(document: *Document, now_ms: u64) bool { @@ -155,7 +167,7 @@ pub const Document = struct { check.problems = check.outline.errors; } else { switch (try document.profile.build(arena, text)) { - .mission => check.problems = &.{}, + .mission => |made| check.mission = made, .problems => |found| check.problems = found, } } @@ -173,14 +185,24 @@ pub const Document = struct { } document.check.deinit(); document.check = check; - document.markProblems(); + document.mark(); + } + + /// Tints the line where the game holds the script, or none. + pub fn setHeldLine(document: *Document, line: ?u32) void { + if (document.held_line == line) return; + document.held_line = line; + document.mark(); } - /// Puts the problems on the text: the line tinted red, and a squiggle under each one's word. - fn markProblems(document: *Document) void { + /// Puts the problems on the text, each line tinted red and a squiggle under each one's word, + /// and tints the line where the game holds the script. + fn mark(document: *Document) void { const editor = document.editor; editor.clearSquiggles(); editor.clearMarkers(); + // The text's part of the held line; the gutter draws its own (`views/script.zig`). + if (document.held_line) |line| editor.addMarker(line, 0, theme.held_line, ""); for (document.check.problems) |problem| { editor.addMarker(problem.at.line, 0, theme.error_line, ""); const at: text_editor.Pos = .{ .line = problem.at.line, .index = problem.at.column }; @@ -213,7 +235,7 @@ pub const Document = struct { const arena = arena_state.allocator(); const text = try arena.dupe(u8, document.editor.text()); const image = switch (try document.profile.build(arena, text)) { - .mission => |made| made, + .mission => |made| made.file, .problems => return .{ .problems = document.check.problems }, }; const stem = document.file[0 .. document.file.len - document.profile.source_extension.len]; @@ -234,8 +256,13 @@ pub const Document = struct { } pub fn toggleBreakpoint(document: *Document, line: usize) void { + document.setBreakpoint(line, !document.hasBreakpoint(line)); + } + + pub fn setBreakpoint(document: *Document, line: usize, on: bool) void { + if (line >= document.editor.lineCount()) return; var flags: LineFlags = @bitCast(document.editor.lineData(line)); - flags.breakpoint = !flags.breakpoint; + flags.breakpoint = on; document.editor.setLineData(line, @bitCast(flags)); } diff --git a/src/palette.zig b/src/palette.zig index d491250..26e0548 100644 --- a/src/palette.zig +++ b/src/palette.zig @@ -162,7 +162,7 @@ pub fn draw(app: *App) void { } } if (offsets) { - widgets.caption(gui.print("Script offsets · planned {f}", .{commands.plans.debugger}), theme.text_dim, fonts.size.small, .regular); + widgets.caption(gui.print("Script offsets · planned {f}", .{commands.plans.watches}), theme.text_dim, fonts.size.small, .regular); } else if (count == 0) { gui.setCursor(gui.add(gui.cursor(), .{ .x = px(8), .y = px(6) })); widgets.caption(if (kind == .line) "Type a line number" else "Nothing matches", theme.text_dim, fonts.size.small, .regular); @@ -275,7 +275,7 @@ fn playForm(app: *App) void { gui.space(10); if (widgets.checkbox("skip launch", "Skip launch", state.skip_launch, null)) state.skip_launch = !state.skip_launch; _ = widgets.checkbox("earlier parts", "Run earlier parts first, for their variables", false, .{ .reason = gui.print("Planned · {f}", .{commands.plans.play_from_part}) }); - _ = widgets.checkbox("break first", "Break at its first statement", false, .{ .reason = gui.print("Planned · {f}", .{commands.plans.debugger}) }); + _ = widgets.checkbox("break first", "Break at its first statement", false, .{ .reason = gui.print("Planned · {f}", .{commands.plans.watches}) }); gui.space(12); widgets.sectionHeading("Watch"); gui.space(6); diff --git a/src/panels.zig b/src/panels.zig index 5d97b3a..c8542c8 100644 --- a/src/panels.zig +++ b/src/panels.zig @@ -1,10 +1,13 @@ //! The workspace's dockable panels. Each is an ImGui window the dock space holds; the source panel -//! is `views/script.zig`. Panels whose data comes from a linked game show what they will hold, -//! greyed out, until the editor link is made. +//! is `views/script.zig`. The debugging panels show the game's script while the editor is linked +//! (`debug/session.zig`), and what they will hold until then. const std = @import("std"); const c = @import("c"); +const openreliant = @import("openreliant"); +const State = openreliant.engine.link.State; + const app_mod = @import("app.zig"); const App = app_mod.App; const Panel = app_mod.Panel; @@ -259,6 +262,8 @@ fn recordRow(document: *const Document, record: game.Record, selected: bool, fai if (record.section == .flight_groups) shown.indent = 54; if (failed) { shown.end = .{ .text = .{ .text = gui.print("line {d}", .{document.problems()[0].at.line + 1}), .color = theme.error_count } }; + } else if (holds(document, record)) { + shown.end = .{ .badge = .{ .text = "held", .background = theme.accent, .foreground = theme.on_accent } }; } else if (record.starts) { shown.end = .{ .badge = .{ .text = "start", .background = theme.teal_badge, .foreground = theme.teal_badge_text } }; } else if (record.section == .triggers and hasBreakpointIn(document, record)) { @@ -269,6 +274,12 @@ fn recordRow(document: *const Document, record: game.Record, selected: bool, fai return shown; } +/// Whether the game holds the script on a line of `record`'s code. +fn holds(document: *const Document, record: game.Record) bool { + const line = document.held_line orelse return false; + return line >= record.first_line and line <= record.last_line; +} + fn hasBreakpointIn(document: *const Document, record: game.Record) bool { var line = record.first_line; while (line <= record.last_line) : (line += 1) { @@ -335,10 +346,106 @@ fn tableHeader(columns: []const []const u8, widths: []const f32) void { c.ImGui_Dummy(.{ .x = width, .y = height }); } +/// The threads' columns: their names and widths. +const thread_columns = [_][]const u8{ "Slot", "State", "Where", "Started by", "Depth", "Waits for" }; +const thread_widths = [_]f32{ 46, 64, 136, 196, 58, 80 }; + fn threads(app: *App) void { - _ = app; - tableHeader(&.{ "Slot", "State", "Where", "Started by", "Depth", "Waits for" }, &.{ 46, 64, 136, 196, 58, 80 }); - waiting(.cable, "The script's threads show here while the game is linked", commands.plans.debugger); + tableHeader(&thread_columns, &thread_widths); + const session = &app.session; + if (session.link_state != .linked) return waiting(.cable, "Threads show while linked", null); + if (session.playing == null) return waiting(.cable, "No mission playing", null); + const snapshot = session.state orelse return waiting(.cable, "Waiting for state", null); + const state = snapshot.state; + _ = c.ImGui_BeginChild("threads", .{ .x = 0, .y = -px(36) }, 0, 0); + for (state.threads) |thread| { + gui.pushIdInt(thread.slot); + defer gui.popId(); + threadRow(app, state, thread); + } + c.ImGui_EndChild(); + padded(12, 10); + widgets.caption(if (session.first_finished) "Start parts: done" else "Start parts: running", theme.text_dim, fonts.size.small, .regular); +} + +/// A thread's row: its slot, whether the game holds it, where it is, what started it, its depth, +/// and what it waits for. A double click goes to its line. +fn threadRow(app: *App, state: State, thread: State.Thread) void { + const session = &app.session; + const at = gui.cursor(); + const width = gui.available().x; + const height = px(27); + _ = c.ImGui_InvisibleButton("thread", .{ .x = width, .y = height }, 0); + const double_clicked = gui.hovered() and c.ImGui_IsMouseDoubleClicked(c.ImGuiMouseButton_Left); + const list = gui.drawList(); + const held = if (session.held) |hold| thread.at == hold.offset else false; + if (held) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.held_row, 0) else if (gui.hovered()) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.row_hover, 0); + gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.table_line, 0); + const middle = at.y + height / 2; + var x = at.x + px(12); + gui.mono(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{thread.slot})); + gui.popFont(); + x += px(thread_widths[0]); + if (held) { + stateTag(list, x, middle, "Held"); + } else { + const waits = thread.interrupted or thread.wake > state.clock; + gui.sans(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, if (waits) "Waiting" else "Ready"); + gui.popFont(); + } + x += px(thread_widths[1]); + // Where: its line, from the build's map, and its offset. + const line = if (thread.at) |offset| session.lineOf(offset) else null; + var where_x = x; + if (line) |found| { + gui.mono(.regular, fonts.size.ui); + const text = gui.print("line {d}", .{found + 1}); + gui.drawText(list, .{ .x = where_x, .y = gui.textTop(middle) }, if (held) theme.held_text else theme.text_row, text); + where_x += gui.textSize(text).x; + gui.popFont(); + } + gui.sans(.regular, fonts.size.ui); + const offset_text = if (thread.at) |offset| gui.print("{s}offset {d}", .{ if (line != null) " · " else "", offset }) else "outside the script"; + gui.drawText(list, .{ .x = where_x, .y = gui.textTop(middle) }, theme.text_dim, offset_text); + gui.popFont(); + x += px(thread_widths[2]); + gui.mono(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, startedBy(app, thread, line)); + x += px(thread_widths[3]); + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{thread.depth})); + gui.popFont(); + x += px(thread_widths[4]); + gui.sans(.regular, fonts.size.ui); + const waits_for = if (thread.interrupted) "its trigger" else if (thread.wake != 0) gui.print("clock {d} s", .{thread.wake}) else "nothing"; + gui.drawText(list, .{ .x = x, .y = gui.textTop(middle) }, theme.text_dim, waits_for); + gui.popFont(); + if (double_clicked) if (line) |found| app.goTo(found, 0); +} + +/// The design's tag of the thread the game holds: the state on the accent, 7 pixels to each side. +fn stateTag(list: *c.ImDrawList, left: f32, middle: f32, text: []const u8) void { + gui.sans(.semibold, 11); + defer gui.popFont(); + const height = px(17); + const max = gui.vec2(left + gui.textSize(text).x + px(7) * 2, middle + height / 2); + gui.rect(list, .{ .x = left, .y = middle - height / 2 }, max, theme.accent, 3); + gui.drawText(list, .{ .x = left + px(7), .y = gui.textTop(middle) }, theme.on_accent, text); +} + +/// What started a thread, as the source writes it: its trigger, or the part whose code it runs. +fn startedBy(app: *App, thread: State.Thread, line: ?u32) []const u8 { + const document = app.document orelse return ""; + const known = document.outline(); + if (thread.trigger) |index| { + for (known.records) |record| { + if (record.section == .triggers and record.index == index) return gui.print("on({s}, \"{s}\")", .{ record.subject, record.condition }); + } + return gui.print("trigger {d}", .{index}); + } + if (thread.depth == 0) if (line) |found| if (known.at(found)) |record| if (record.section == .parts) return record.title; + return "a part"; } fn breakpoints(app: *App) void { @@ -364,7 +471,7 @@ fn breakpoints(app: *App) void { if (gui.hovered()) gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.row_hover, 0); gui.rect(list, .{ .x = at.x, .y = at.y + height - px(1) }, .{ .x = at.x + width, .y = at.y + height }, theme.table_line, 0); const middle = at.y + height / 2; - gui.dot(list, .{ .x = at.x + px(17), .y = middle }, 4.5, theme.breakpoint); + if (app.session.mapped()) gui.dot(list, .{ .x = at.x + px(17), .y = middle }, 4.5, theme.breakpoint) else gui.ring(list, .{ .x = at.x + px(17), .y = middle }, 3.75, 1.5, theme.breakpoint); gui.mono(.regular, fonts.size.ui); gui.drawText(list, .{ .x = at.x + px(28), .y = gui.textTop(middle) }, theme.text_row, gui.print("{d}", .{line + 1})); gui.drawText(list, .{ .x = at.x + px(76), .y = gui.textTop(middle) }, theme.syntax.name, statement[0..@min(statement.len, 48)]); @@ -517,9 +624,13 @@ fn kindNoun(section: game.Section) []const u8 { }; } +/// The game's variables the source uses, then the mission's globals, with their values from the +/// game's state while it's linked, in orange where they changed since the last hold. fn variables(app: *App) void { padded(0, 4); const document = app.document; + const session = &app.session; + const state: ?State = if (session.mapped()) if (session.state) |snapshot| snapshot.state else null else null; _ = c.ImGui_BeginChild("variables", .{ .x = 0, .y = 0 }, 0, 0); defer c.ImGui_EndChild(); _ = widgets.row("game", .{ .label = "Game", .indent = 12, .open = true, .strong = true }); @@ -527,20 +638,32 @@ fn variables(app: *App) void { for (app.language.words) |word| { if (word.kind != .game_variable) continue; if (!usedIn(document, app.language.variables, word.name)) continue; - variableRow(word.name, "", theme.syntax.game); + const value = if (state) |known| gui.print("{d}", .{scriptWord(known.variable(word.name) orelse 0)}) else ""; + variableRow(word.name, value, theme.syntax.game, session.variableChanged(word.name)); shown += 1; } - if (shown == 0) variableRow("none used", "", theme.text_dim); + if (shown == 0) variableRow("none used", "", theme.text_dim, false); _ = widgets.row("globals", .{ .label = "Globals", .indent = 12, .open = true, .strong = true }); var globals: usize = 0; if (document) |open| for (open.outline().records) |record| { if (record.section != .globals) continue; - variableRow(record.title, record.detail, theme.syntax.name); + // A global's value from the game, by its place among the globals; its first value from the + // source until then. + const value = if (state) |known| if (record.index < known.globals.len) gui.print("{d}", .{scriptWord(known.globals[record.index])}) else "" else record.detail; + variableRow(record.title, value, theme.syntax.name, session.globalChanged(record.index)); globals += 1; }; - if (globals == 0) variableRow("none", "", theme.text_dim); - padded(12, 8); - widgets.caption(gui.print("Values while the game is linked · planned {f}", .{commands.plans.debugger}), theme.text_off, fonts.size.smaller, .regular); + if (globals == 0) variableRow("none", "", theme.text_dim, false); + if (state == null) { + padded(12, 8); + widgets.caption("Values while this build plays", theme.text_off, fonts.size.smaller, .regular); + } +} + +/// One of the script's words, as the state gives it, unsigned, as a signed number: the scripts +/// count down past 0. +fn scriptWord(word: u32) i32 { + return @bitCast(word); } /// Whether the source reads the game's variable `name` under the name `under`, as @@ -561,8 +684,9 @@ fn usedIn(document: ?*Document, under: []const u8, name: []const u8) bool { return false; } -/// A variable's line, as the design's `.vr`: its name in the code font, its value at the end. -fn variableRow(name: []const u8, value: []const u8, color: theme.Color) void { +/// A variable's line, as the design's `.vr`: its name in the code font, its value at the end, in +/// orange where it changed since the last hold. +fn variableRow(name: []const u8, value: []const u8, color: theme.Color, changed: bool) void { const at = gui.cursor(); const width = gui.available().x; const height = px(22); @@ -571,12 +695,12 @@ fn variableRow(name: []const u8, value: []const u8, color: theme.Color) void { gui.mono(.regular, fonts.size.ui); const y = gui.textTop(at.y + height / 2); gui.drawText(list, .{ .x = at.x + px(34), .y = y }, color, name); - gui.drawText(list, .{ .x = at.x + width - px(12) - gui.textSize(value).x, .y = y }, theme.text_row, value); + gui.drawText(list, .{ .x = at.x + width - px(12) - gui.textSize(value).x, .y = y }, if (changed) theme.accent else theme.text_row, value); gui.popFont(); } fn watch() void { - waiting(.eye, "Watched values show here while the game is linked", commands.plans.debugger); + waiting(.eye, "Watches show while linked", commands.plans.watches); } fn liveScene() void { @@ -619,6 +743,10 @@ fn inspector(app: *App) void { widgets.caption(where, theme.text_dim, fonts.size.small, .regular); c.ImGui_EndGroup(); gui.setCursor(.{ .x = left, .y = @max(gui.cursor().y, at.y + px(34)) + px(18) }); + if (holds(document, record.*)) { + heldBar(app, document, record.*, content_width); + gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(18) }); + } const edit_off = app.unavailable(.add_record); switch (record.section) { @@ -638,7 +766,33 @@ fn inspector(app: *App) void { gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(18) }); widgets.sectionHeading("Live"); gui.space(9); - widgets.caption(gui.print("While the game is linked · planned {f}", .{commands.plans.debugger}), theme.text_off, fonts.size.small, .regular); + widgets.caption(gui.print("While linked · planned {f}", .{commands.plans.live_scene}), theme.text_off, fonts.size.small, .regular); +} + +/// The inspector's line for the thread the game holds in the record: its slot and statement, on +/// the held colours. +fn heldBar(app: *App, document: *Document, record: game.Record, width: f32) void { + const at = gui.cursor(); + const height = px(32); + const list = gui.drawList(); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.held, 4); + const middle = at.y + height / 2; + widgets.drawIcon(list, .{ .x = at.x + px(12), .y = middle }, .pause, fonts.size.icon_tiny, theme.accent); + const slot = heldSlot(app); + const statement = if (script_view.statementOf(document, record, document.held_line.?)) |found| found[0] else 1; + const text = if (slot) |found| gui.print("Thread {d} · held · statement {d}", .{ found, statement }) else gui.print("Held · statement {d}", .{statement}); + gui.sans(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = at.x + px(32), .y = gui.textTop(middle) }, theme.held_text, text); + gui.popFont(); + c.ImGui_Dummy(.{ .x = width, .y = height }); +} + +/// The slot of the thread the game holds, from the state. +fn heldSlot(app: *App) ?u32 { + const held = app.session.held orelse return null; + const snapshot = app.session.state orelse return null; + for (snapshot.state.threads) |thread| if (thread.at == held.offset) return thread.slot; + return null; } fn capitalNoun(section: game.Section) []const u8 { diff --git a/src/platform/window.zig b/src/platform/window.zig index 3c272c2..980ad1c 100644 --- a/src/platform/window.zig +++ b/src/platform/window.zig @@ -223,6 +223,11 @@ pub const Window = struct { _ = c.SDL_SetWindowFullscreen(window.handle, !full); } + /// Brings the window in front of the others, as the game holds the script. + pub fn raise(window: *Window) void { + _ = c.SDL_RaiseWindow(window.handle); + } + pub fn setTitle(window: *Window, title: [*:0]const u8) void { _ = c.SDL_SetWindowTitle(window.handle, title); } diff --git a/src/screens/settings.zig b/src/screens/settings.zig index 308ea79..a9169ea 100644 --- a/src/screens/settings.zig +++ b/src/screens/settings.zig @@ -1,5 +1,6 @@ //! The settings: the Keys page sets every action's keys on Windows and Linux and on macOS side by -//! side, the current system's in front, and the Game page where the game and `openreliant` are. +//! side, the current system's in front, the Game page where the game and `openreliant` are, and +//! the Editor link page how the editor links to the game. const std = @import("std"); const c = @import("c"); @@ -9,6 +10,7 @@ const App = app_mod.App; const SettingsPage = app_mod.SettingsPage; const commands = @import("../commands.zig"); const Command = commands.Command; +const StateRate = @import("../debug/session.zig").StateRate; const keys = @import("../keys.zig"); const shell = @import("../shell.zig"); const ui = @import("../ui.zig"); @@ -43,7 +45,7 @@ pub fn draw(app: *App) void { const top = gui.cursor().y + px(3); const bottom = pos.y + size.y - px(24); const height = bottom - top - px(3); - const notes = app.settings_page == .keys; + const notes = app.settings_page == .keys or app.settings_page == .editor_link; const content_left = pos.x + px(3) + px(nav_width) + px(3); const content_right = pos.x + size.x - px(3) - (if (notes) px(notes_width) + px(3) else 0); @@ -55,16 +57,17 @@ pub fn draw(app: *App) void { switch (app.settings_page) { .keys => keysPage(app), .game => gamePage(app), - .general, .appearance, .editor_link => plannedPage(app.settings_page), + .editor_link => linkPage(app), + .general, .appearance => plannedPage(app.settings_page), } c.ImGui_EndChild(); if (notes) { panel("notes", .{ .x = content_right + px(3), .y = top }, .{ .x = px(notes_width), .y = height }); - keyNotes(); + if (app.settings_page == .keys) keyNotes() else linkNotes(app); c.ImGui_EndChild(); } - statusBar(app, bottom, pos.x, size.x); + if (app.settings_page == .editor_link) linkStatusBar(app, bottom, pos.x, size.x) else statusBar(app, bottom, pos.x, size.x); } /// A panel of the page: the design's `.panel`, a dark box with a line around it. @@ -113,11 +116,299 @@ fn plannedPage(page: SettingsPage) void { widgets.caption(switch (page) { .general => "General", .appearance => "Appearance", - .editor_link => "Editor link", else => "", }, theme.text_strong, 15, .semibold); gui.space(6); - widgets.caption(gui.print("Planned · {f}", .{if (page == .editor_link) commands.plans.debugger else commands.plans.settings}), theme.text_dim, fonts.size.small, .regular); + widgets.caption(gui.print("Planned · {f}", .{commands.plans.settings}), theme.text_dim, fonts.size.small, .regular); +} + +/// The Editor link page: whether Play links the game, the port, how often the state is asked for +/// while the script runs, the breakpoints kept, and live changes, planned. +fn linkPage(app: *App) void { + const page_left = gui.cursor().x; + const width = gui.available().x; + const list = gui.drawList(); + // The heading, with the link's chip and Unlink at its end: 12 pixels of padding + // around its title and the line under it, a pixel apart. + const head_top = gui.cursor().y; + const title_line = 15 * fonts.line_height; + const detail_line = fonts.size.small * fonts.line_height; + const head_height = px(12 + title_line + 1 + detail_line + 12 + 1); + gui.sans(.semibold, 15); + gui.drawText(list, .{ .x = page_left + px(14), .y = gui.textTopIn(head_top + px(12 + title_line / 2), 15) }, theme.text_strong, "Editor link"); + gui.popFont(); + gui.sans(.regular, fonts.size.small); + gui.drawText(list, .{ .x = page_left + px(14), .y = gui.textTopIn(head_top + px(12 + title_line + 1 + detail_line / 2), fonts.size.small) }, theme.text_dim, "Debugging · live changes"); + gui.popFont(); + const unlink = commands.info(.unlink).label; + const button_width = widgets.toolButtonWidth(.{ .label = unlink }); + const chip = shell.linkChip(app); + const right = page_left + width - px(14); + const head_middle = head_top + head_height / 2; + gui.setCursor(.{ .x = right - button_width, .y = head_middle - px(widgets.tool_button_height) / 2 }); + if (widgets.toolButton("unlink", .{ .label = unlink, .disabled = app.unavailable(.unlink) })) app.run(.unlink); + gui.setCursor(.{ .x = right - button_width - px(8) - widgets.chipWidth(chip), .y = head_middle - px(widgets.chip_height) / 2 }); + widgets.chip(chip); + gui.rect(list, .{ .x = page_left, .y = head_top + head_height - px(1) }, .{ .x = page_left + width, .y = head_top + head_height }, theme.divider, 0); + gui.setCursor(.{ .x = page_left, .y = head_top + head_height }); + + const link = &app.settings.link; + { + const row: Row = .begin("when playing", "Link on Play", "Play adds ", "--editor-link", false); + defer row.end(); + row.centre(switch_height); + if (widgets.toggle("link when playing", link.when_playing, null)) { + link.when_playing = !link.when_playing; + app.settings.save(app.io); + } + } + { + const row: Row = .begin("port", "Port", "", "--editor-link-port", false); + defer row.end(); + row.centre(field_height); + portField(app); + } + { + const row: Row = .begin("state", "State polling", "Variables, Watch · while running", null, false); + defer row.end(); + const labels = [_][]const u8{ "Off", "1 Hz", "4 Hz" }; + row.centre(choice_height); + if (choice("state rate", &labels, @backingInt(link.state_rate))) |chosen| { + link.state_rate = @fromBackingInt(@intCast(chosen)); + app.settings.save(app.io); + } + } + { + const row: Row = .begin("breakpoints", "Breakpoints", "Per source file", null, false); + defer row.end(); + const count = app.breakpoint_store.count(); + const text = gui.print("{d} file{s} · {d} breakpoint{s}", .{ count.files, if (count.files == 1) "" else "s", count.breakpoints, if (count.breakpoints == 1) "" else "s" }); + gui.sans(.regular, fonts.size.ui); + gui.drawText(gui.drawList(), .{ .x = gui.cursor().x, .y = gui.textTop(row.middle) }, theme.text_menu, text); + gui.popFont(); + const remove = "Remove all"; + gui.setCursor(.{ .x = right - widgets.toolButtonWidth(.{ .label = remove }), .y = gui.cursor().y }); + row.centre(widgets.tool_button_height); + const none: ?widgets.Disabled = if (count.breakpoints == 0) .{ .reason = "No breakpoints" } else null; + if (widgets.toolButton("remove all", .{ .label = remove, .disabled = none })) app.removeAllBreakpoints(); + } + { + const planned: widgets.Disabled = .{ .reason = gui.print("Planned · {f}", .{commands.plans.live_changes}) }; + const row: Row = .begin("live changes", "Live changes", "Hot-swap each build", null, true); + defer row.end(); + const control_x = gui.cursor().x; + row.centre(switch_height); + _ = widgets.toggle("live changes", false, planned); + gui.setCursor(.{ .x = control_x + px(30) + px(10), .y = row.middle }); + planTag(planned.reason); + } + c.ImGui_Dummy(.{ .x = 0, .y = 0 }); +} + +/// A row of the Editor link page: its label and the line under it, in a column of their own, with +/// the control after them in the middle of the row (`centre`), until `end`. The line under the +/// label can end in a word in the code font, and a row for what's planned is dimmed whole. +const Row = struct { + top: f32, + middle: f32, + dimmed: bool, + + /// The design's row: 10 pixels of padding around its label's two lines, 2 pixels apart, and + /// its line, with the label's column 220 pixels wide. + const padding = 10; + const label_line = fonts.size.ui * fonts.line_height; + const detail_line = fonts.size.small * fonts.line_height; + const height = padding * 2 + label_line + 2 + detail_line + 1; + const label_column = 220; + /// How much a row for what's planned shows. + const dimmed_alpha = 0.45; + + fn begin(id: []const u8, label: []const u8, detail: []const u8, detail_code: ?[]const u8, dimmed: bool) Row { + const at = gui.cursor(); + const width = gui.available().x; + const list = gui.drawList(); + gui.pushId(id); + if (dimmed) gui.pushVar(c.ImGuiStyleVar_Alpha, dimmed_alpha); + gui.rect(list, .{ .x = at.x, .y = at.y + px(height) - px(1) }, .{ .x = at.x + width, .y = at.y + px(height) }, theme.table_line, 0); + const label_middle = at.y + px(padding + label_line / 2); + const detail_middle = at.y + px(padding + label_line + 2 + detail_line / 2); + gui.sans(.regular, fonts.size.ui); + gui.drawText(list, .{ .x = at.x + px(14), .y = gui.textTopIn(label_middle, fonts.size.ui) }, theme.text, label); + gui.popFont(); + gui.sans(.regular, fonts.size.small); + gui.drawText(list, .{ .x = at.x + px(14), .y = gui.textTopIn(detail_middle, fonts.size.small) }, theme.text_dim, detail); + const detail_end = at.x + px(14) + gui.textSize(detail).x; + gui.popFont(); + if (detail_code) |code| { + gui.mono(.regular, fonts.size.small); + gui.drawText(list, .{ .x = detail_end, .y = gui.textTopIn(detail_middle, fonts.size.small) }, theme.text_dim, code); + gui.popFont(); + } + const row: Row = .{ .top = at.y, .middle = at.y + px(padding + (label_line + 2 + detail_line) / 2), .dimmed = dimmed }; + gui.setCursor(.{ .x = at.x + px(14) + px(label_column) + px(16), .y = row.middle }); + return row; + } + + /// Moves the cursor for a control `control_height` design pixels high, to sit in the middle of + /// the row. + fn centre(row: Row, control_height: f32) void { + gui.setCursor(.{ .x = gui.cursor().x, .y = row.middle - px(control_height) / 2 }); + } + + fn end(row: Row) void { + if (row.dimmed) gui.popVar(1); + gui.popId(); + gui.setCursor(.{ .x = c.ImGui_GetWindowPos().x, .y = row.top + px(height) }); + } +}; + +/// The heights of the page's controls: the switch, the port's field and the choice. +const switch_height = 16; +const field_height = 28; +const choice_height = 30; + +/// The port the game listens at, as a field of numbers, saved as it's left. +fn portField(app: *App) void { + const State = struct { + var buffer: [8:0]u8 = @splat(0); + var editing = false; + }; + const at = gui.cursor(); + const size = gui.vec2(px(110), px(28)); + const list = gui.drawList(); + gui.rect(list, at, gui.add(at, size), theme.button, 4); + gui.outline(list, at, gui.add(at, size), theme.border, 4); + if (!State.editing) _ = std.mem.printSentinel(&State.buffer, "{d}", .{app.settings.link.port}, 0) catch {}; + gui.mono(.medium, fonts.size.ui); + defer gui.popFont(); + gui.setCursor(.{ .x = at.x + px(7), .y = at.y }); + c.ImGui_SetNextItemWidth(size.x - px(14)); + gui.pushColor(c.ImGuiCol_FrameBg, theme.fade(theme.field, 0)); + gui.pushColor(c.ImGuiCol_FrameBgHovered, theme.fade(theme.field, 0)); + gui.pushColor(c.ImGuiCol_FrameBgActive, theme.fade(theme.field, 0)); + gui.pushVar(c.ImGuiStyleVar_FrameBorderSize, 0); + gui.pushVar2(c.ImGuiStyleVar_FramePadding, .{ .x = px(2), .y = gui.textTop(at.y + size.y / 2) - at.y }); + _ = c.ImGui_InputText("##port", &State.buffer, State.buffer.len + 1, c.ImGuiInputTextFlags_CharsDecimal); + gui.popVar(2); + gui.popColor(3); + State.editing = c.ImGui_IsItemActive(); + if (c.ImGui_IsItemDeactivatedAfterEdit()) { + if (std.fmt.parseInt(u16, std.mem.sliceTo(&State.buffer, 0), 10)) |port| { + if (port != 0) { + app.settings.link.port = port; + app.settings.save(app.io); + } + } else |_| {} + } +} + +/// The design's choice of a few, the settings' `.seg`: each option as wide as its label, the one +/// chosen lit. The option clicked, if one is. +fn choice(id: []const u8, options: []const []const u8, current: usize) ?usize { + gui.pushId(id); + defer gui.popId(); + gui.sans(.regular, fonts.size.ui); + defer gui.popFont(); + var width = px(3) * 2 + px(2) * @as(f32, @floatFromInt(options.len - 1)); + for (options) |option| width += gui.textSize(option).x + px(12) * 2; + const height = px(30); + const at = gui.cursor(); + const list = gui.drawList(); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.field, 5); + gui.outline(list, at, .{ .x = at.x + width, .y = at.y + height }, theme.border, 5); + var chosen: ?usize = null; + var x = at.x + px(3); + for (options, 0..) |option, index| { + const item_width = gui.textSize(option).x + px(12) * 2; + gui.setCursor(.{ .x = x, .y = at.y + px(3) }); + gui.pushIdInt(index); + if (c.ImGui_InvisibleButton("option", .{ .x = item_width, .y = px(24) }, 0)) chosen = index; + gui.popId(); + const on = index == current; + if (on) gui.rect(list, .{ .x = x, .y = at.y + px(3) }, .{ .x = x + item_width, .y = at.y + px(27) }, theme.border, 3); + gui.drawText(list, .{ .x = x + px(12), .y = gui.textTop(at.y + height / 2) }, if (on) theme.white else theme.text_muted, option); + x += item_width + px(2); + } + gui.setCursor(at); + c.ImGui_Dummy(.{ .x = width, .y = height }); + return chosen; +} + +/// The design's `.plan`: what's planned, in a small grey pill, its middle at the cursor. +fn planTag(text: []const u8) void { + gui.sans(.semibold, 11); + defer gui.popFont(); + const at = gui.cursor(); + const width = gui.textSize(text).x + px(7) * 2; + const height = px(20); + const middle = at.y; + gui.rect(gui.drawList(), .{ .x = at.x, .y = middle - height / 2 }, .{ .x = at.x + width, .y = middle + height / 2 }, theme.pill, 10); + gui.drawText(gui.drawList(), .{ .x = at.x + px(7), .y = gui.textTop(middle) }, theme.text_muted, text); + gui.setCursor(.{ .x = at.x, .y = middle - height / 2 }); + c.ImGui_Dummy(.{ .x = width, .y = height }); +} + +/// The notes beside the Editor link page: the game linked, what a hold stops, and how to link a +/// game started by hand. +fn linkNotes(app: *App) void { + const session = &app.session; + const left = gui.cursor().x + px(12); + gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(2) }); + const greeting = if (session.link_state == .linked) session.greeting else null; + const plays = if (session.playing) |playing| gui.print("mission {d} · {s}", .{ playing.number, switch (playing.match) { + .this_build => "this build", + .older_build => "older build", + .other_mission => "other mission", + .unknown => "checking", + } }) else "no mission"; + const linked_rows = [_][2][]const u8{ + .{ "Linked", if (greeting) |linked| gui.print("{s} · OpenReliant {s}", .{ linked.game, linked.openreliant }) else if (session.link_state == .waiting) "Waiting" else "No" }, + .{ "Address", gui.print("127.0.0.1:{d}", .{if (session.link_state == .off) app.settings.link.port else session.port}) }, + .{ "Plays", plays }, + .{ "Clients", "1 at a time" }, + }; + // Where no game is linked, it plays nothing the editor knows of. + const game_rows: []const [2][]const u8 = if (greeting != null) &linked_rows else &.{ linked_rows[0], linked_rows[1], linked_rows[3] }; + noteSection(left, "The game", game_rows, note_gap); + noteSection(left, "While held", &.{ + .{ "Script", "Threads, timers, clock stop" }, + .{ "Ships", "Fly on · last orders" }, + .{ "Pause", "Whole mission stops" }, + .{ "Start parts", "Loading screen holds" }, + }, note_gap); + gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(14) }); + const box_at = gui.cursor(); + const box_width = gui.available().x - px(12); + const box_height = px(84); + const list = gui.drawList(); + gui.rect(list, box_at, .{ .x = box_at.x + box_width, .y = box_at.y + box_height }, theme.panel, 5); + gui.outline(list, box_at, .{ .x = box_at.x + box_width, .y = box_at.y + box_height }, theme.table_head_line, 5); + gui.setCursor(.{ .x = box_at.x + px(10), .y = box_at.y + px(10) }); + widgets.sectionHeading("Link by hand"); + gui.setCursor(.{ .x = box_at.x + px(10), .y = gui.cursor().y + px(6) }); + widgets.monoCaption("openreliant --editor-link", theme.text_menu, fonts.size.small, .regular); + gui.setCursor(.{ .x = box_at.x + px(10), .y = gui.cursor().y + px(2) }); + widgets.caption(gui.print("Then Game · {s}", .{commands.info(.link).label}), theme.text_muted, fonts.size.small, .regular); +} + +fn linkStatusBar(app: *App, top: f32, left: f32, width: f32) void { + const list = c.ImGui_GetWindowDrawList(); + gui.rect(list, .{ .x = left, .y = top }, .{ .x = left + width, .y = top + px(24) }, theme.bar, 0); + gui.rect(list, .{ .x = left, .y = top }, .{ .x = left + width, .y = top + px(1) }, theme.divider, 0); + gui.sans(.regular, fonts.size.smaller); + defer gui.popFont(); + const y = gui.textTop(top + px(24) / 2); + const session = &app.session; + const first = gui.print("Editor link · port {d}", .{app.settings.link.port}); + gui.drawText(list, .{ .x = left + px(10), .y = y }, theme.text_muted, first); + const state = switch (session.link_state) { + .off => "Not linked", + .waiting => "Waiting for game", + .linked => gui.print("Linked · {s}", .{session.greeting.?.game}), + }; + gui.drawText(list, .{ .x = left + px(10) + gui.textSize(first).x + px(16), .y = y }, theme.text_muted, state); + const end = "Live changes: planned"; + gui.drawText(list, .{ .x = left + width - px(10) - gui.textSize(end).x, .y = y }, theme.text_muted, end); } fn gamePage(app: *App) void { @@ -411,12 +702,12 @@ fn keyNotes() void { .{ "F11", "Show Desktop · left free" }, .{ "F-keys", "Media keys by default · fn+F6" }, .{ "Text", "Option+arrows: words · Cmd+arrows: line ends" }, - }); + }, key_note_gap); noteSection(left, "Windows, Linux", &.{ .{ "Debugging", "Visual Studio's keys" }, .{ "Quit", "Alt+F4 · Linux also Ctrl+Q" }, .{ "Text", "Ctrl+arrows: words · Home, End" }, - }); + }, key_note_gap); gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(18) }); const box_at = gui.cursor(); const box_width = gui.available().x - px(12); @@ -432,10 +723,13 @@ fn keyNotes() void { widgets.caption("Per system · changed keys only", theme.text_dim, fonts.size.small, .regular); } -fn noteSection(left: f32, heading: []const u8, rows: []const [2][]const u8) void { +/// A section of the notes: its heading, and a row for each pair, the second wrapped in its column, +/// `gap` design pixels under the last line of the row before. +fn noteSection(left: f32, heading: []const u8, rows: []const [2][]const u8, gap: f32) void { gui.setCursor(.{ .x = left, .y = gui.cursor().y + px(16) }); widgets.sectionHeading(heading); gui.space(6); + const spacing = c.ImGui_GetStyle().*.ItemSpacing.y; for (rows) |row| { gui.setCursor(.{ .x = left, .y = gui.cursor().y }); const at = gui.cursor(); @@ -444,10 +738,15 @@ fn noteSection(left: f32, heading: []const u8, rows: []const [2][]const u8) void gui.wrapAt(c.ImGui_GetWindowPos().x + c.ImGui_GetWindowWidth() - px(12)); widgets.caption(row[1], theme.text, fonts.size.ui, .regular); c.ImGui_PopTextWrapPos(); - gui.space(4); + gui.setCursor(.{ .x = left, .y = gui.cursor().y - spacing + px(gap) }); } } +/// The space under a row of the notes: the Keys page's, and the Editor link page's, whose rows are +/// 8 pixels apart in lines of 1.45 ems. +const key_note_gap = 16; +const note_gap = 8 + (fonts.line_height - 1.3) * fonts.size.ui; + fn statusBar(app: *App, top: f32, left: f32, width: f32) void { const list = c.ImGui_GetWindowDrawList(); gui.rect(list, .{ .x = left, .y = top }, .{ .x = left + width, .y = top + px(24) }, theme.bar, 0); diff --git a/src/screens/start.zig b/src/screens/start.zig index 412e5e8..30de5e2 100644 --- a/src/screens/start.zig +++ b/src/screens/start.zig @@ -413,14 +413,21 @@ fn gameBox(app: *App, width: f32) void { gui.rect(list, at, max, theme.fade(theme.panel, 0.92), 8); gui.outline(list, at, max, theme.border, 8); const middle = at.y + height / 2; - gui.dot(list, .{ .x = at.x + px(21), .y = middle }, 5, if (app.linked_game != null) theme.linked else theme.text_off); + const session = &app.session; + const greeting = if (session.link_state == .linked) session.greeting else null; + gui.dot(list, .{ .x = at.x + px(21), .y = middle }, 5, if (greeting != null) theme.linked else theme.text_off); gui.sans(.semibold, fonts.size.body); // The link is to OpenReliant, which says which game it plays once it answers. - const state = if (app.linked_game) |name| gui.print("{s} · linked", .{name}) else "OpenReliant · not linked"; + const state = if (greeting) |linked| gui.print("{s} · linked", .{linked.game}) else if (session.link_state == .waiting) "OpenReliant · waiting" else "OpenReliant · not linked"; gui.drawText(list, .{ .x = at.x + px(40), .y = at.y + px(15) }, theme.text, state); gui.popFont(); gui.sans(.regular, fonts.size.ui); - const detail = if (app.settings.game_folder) |folder| gui.print("Game folder {s} · linking planned {f}", .{ std.fs.path.basename(folder), commands.plans.debugger }) else "No game folder yet"; + const detail = if (greeting) |linked| + gui.print("OpenReliant {s} · port {d}", .{ linked.openreliant, session.port }) + else if (app.settings.game_folder) |folder| + gui.print("Game folder {s}", .{std.fs.path.basename(folder)}) + else + "No game folder yet"; gui.drawText(list, .{ .x = at.x + px(40), .y = at.y + px(37) }, theme.text_muted, detail); gui.popFont(); if (app.settings.game_folder) |folder| if (c.ImGui_IsMouseHoveringRect(at, max)) gui.tooltipNow(folder); diff --git a/src/settings.zig b/src/settings.zig index 0d579e1..ffc8cbd 100644 --- a/src/settings.zig +++ b/src/settings.zig @@ -1,15 +1,18 @@ //! The editor's own settings, kept for each user where the system keeps such files (SDL's //! preferences folder for OpenReliant's editor): `editor.ini` with the game's folder, where -//! `openreliant` is and the mods opened lately, and `keys.ini` with the keys changed from their -//! defaults, each system's on its own. +//! `openreliant` is, the editor link's settings and the mods opened lately, and `keys.ini` with the +//! keys changed from their defaults, each system's on its own. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const c = @import("c"); +const openreliant = @import("openreliant"); + const commands = @import("commands.zig"); const Command = commands.Command; +const StateRate = @import("debug/session.zig").StateRate; const ini = @import("ini.zig"); const keys = @import("keys.zig"); const Chord = keys.Chord; @@ -29,6 +32,7 @@ pub const Settings = struct { /// The mod folders opened lately, the latest first. recent: std.ArrayList([]const u8) = .empty, keymap: Keymap = .{}, + link: Link = .{}, pub fn load(gpa: Allocator, io: Io) Settings { var settings: Settings = .{ .arena = .init(gpa) }; @@ -42,6 +46,7 @@ pub const Settings = struct { const entries = ini.parse(arena, text) catch &.{}; settings.game_folder = ini.get(entries, "Game", "Folder"); settings.openreliant = ini.get(entries, "Game", "OpenReliant"); + settings.link.read(entries); for (entries) |entry| { if (std.ascii.eqlIgnoreCase(entry.section, "Recent") and settings.recent.items.len < recent_count) { settings.recent.append(arena, entry.value) catch break; @@ -72,6 +77,7 @@ pub const Settings = struct { try out.writeAll("; OpenReliant Editor's settings.\n[Game]\n"); if (settings.game_folder) |path| try out.print("Folder={s}\n", .{path}); if (settings.openreliant) |path| try out.print("OpenReliant={s}\n", .{path}); + try settings.link.write(out); try out.writeAll("\n[Recent]\n"); for (settings.recent.items, 1..) |path, index| try out.print("{d}={s}\n", .{ index, path }); } @@ -106,6 +112,33 @@ pub const Settings = struct { } }; +/// The editor link's settings, `[Link]` in editor.ini. +pub const Link = struct { + /// Whether Play starts the game linked to the editor (`WhenPlaying`). + when_playing: bool = true, + /// The port the game listens at, as `--editor-link-port` gives it (`Port`). + port: u16 = openreliant.engine.link.default_port, + /// How often Variables and Watch poll the script's state while it runs (`State`: `off`, `1` + /// or `4`, in Hz). + state_rate: StateRate = .four_hz, + + const rate_names = std.EnumArray(StateRate, []const u8).init(.{ .off = "off", .one_hz = "1", .four_hz = "4" }); + + fn read(settings: *Link, entries: []const ini.Entry) void { + if (ini.get(entries, "Link", "WhenPlaying")) |value| settings.when_playing = !std.mem.eql(u8, value, "0"); + if (ini.get(entries, "Link", "Port")) |value| settings.port = std.fmt.parseInt(u16, value, 10) catch settings.port; + if (ini.get(entries, "Link", "State")) |value| { + for (std.enums.values(StateRate)) |rate| { + if (std.ascii.eqlIgnoreCase(value, rate_names.get(rate))) settings.state_rate = rate; + } + } + } + + fn write(settings: Link, out: *Io.Writer) Io.Writer.Error!void { + try out.print("\n[Link]\nWhenPlaying={d}\nPort={d}\nState={s}\n", .{ @intFromBool(settings.when_playing), settings.port, rate_names.get(settings.state_rate) }); + } +}; + /// The keys of each action on each system: the defaults, with the ones changed in keys.ini. pub const Keymap = struct { changed: std.EnumArray(keys.System, std.EnumArray(Command, ?[]const Chord)) = .initFill(.initFill(null)), @@ -193,6 +226,22 @@ fn writeFile(io: Io, folder: []const u8, name: []const u8, data: []const u8) voi }; } +test "the editor link's settings read back as written" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const changed: Link = .{ .when_playing = false, .port = 4000, .state_rate = .one_hz }; + var buffer: [256]u8 = undefined; + var out: Io.Writer = .fixed(&buffer); + try changed.write(&out); + var read_back: Link = .{}; + read_back.read(try ini.parse(arena.allocator(), out.buffered())); + try std.testing.expectEqual(changed, read_back); + // A port that isn't one leaves the default. + var defaults: Link = .{}; + defaults.read(try ini.parse(arena.allocator(), "[Link]\nPort=99999\n")); + try std.testing.expectEqual(openreliant.engine.link.default_port, defaults.port); +} + test "changed keys read back as written" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); diff --git a/src/shell.zig b/src/shell.zig index e8fe8e3..16fc910 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -388,7 +388,7 @@ fn toolbar(app: *App) void { row.next(6, widgets.divider_height); widgets.toolbarDivider(); row.next(6, widgets.tool_button_height); - const held = app.held; + const held = app.held(); if (widgets.toolButton("continue", .{ .label = "Continue", .icon = .step_forward, @@ -397,14 +397,14 @@ fn toolbar(app: *App) void { .key = if (held) app.keyText(.@"continue") else null, .disabled = app.unavailable(.@"continue"), })) app.run(.@"continue"); - inline for (.{ .{ Command.step_over, ui.icons.Icon.redo_dot }, .{ Command.step_into, ui.icons.Icon.arrow_down_to_dot }, .{ Command.pause, ui.icons.Icon.pause }, .{ Command.let_go, ui.icons.Icon.square } }) |button| { + inline for (.{ .{ Command.step_over, ui.icons.Icon.redo_dot }, .{ Command.step_into, ui.icons.Icon.arrow_down_to_dot }, .{ Command.pause, ui.icons.Icon.pause }, .{ Command.unlink, ui.icons.Icon.square } }) |button| { row.next(6, widgets.tool_button_height); const tip = if (app.keyText(button[0])) |key| gui.print("{s}, {s}", .{ commands.info(button[0]).label, key }) else commands.info(button[0]).label; if (widgets.toolButton(@tagName(button[0]), .{ .icon = button[1], .icon_only = true, .disabled = app.unavailable(button[0]), .tooltip = tip })) app.run(button[0]); } // The chips, at the toolbar's end. - var chips: [2]widgets.Chip = undefined; + var chips: [3]widgets.Chip = undefined; var chip_count: usize = 0; if (document) |open| { const found = open.problems(); @@ -417,11 +417,11 @@ fn toolbar(app: *App) void { chip_count += 1; } } - // The link is to OpenReliant, which names the game it plays once it answers. - chips[chip_count] = if (app.linked_game) |name| - .{ .style = .linked, .text = name } - else - .{ .style = .idle, .text = "OpenReliant", .extra = "not linked", .tooltip = gui.print("The editor link: planned · {f}", .{commands.plans.debugger}) }; + if (holdChip(app)) |chip| { + chips[chip_count] = chip; + chip_count += 1; + } + chips[chip_count] = linkChip(app); chip_count += 1; var total: f32 = 0; for (chips[0..chip_count]) |chip| total += widgets.chipWidth(chip) + px(8); @@ -437,6 +437,38 @@ fn toolbar(app: *App) void { gui.setCursor(.{ .x = window_pos.x, .y = top + px(toolbar_height) }); } +/// The chip of a held script, or a paused mission: where it holds, and the script's clock. +fn holdChip(app: *const App) ?widgets.Chip { + const session = &app.session; + const clock = if (session.state) |snapshot| gui.print("{d} s", .{snapshot.state.clock}) else null; + if (session.held) |held| { + const text = if (session.heldStatement()) |statement| gui.print("Held · line {d}", .{statement.at.line + 1}) else gui.print("Held · offset {d}", .{held.offset}); + return .{ .style = .held, .text = text, .extra = clock, .extra_mono = true, .tooltip = "Threads, timers, clock stopped · ships fly on" }; + } + if (session.paused) return .{ .style = .held, .text = "Paused", .extra = clock, .extra_mono = true, .tooltip = "Whole mission paused" }; + return null; +} + +/// The chip of the editor link: to OpenReliant, which names the game once it answers, then the +/// mission it plays. +pub fn linkChip(app: *const App) widgets.Chip { + const session = &app.session; + return switch (session.link_state) { + .off => .{ .style = .idle, .text = "OpenReliant", .extra = "not linked", .tooltip = "Play links · or Game · Link to game" }, + .waiting => .{ .style = .idle, .text = "OpenReliant", .extra = "waiting", .tooltip = gui.print("127.0.0.1:{d}", .{session.port}) }, + .linked => linked: { + const greeting = session.greeting.?; + const tooltip = gui.print("OpenReliant {s} · 127.0.0.1:{d}", .{ greeting.openreliant, session.port }); + const playing = session.playing orelse break :linked .{ .style = .linked, .text = greeting.game, .extra = "linked", .tooltip = tooltip }; + if (session.held != null) break :linked .{ .style = .linked, .text = greeting.game, .tooltip = tooltip }; + break :linked switch (playing.match) { + .older_build => .{ .style = .warning, .text = "Older build", .extra = "restart mission", .tooltip = "Rebuild, restart: each attempt rereads the file" }, + .this_build, .other_mission, .unknown => .{ .style = .linked, .text = greeting.game, .extra = gui.print("mission {d}", .{playing.number}), .tooltip = tooltip }, + }; + }, + }; +} + /// The toolbar's row of controls, each centred on the row as the design's flex row centres them. const ToolbarRow = struct { top: f32, @@ -498,11 +530,12 @@ fn statusBar(app: *App) void { const text = gui.print("{d} problem{s}", .{ found.len, if (found.len == 1) "" else "s" }); gui.drawText(list, .{ .x = x, .y = text_y }, theme.error_text, text); x += gui.textSize(text).x + gap; - } else if (app.held) { + } else if (app.held() or app.session.paused) { + const text = if (app.held()) "Held" else "Paused"; gui.dot(list, .{ .x = x + px(3.5), .y = middle }, 3.5, theme.accent); x += px(7) + px(6); - gui.drawText(list, .{ .x = x, .y = text_y }, theme.held_text, "Held"); - x += gui.textSize("Held").x + gap; + gui.drawText(list, .{ .x = x, .y = text_y }, theme.held_text, text); + x += gui.textSize(text).x + gap; } const cursor = document.editor.cursor(); for ([_][]const u8{ document.file, gui.print("Ln {d}, Col {d}", .{ cursor.line + 1, cursor.index + 1 }), keyHints(app) }) |part| { @@ -512,7 +545,7 @@ fn statusBar(app: *App) void { // At the end: the last build, and live changes. var right = window_pos.x + width - px(10); - const live = if (found.len > 0) "Live changes paused" else "Live changes: not linked"; + const live = if (app.session.link_state == .off) "Live changes: not linked" else "Live changes: planned"; right -= gui.textSize(live).x; gui.drawText(list, .{ .x = right, .y = text_y }, theme.text_muted, live); right -= gap; @@ -531,7 +564,7 @@ fn keyHints(app: *App) []const u8 { const views = app.settings.keymap.chords(.view_1, system); const view_key = if (views.len > 0) gui.print("{f}", .{views[0].written(system)}) else ""; const views_hint = if (view_key.len > 0) gui.print("{s} to 3 switch views", .{view_key}) else ""; - if (app.held) { + if (app.held()) { return gui.print("{s} continue · {s} step over · {s} step into · {s}", .{ app.keyText(.@"continue") orelse "", app.keyText(.step_over) orelse "", app.keyText(.step_into) orelse "", views_hint }); } return gui.print("{s} build · {s} breakpoint · {s}", .{ app.keyText(.build) orelse "", app.keyText(.toggle_breakpoint) orelse "", views_hint }); diff --git a/src/ui/gui.zig b/src/ui/gui.zig index 75ea9be..6c64614 100644 --- a/src/ui/gui.zig +++ b/src/ui/gui.zig @@ -232,6 +232,11 @@ pub fn dot(list: *c.ImDrawList, center: Vec2, radius: f32, color: Color) void { c.ImDrawList_AddCircleFilled(list, center, px(radius), color, 0); } +/// A circle's line `thickness` wide, `radius` from its centre to the middle of the line. +pub fn ring(list: *c.ImDrawList, center: Vec2, radius: f32, thickness: f32, color: Color) void { + c.ImDrawList_AddCircleEx(list, center, px(radius), color, 0, px(thickness)); +} + /// Whether the last item is under the pointer, shown as a hand for a clickable one. pub fn hovered() bool { return c.ImGui_IsItemHovered(0); diff --git a/src/ui/theme.zig b/src/ui/theme.zig index 71a6336..27cfa7e 100644 --- a/src/ui/theme.zig +++ b/src/ui/theme.zig @@ -99,6 +99,8 @@ pub const held = rgb(0x2a1a0d); pub const held_text = rgb(0xffd2a6); pub const held_dim = rgb(0xc79a73); pub const held_line = fade(accent, 0.14); +/// The row of the thread the game holds. +pub const held_row = rgb(0x1b1510); /// Breakpoints and errors. pub const breakpoint = rgb(0xe5484d); diff --git a/src/ui/widgets.zig b/src/ui/widgets.zig index 764a21d..d0c0c0e 100644 --- a/src/ui/widgets.zig +++ b/src/ui/widgets.zig @@ -62,38 +62,41 @@ const view_tab_rounding = 4; /// The view tabs' line and padding around the tabs. const view_tabs_inset = 1 + 2; +/// How wide the design's `.tb` button is with `options`: its parts with 6 pixels between them, +/// in 10 pixels of padding, or 30 pixels for an icon alone. +pub fn toolButtonWidth(options: ToolButton) f32 { + if (options.icon_only) return px(30); + gui.sans(options.weight, options.size); + defer gui.popFont(); + var width = px(10) * 2; + var parts: f32 = 0; + if (options.icon != null) { + width += px(fonts.size.icon); + parts += 1; + } + if (options.label.len > 0) { + width += gui.textSize(options.label).x; + parts += 1; + } + if (options.key) |key| { + width += keyChipWidth(key); + parts += 1; + } + if (options.pill) |pill_shown| { + width += pillWidth(pill_shown.text); + parts += 1; + } + if (parts > 1) width += px(6) * (parts - 1); + return width; +} + /// The design's `.tb` button. True when clicked. pub fn toolButton(id: []const u8, options: ToolButton) bool { const icon_size = fonts.size.icon; + const width = toolButtonWidth(options); gui.sans(options.weight, options.size); defer gui.popFont(); const label_width = if (options.label.len > 0) gui.textSize(options.label).x else 0; - const key_width = if (options.key) |key| keyChipWidth(key) else 0; - const pill_width = if (options.pill) |pill_shown| pillWidth(pill_shown.text) else 0; - var width: f32 = undefined; - if (options.icon_only) { - width = px(30); - } else { - width = px(10) * 2; - var parts: f32 = 0; - if (options.icon != null) { - width += px(icon_size); - parts += 1; - } - if (options.label.len > 0) { - width += label_width; - parts += 1; - } - if (options.key != null) { - width += key_width; - parts += 1; - } - if (options.pill != null) { - width += pill_width; - parts += 1; - } - if (parts > 1) width += px(6) * (parts - 1); - } const height = px(options.height); const at = gui.cursor(); const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = width, .y = height }, 0) and options.disabled == null; @@ -145,7 +148,7 @@ pub fn toolButton(id: []const u8, options: ToolButton) bool { if (options.key) |key| { const on_accent = options.style == .primary and options.disabled == null; drawKeyChip(list, .{ .x = x, .y = middle }, key, if (on_accent) .on_accent else .normal); - x += key_width + px(6); + x += keyChipWidth(key) + px(6); } if (options.pill) |pill_shown| drawPill(list, .{ .x = x, .y = middle }, pill_shown); @@ -764,25 +767,19 @@ pub fn checkbox(id: []const u8, label: []const u8, checked: bool, disabled: ?Dis return clicked; } -/// A switch as the status bar's Live changes: a track with a knob. -pub fn toggle(id: []const u8, label: []const u8, on: bool, disabled: ?Disabled) bool { - gui.sans(.regular, fonts.size.smaller); - defer gui.popFont(); - const track_width = px(24); - const track_height = px(13); - const width = track_width + px(7) + gui.textSize(label).x; - const height = px(20); +/// The design's switch, the settings' `.sw`: a track 30 by 16 pixels, orange while on, with a +/// knob 12 pixels wide at the end it's switched to. True when clicked. +pub fn toggle(id: []const u8, on: bool, disabled: ?Disabled) bool { + const width = px(30); + const height = px(16); const at = gui.cursor(); const clicked = c.ImGui_InvisibleButton(gui.z(id), .{ .x = width, .y = height }, 0) and disabled == null; + if (disabled == null) gui.handCursor(); const list = gui.drawList(); const middle = at.y + height / 2; - const min = gui.vec2(at.x, middle - track_height / 2); - const max = gui.vec2(at.x + track_width, middle + track_height / 2); - const lit = on and disabled == null; - gui.rect(list, min, max, if (lit) theme.accent else theme.border, 7); - const knob_x = if (on) max.x - px(2) - px(4.5) else min.x + px(2) + px(4.5); - gui.dot(list, .{ .x = knob_x, .y = middle }, 4.5, if (lit) theme.on_accent else theme.text_off); - gui.drawText(list, .{ .x = max.x + px(7), .y = gui.textTop(middle) }, if (disabled == null) theme.text_menu else theme.text_off, label); + gui.rect(list, at, .{ .x = at.x + width, .y = at.y + height }, if (on) theme.accent else theme.border, 8); + const knob_x = if (on) at.x + px(16) + px(6) else at.x + px(2) + px(6); + gui.dot(list, .{ .x = knob_x, .y = middle }, 6, if (on) theme.on_accent else theme.text_dim); if (disabled) |off| gui.tooltip(off.reason); return clicked; } diff --git a/src/views/script.zig b/src/views/script.zig index 6de08c3..9808338 100644 --- a/src/views/script.zig +++ b/src/views/script.zig @@ -98,7 +98,8 @@ fn tabs(app: *App, document: *Document) void { gui.setCursor(.{ .x = at.x, .y = at.y + height }); } -/// The line under the tabs: the file, the record the cursor is in, and its statement. +/// The line under the tabs: the file, the record the cursor is in, and its statement, with the +/// statement's offset in the script at the end, from the build's map. fn breadcrumbs(app: *App, document: *Document) void { _ = app; const at = gui.cursor(); @@ -130,12 +131,18 @@ fn breadcrumbs(app: *App, document: *Document) void { gui.popFont(); } } + if (document.mission()) |mission| if (mission.statementOn(@intCast(cursor.line))) |statement| { + gui.mono(.regular, fonts.size.small); + const offset = gui.print("script offset {d}", .{statement.offset}); + gui.drawText(list, .{ .x = at.x + width - px(12) - gui.textSize(offset).x, .y = gui.textTop(middle) }, theme.text_faint, offset); + gui.popFont(); + }; c.ImGui_Dummy(.{ .x = width, .y = height }); } -/// Which statement of its record's code the cursor's line is, and how many there are: the lines -/// of the body that hold code. -fn statementOf(document: *Document, record: game.Record, line: usize) ?[2]usize { +/// Which statement of its record's code `line` is, and how many there are: the lines of the body +/// that hold code. +pub fn statementOf(document: *Document, record: game.Record, line: usize) ?[2]usize { if (record.section != .triggers and record.section != .parts) return null; var count: usize = 0; var current: ?usize = null; @@ -411,8 +418,9 @@ fn listing(app: *App, document: *Document) void { gui.popFont(); } -/// Draws a line's number, with its breakpoint or its problem in the room before it, which a click -/// sets or clears a breakpoint in. +/// Draws a line's number, with the room before it marking where the game holds the script, a +/// breakpoint or a problem, which a click sets or clears a breakpoint in. A breakpoint the game +/// holds the script at is a dot, and one it doesn't yet, as no build of the mission plays, a ring. fn drawLineNumber(app: *App, number: *const c.OrTextLineNumber) void { const document = app.document orelse return; const list: *c.ImDrawList = @ptrCast(@alignCast(number.draw_list)); @@ -424,11 +432,17 @@ fn drawLineNumber(app: *App, number: *const c.OrTextLineNumber) void { const marks_max = gui.vec2(number.x - glyph * 0.6, number.y + number.height); const center = gui.vec2((marks_min.x + marks_max.x) / 2, middle); const breakpoint = document.hasBreakpoint(line); + const held = if (document.held_line) |held_line| held_line == line else false; var problem = false; for (document.problems()) |found| problem = problem or found.at.line == line; const hovered = c.ImGui_IsMouseHoveringRect(marks_min, marks_max) and c.ImGui_IsWindowHovered(0); - if (breakpoint) { - gui.dot(list, center, 5, theme.breakpoint); + if (held) { + // The held line's tint, from the panel's edge to the text, which the text editor tints. + const text_left = document.editor.screenPos(.{ .line = line, .index = 0 }).x; + gui.rect(list, .{ .x = c.ImGui_GetWindowPos().x, .y = number.y }, .{ .x = text_left, .y = number.y + number.height }, theme.held_line, 0); + drawHeldMarker(list, center, breakpoint); + } else if (breakpoint) { + if (app.session.mapped()) gui.dot(list, center, 5, theme.breakpoint) else gui.ring(list, center, 4.25, 1.5, theme.breakpoint); } else if (problem) { widgets.drawIcon(list, .{ .x = center.x - px(6), .y = middle }, .circle_alert, fonts.size.icon_tiny, theme.error_icon); } else if (hovered) { @@ -436,14 +450,26 @@ fn drawLineNumber(app: *App, number: *const c.OrTextLineNumber) void { } if (hovered) { c.ImGui_SetMouseCursor(c.ImGuiMouseCursor_Hand); - if (c.ImGui_IsMouseClicked(c.ImGuiMouseButton_Left)) document.toggleBreakpoint(line); + if (c.ImGui_IsMouseClicked(c.ImGuiMouseButton_Left)) app.toggleBreakpoint(line); } const text = gui.print("{d}", .{line + 1}); - const color = if (problem) theme.error_count else if (line == number.cursor_line) theme.text_menu else theme.text_faint; + const color = if (held) theme.held_text else if (problem) theme.error_count else if (line == number.cursor_line) theme.text_menu else theme.text_faint; const width = gui.textSize(text).x; gui.drawText(list, .{ .x = number.x + number.width - width, .y = gui.textTop(number.y + number.height / 2) }, color, text); } +/// The mark of the line the game holds the script at, centred on `center`: the design's orange +/// arrow, 14 by 12 pixels, with a dark dot in it where a breakpoint holds there. +fn drawHeldMarker(list: *c.ImDrawList, center: gui.Vec2, breakpoint: bool) void { + const left = center.x - px(7); + const top = center.y - px(6); + const outline = [_]gui.Vec2{ .{ .x = 1, .y = 1 }, .{ .x = 9, .y = 1 }, .{ .x = 13, .y = 6 }, .{ .x = 9, .y = 11 }, .{ .x = 1, .y = 11 } }; + var points: [outline.len]gui.Vec2 = undefined; + for (&points, outline) |*point, from| point.* = .{ .x = left + px(from.x), .y = top + px(from.y) }; + c.ImDrawList_AddConvexPolyFilled(list, &points, points.len, theme.accent); + if (breakpoint) gui.dot(list, .{ .x = left + px(5.5), .y = top + px(6) }, 2.5, theme.breakpoint_inner); +} + /// A line's decoration: a problem's message at the end of its line. fn drawDecoration(app: *App, decoration: *const c.OrTextDecoration) void { const document = app.document orelse return;