From 9c41f3c1712359ae1376de61d95360d5b664f1fa Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 00:54:16 +0200 Subject: [PATCH 1/2] feat: sltool dte decompile writes a mission as Luau source Mission source starts with reading missions as text: each record a global declaration named after its display name, the script as Luau statements, and the game's values by the names the scripting API gives them, from the same tables the game runs on. docs/formats/mission-source.md specifies the form. The source leaves out what the game works out again and parks what it never reads, such as the operand tables: mission_build_part_tables hands their entry to mission_fill_part, which pops it unread. Every shipped mission decompiles; building the text again comes next. Closes #1130 --- docs/README.md | 1 + docs/formats/dte.md | 25 +- docs/formats/mission-source.md | 297 +++++++++++++ ghidra/names/LANCER.EXE.tsv | 6 +- src/formats/dte.zig | 11 +- src/formats/dte/listing.zig | 16 +- src/formats/dte/source.zig | 19 +- src/formats/dte/source/names.zig | 146 +++++++ src/formats/dte/source/script.zig | 392 ++++++++++++++++++ src/formats/dte/source/vocabulary.zig | 90 ++++ src/formats/dte/source/write.zig | 573 ++++++++++++++++++++++++++ src/tools/sltool/dte.zig | 11 + 12 files changed, 1561 insertions(+), 26 deletions(-) create mode 100644 docs/formats/mission-source.md create mode 100644 src/formats/dte/source/names.zig create mode 100644 src/formats/dte/source/script.zig create mode 100644 src/formats/dte/source/vocabulary.zig create mode 100644 src/formats/dte/source/write.zig diff --git a/docs/README.md b/docs/README.md index 252f9cd8..3051d372 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ The [user guide](guide/README.md) covers installing, configuring and playing Ope | [`formats/bink.md`](formats/bink.md) | `.bik` movies: the Bink container. | | [`formats/frc.md`](formats/frc.md) | `.frc` force-feedback effects. | | [`formats/dte.md`](formats/dte.md) | `.DTE` missions: directory, ships, triggers, and the script VM. | +| [`formats/mission-source.md`](formats/mission-source.md) | Mission source: a mission as Luau text, which `sltool dte decompile` writes. | | [`formats/stats.md`](formats/stats.md) | Ship, gun, missile and pilot stat tables. | | [`formats/save.md`](formats/save.md) | Saved games: the IFF files of the campaign, the autosave and the restart point. | | [`formats/profile.md`](formats/profile.md) | `profile.bin`: the pilot's profile, the call sign and the pilot's record. | diff --git a/docs/formats/dte.md b/docs/formats/dte.md index 1b2b32ce..be22f2b6 100644 --- a/docs/formats/dte.md +++ b/docs/formats/dte.md @@ -66,7 +66,13 @@ the count says how much of the reserved room is filled, so most missions are exa Sections 17 to 21 and 25 are empty in all 44 missions. The engine reads nothing of sections 9, 20, 21 and 23: the binder binds 21 into a local variable of its own, and the rest into globals nothing -reads. Sections 9 and 23 hold records in some missions, likely the original editor's. Of the +reads. Sections 9 and 23 hold records in some missions, likely the original editor's. Nor does it +read the operand tables, sections 1, 22 and 26, but for a mission that leaves section 24 unused +(below): `mission_operand` (`0x004529D0`) is their only reader, and its only caller, +`mission_build_part_tables` (`0x00452F50`), hands the entry it gives, by the part's `kind`, to +`mission_fill_part` (`0x00452FD0`), which pops it unread. **Unverified:** that the original reads +nothing of section 11, which OpenReliant's engine doesn't read. [Mission +source](mission-source.md#what-the-source-leaves-out) parks these sections. Of the directory's 128 slots before the first section, at `0x400`, the binder reads the first 27. Slot 27 holds the file's size in most missions and is unused in the rest, and slots 28 on are unused in all of them. Section 24 holds one word per command of the [catalogue](#commands) (`dte.CommandFlags`): @@ -716,11 +722,13 @@ the constant table's filler, which hold stale bytes in the shipped missions. ## Mission source groundwork -Mission source compiles to a standard `.DTE` ([#1126](https://github.com/OpenReliant/openreliant/issues/1126)). -Its syntax is a subset of Luau, read only as a mission is built, and its model is the one -[Mission Source v1](https://github.com/OpenReliant/openreliant/issues/608) defines: explicit IDs, -typed references, display names apart from IDs, and nothing dropped silently. Its script is the -statements above. The first implemented slice is source identity and typed symbol resolution +Mission source compiles to a standard `.DTE` +([#1126](https://github.com/OpenReliant/openreliant/issues/1126)), and [Mission +source](mission-source.md) specifies it. Its syntax is a subset of Luau, read only as a mission is +built, and its model is the one [Mission Source +v1](https://github.com/OpenReliant/openreliant/issues/608) defines: explicit IDs, typed references, +display names apart from IDs, and nothing dropped silently. Its script is the statements above. The +first implemented slice is source identity and typed symbol resolution ([#609](https://github.com/OpenReliant/openreliant/issues/609)). [`dte/source.zig`](../../src/formats/dte/source.zig) provides `Symbols(Record)`, a table for one @@ -743,8 +751,9 @@ The table borrows immutable records and ID bytes. Rebuild it after changing IDs order: a reference then resolves to the record's new index. Display names may repeat or change without affecting resolution. The caller selects the script bank explicitly. -**Not implemented:** the Luau syntax, its parser, and building and decompiling missions as text, -which follow under [#1126](https://github.com/OpenReliant/openreliant/issues/1126). The existing +**Not implemented:** the parser, and building missions from text, which follow under +[#1126](https://github.com/OpenReliant/openreliant/issues/1126); `sltool dte decompile` writes +the text. The existing binary reader, writer and assembler remain the format implementation. ## Prior art diff --git a/docs/formats/mission-source.md b/docs/formats/mission-source.md new file mode 100644 index 00000000..e77dbb84 --- /dev/null +++ b/docs/formats/mission-source.md @@ -0,0 +1,297 @@ +# Mission source + +Mission source is a mission written as text. `sltool dte decompile` writes a mission's source from +its `.DTE` file, and `sltool dte build` compiles source into a standard `.DTE` that plays in the +original too ([#1126](https://github.com/OpenReliant/openreliant/issues/1126)). The syntax is a +subset of Luau, read as the mission is built: nothing in the file runs, in OpenReliant or anywhere +else. **Not implemented yet:** `sltool dte build` +([#1130](https://github.com/OpenReliant/openreliant/issues/1130) writes the text). + +```luau +-- mission1.dte as source. + +reliant_group = flight_group("(FG)Reliant", { wing = "player" }) + +player_ship = ship("Player_Ship", { + type = "grendel", + group = reliant_group, + position = { -11608147, -3, 11596510 }, + yaw = 90, +}) +convoy_nav_point = nav_point("Convoy Nav Point", { position = { 1234884, 0, 11312658 }, yaw = 175 }) + +convoykilled = global("(GV)convoykilled", 0) + +on(player_ship, "player_ready_to_jump", { repeats = "always" }, function() + jumping_to_convoy() + InterruptTriggerCode() + if convoykilled == 1 then + go_home_total_loss() + else + jumping_to_sherman() + end + InterruptTriggerCode() + return 1 +end) + +jumping_to_convoy = part("(F)Jumping to CONVOY", function() + -- ... + return 1 +end) +``` + +## The file + +A mission's source is one file, named `.mission.luau`, such as `mission1.mission.luau`. +At its top level it holds declarations and nothing else: + +- `name = constructor(...)` declares a record, such as a ship or a part, under `name`. +- `on(...)` declares a trigger. +- `mission(...)` gives the mission's own details. + +Comments go anywhere, and blank lines don't count. Every name is the file's: a declaration can use +any name the file declares, before or after it. A name is a Luau identifier, `[A-Za-z_][A-Za-z0-9_]*`, +that isn't a Luau keyword or one of the names this page gives the source itself. + +The order of the declarations of a kind is the order of the mission's records of that kind, since +the script and the records refer to records by their place: the first ship declared is ship 0, the +first part part 0, and so on. + +## The mission + +```luau +mission({ name = "Patrol" }) +``` + +`name` is OpenReliant's name for the mission ([OpenReliant's mission +name](dte.md#openreliants-mission-name)). A mission without the declaration has none. + +## Records + +Each record has its display name, the name the original's tools and the game show, and fields in a +table. A field left out takes its default. + +A value the game names is written by the name the scripting API gives it +([Scripting reference](../guide/reference.md)): a ship type as `"grendel"`, a pilot as +`"bandit_tigers_leader"`, an order as `"find_new_target"`, a condition as `"shot_at"`. The names come +from the tables the game runs on, so mission source, mods' scripts and the editor share them. A +value the game doesn't name, such as a ship type or an order a mod adds, stays a number. + +### Globals + +```luau +convoykilled = global("(GV)convoykilled", 0) +``` + +The display name and the value the global starts with. + +### Flight groups + +```luau +reliant_group = flight_group("(FG)Reliant", { wing = "player" }) +``` + +| Field | Value | Default | +|---|---|---| +| `wing` | The wing the group's ships are listed in: `"player"`, `"second"`, `"third"` or `"none"` | `"none"` | + +### Ships + +```luau +naginata = ship("(A1)Naginata", { + type = "naginata", + group = reliant_group, + pilot = "bandit_tigers_leader", + position = { -11616031, 187, 11597653 }, + yaw = 90, +}) +``` + +`ship` places a ship, a capital ship or a station. `nav_point`, `waypoint`, `point` and `curve_point` +place the mission's markers ([Ships](dte.md#ships)), which take the same fields but `type`. + +| Field | Value | Default | +|---|---|---| +| `type` | The ship's type | Required for `ship` | +| `group` | Its flight group | None | +| `pilot` | Its pilot, by its record of `pilotstats.bin` | None | +| `position` | `{ x, y, z }`, in the game's coordinates, Y down | `{ 0, 0, 0 }` | +| `yaw`, `pitch`, `roll` | Whole degrees; -1 where the mission leaves the angle unset | 0 | +| `launch` | `{ from = "reliant", gate = 2 }`: the type of ship it launches from, the first of that type in the mission, and the gate it launches through | It doesn't launch | +| `formation_point` | Its place in a formation: `sabres[2]` is the second point of the formation `sabres` | None | +| `tier` | The loadout tier its missile racks are fitted by; 255 asks for the campaign's | 255 | +| `marker` | For a `point` that marks a place on a curve: `{ curve = camera_curve, at = 0.5 }`, the share of the way along it | None | + +### Squads + +```luau +sabre_targets = squad("(AI)sabretargets", { sabre_1, sabre_2, component(kurgan, 3), escort_group }) +``` + +The display name and the members in their order: ships, a ship's component as `component(ship, +index)`, flight groups and other squads. + +### Formations + +```luau +sabres = formation("sabres", { { 0, 0, 0 }, { -400, 0, 300 }, { 400, 0, 300 } }) +``` + +The display name and the formation's points, each where it stands from the formation's origin. A +ship names a point as `sabres[1]` for the first. + +### Curves + +```luau +camera_curve = curve({ + start = convoy_nav_point, finish = escort_point, + from = { 1234884, 0, 11312658 }, to = { 1240000, 0, 11300000 }, + leaving_handle = handle_1, arriving_handle = handle_2, + leaving = { 10, 0, 0 }, arriving = { 0, 0, -10 }, +}) +``` + +A curve has no display name. `start` and `finish` are the ships it runs between, `from` and `to` +their places, `leaving_handle` and `arriving_handle` the ships its tangents are drawn to, and +`leaving` and `arriving` the tangents ([Curves](dte.md#curves)). A ship left out is none. + +### Parts + +```luau +launchfunction = part("(F)launchfunction", function() + -- ... + return 1 +end, { start = true }) + +go_home = part("(F)GO HOME (Total Loss)", function() + -- ... + return 1 +end) + +unused = part("(F)Not written yet") +``` + +The display name and the part's code, a function whose parameters are the part's arguments. A part +without a function has no block. `start = true` runs it as the mission starts, before any trigger is +armed. + +### Triggers + +```luau +on(black_guard, "shot_at", { repeats = "always", attacker = any_ship }, + function(attacker, shield_damage, hull_damage, victim, weapon_fired) + if hull_damage >= 50 then + -- ... + end + return 1 + end) +``` + +`on` takes the trigger's subject, the ship, flight group or squad whose events it watches, its +condition, its fields, which can be left out, and the code its events run. The function's parameters are the values the +condition's events carry, named after their labels in the condition catalogue +([Conditions](dte.md#conditions)). + +| Field | Value | Default | +|---|---|---| +| `repeats` | `"once"`, which disarms the trigger as it fires; `"always"`; or `{ counted = 3 }`, which fires that many times each time the script arms it, with `left = 1` where fewer firings are left as the mission starts | `"once"` | +| `component` | The subject's component it watches, by its index | The subject itself | +| `deferred` | `true` leaves the thread to the scheduler rather than running it inside the event | `false` | +| A value's name | What the event's value must be, for the values the condition checks: a number, a ship, flight group or squad, or `any_ship` for any of the players' ships | Not checked | + +The triggers are in the order of their declarations, and a subject's triggers must follow one +another. A trigger whose subject is `nil` belongs to no object, so it never fires. A trigger without +a function runs nothing. + +Triggers that share their code name it once: + +```luau +convoy_hit = handler(function(attacker, shield_damage, hull_damage, victim, weapon_fired) + -- ... + return 1 +end) +on(convoy_1, "shot_at", { repeats = "always" }, convoy_hit) +on(convoy_2, "shot_at", { repeats = "always" }, convoy_hit) +``` + +## The script + +The code of parts and triggers is the script's statements ([Statements](dte.md#statements)), in +Luau's syntax: + +| Statement | Source | +|---|---| +| A command | `SetAI(sabre_targets, "find_new_target", 1, nil)`. A command the catalogue doesn't have is `command(250, ...)` | +| A part called | `go_home()`, with its arguments | +| A part started on a thread of its own | `spawn(go_home)`, with its arguments after the part | +| An assignment | `convoykilled = 1`, `convoykilled += 1`, and `-=`, `*=` and `/=`. Floating-point stores are `add_assign_f(target, value)` and the like | +| `if` | `if ... then ... elseif ... else ... end`. An `else` with nothing in it is kept | +| A random choice | `local roll = random()`, then `if roll < 50 then ... elseif roll < 100 then ... else ... end`: one roll below 100, and the first arm whose threshold is above it, the `else` being the default | +| `return` | `return 1` | + +| Expression | Source | +|---|---| +| A number | `50`. Where the parameter takes a named value, it is written by its name: `"find_new_target"` for an order, `"shot_at"` for a condition, and a part's name for a part. A value the game doesn't name stays a number | +| Text | `"new_sim02.wav"`, as a Luau string | +| A ship, flight group, squad, curve or global | Its name | +| A ship's component | `component(ship, 3)` | +| None | `nil` | +| One of the game's variables | `game.mission_over`, by its name ([The game's variables](../engine/script-vm.md#the-games-variables)), or `game[37]` for one without | +| A part's argument | Its parameter | +| An event's value | Its parameter, in a trigger's function; `thread_value(1)` in a part, for the value of the event that started the thread | +| A value an object keeps of its last event | `last_event(ship, "shot_at", "Hull Damage")` | +| The last command's result | `result()` | +| A comparison | `==`, `~=`, `<`, `<=`, `>`, `>=`; in floating point `greater_f(a, b)` and the like | +| `and`, `or` | `and`, `or` | +| Arithmetic | `+`, `-`, `*`, `/`; in floating point `add_f(a, b)` and the like | +| Membership | `in_flight_group(ship, group)`, `not_in_flight_group`, `in_squad(object, squad)`, `not_in_squad` | + +The script has no loops, since a branch only goes forward. A thread waits with commands such as +`Wait`, and runs again on a timer or a trigger. + +## What the source leaves out + +The source holds what the game reads of a mission. It leaves out: + +- what the game works out again as it binds the mission or starts its script: the ships' runtime + places and angles, which equal the authored ones in every shipped mission, the objects' trigger + slices, a flight group's ships, the parts' and triggers' places in the script, the string pool, + and the directory; +- the script's flags, which the editor link sets ([The editor link](../engine/editor-link.md)). + +The rest is parked as unknown and unused: fields and sections that nothing in the game reads. The +compiler writes them as the shipped missions most often hold them. OpenReliant could keep data of +its own there, as it keeps the mission's name in section 21, since the original ignores them. +**Unverified:** that the original reads none of them; OpenReliant's engine, which follows the +original's disassembly, reads none. + +| Where | What | +|---|---| +| Sections 1, 22 and 26 | The operand tables. `mission_build_part_tables` (`0x00452F50`) hands an entry of one, by the part's `kind`, to `mission_fill_part` (`0x00452FD0`), which pops it unread | +| Sections 9, 11 and 23 | Unread ([Directory](dte.md#directory)) | +| A ship's bytes `0x06`, `0x16`, `0x1A`, `0x2A`, `0x37`, `0x3C`, `0x3E` and `0x42` | Unread | +| A trigger's bytes `0x04` to `0x13`, `0x17`, `0x18` and `0x1B`, and its armed flag | Unread; the game arms every trigger an object's slice holds as the mission starts | +| A trigger's firings left, but for `{ counted = n }` | Read only for a counted trigger | +| A part's bytes but its name, flags and argument count, and its `kind` | Unread, but for the operand tables above | +| A curve's last word, a squad member's last bytes, the globals' and the flight groups' unused words | Unread | +| The objects' tool flags | For the original's tools ([Objects](dte.md#objects)) | +| A random choice's arms' last byte | Unread | + +## References past the records + +A reference to a record that the mission doesn't have, such as a curve's ship in some of the +leftover missions, is written by its index: `ship_index(59031)`, and likewise `flight_group_index`, +`squad_index`, `curve_index`, `global_index`, `part_index`, `object_index`, `formation_index` and +`formation_point_index`. + +## The round trip + +`sltool dte decompile` names each record after its display name: the name in lower case, a leading +tag such as `(F)` or `(GV)` left out, each run of other characters an underscore, and an underscore +where a small letter meets a capital, with a number after it where two would clash. The name is the record's from then on, and renaming the display +name doesn't change it. + +Built again, a decompiled mission is the same mission: the same records and routines, decoded, with +names compared as text and references by what they name. The string pool's layout, its stale +strings, the sections' layout and the parked bytes may differ. To keep a shipped mission byte for +byte, edit its `.DTE` in place instead. diff --git a/ghidra/names/LANCER.EXE.tsv b/ghidra/names/LANCER.EXE.tsv index 097b97e2..235eaeab 100644 --- a/ghidra/names/LANCER.EXE.tsv +++ b/ghidra/names/LANCER.EXE.tsv @@ -69,8 +69,8 @@ address kind name type comment 00452a20 function mission_bind_section Binds the next directory entry to a section's count and pointer, noting its format flags 0045cb40 function mission_alloc_part_tables void __fastcall (void) Allocates the part tables, 256 entries each 0045cb80 function mission_alloc_table Frees and reallocates a table of count x size bytes -00452f50 function mission_build_part_tables void __fastcall (void) Fills the runtime part tables from sections 8 and 17 -00452fd0 function mission_fill_part void __fastcall (MissionPart *part, VmFunction *entry) block = script + start * 2, arguments from the descriptor +00452f50 function mission_build_part_tables void __fastcall (void) Fills the runtime part tables from sections 8 and 17. For each part of section 8 it hands mission_fill_part the operand mission_operand gives at index 0 of the bank the part's kind names, and 0 for those of section 17 +00452fd0 function mission_fill_part void __fastcall (MissionPart *part, VmFunction *entry, void *operand) block = script + start * 2, arguments from the descriptor. The operand, a third argument on the stack, it pops unread (RET 4), so the game reads nothing of the operand tables 00453020 function part_in_table_b uint __fastcall (MissionPart *part) Whether a part descriptor lies outside section 8, in section 17 00525f3c data mission_operands_a Section 1 005294f8 data mission_globals MissionGlobal * Section 2 @@ -99,7 +99,7 @@ address kind name type comment 00525fb4 data mission_script_b byte * Section 18 00529510 data mission_script_b_halfwords ushort 00525f9c data mission_command_flags ushort * Section 24, one per command -004529d0 function mission_operand void * __fastcall (ushort index, char bank) Operand index of the table bank names: 0 section 1, 1 section 22, 2 section 26 +004529d0 function mission_operand void * __fastcall (ushort index, char bank) Operand index of the table bank names: 0 section 1, 1 section 22, 2 section 26. Its one caller, mission_build_part_tables, hands the operand to mission_fill_part, which pops it unread 00525278 data mission_operands_b Section 22, the second operand table 0052570c data mission_operands_c Section 26, the third operand table 00525f18 data mission_formations Formation * Section 14, the formations diff --git a/src/formats/dte.zig b/src/formats/dte.zig index de5e7101..d95ec24f 100644 --- a/src/formats/dte.zig +++ b/src/formats/dte.zig @@ -250,6 +250,8 @@ pub const Ship = extern struct { /// The `formation_point` of a ship in no formation. pub const no_formation_point: u16 = 0xFFFF; + /// The `tier` most records hold, which asks for the campaign's loadout tier. + pub const campaign_tier: u8 = 0xFF; /// The `kind` of a waypoint: a point a flight group's Patrol Route flies through, in the order /// the mission lists them. @@ -1693,6 +1695,10 @@ pub const Mission = struct { return mission.records(FlightGroup, .flight_groups); } + pub fn squadMembers(mission: Mission) Error![]align(1) const SquadMember { + return mission.records(SquadMember, .squad_members); + } + pub fn squads(mission: Mission) Error![]align(1) const Squad { return mission.records(Squad, .squads); } @@ -1824,13 +1830,10 @@ pub const testing = struct { made.kind = kind; made.pilot = Ship.no_pilot; made.launch_gate = Ship.no_launch; - made.tier = campaign_tier; + made.tier = Ship.campaign_tier; return made; } - /// The `tier` of a record fitted by the campaign's loadout tier (`Ship.tier`). - const campaign_tier = 0xFF; - /// `count` ship records of `kind`, each in no flight group, its object ID its index. pub fn ships(comptime count: usize, kind: u16) [count]Ship { var made: [count]Ship = undefined; diff --git a/src/formats/dte/listing.zig b/src/formats/dte/listing.zig index d89c5da8..0b553a2a 100644 --- a/src/formats/dte/listing.zig +++ b/src/formats/dte/listing.zig @@ -9,6 +9,7 @@ const dte = @import("../dte.zig"); const layout = @import("../layout.zig"); const commands = @import("../../engine/game/executor/commands.zig"); const orders = @import("../../engine/game/ai/orders.zig"); +const vocabulary = @import("source/vocabulary.zig"); /// How a listing names the Executor's commands. pub const Commands = union(enum) { @@ -108,7 +109,7 @@ pub fn writeInstructionAt(writer: *Writer, mission: dte.Mission, instructions: [ try writeInstruction(writer, mission, instruction, constants, options); if (options.commands != .starlancer) return; const argument = argumentOf(instructions, index) orelse return; - if (!std.mem.eql(u8, argument.label, ai_mode)) return; + if (vocabulary.of(argument.param) != .order) return; const number = pushedValue(instruction, constants) orelse return; const order: orders.Order = @fromBackingInt(std.math.cast(i16, number) orelse return); const info = orders.info(order) orelse return; @@ -119,14 +120,11 @@ pub fn writeInstructionAt(writer: *Writer, mission: dte.Mission, instructions: [ } } -/// The label of the parameter that takes an order, `SetAI`'s. -const ai_mode = "AI Mode"; - -/// A command's parameter that a push fills: its place among the command's parameters, and its -/// label in the command catalogue. +/// A command's parameter that a push fills: its place among the command's parameters, and the +/// parameter in the command catalogue. const Argument = struct { - param: usize, - label: []const u8, + index: usize, + param: commands.Param, }; /// The parameter instruction `index` of `instructions` pushes the argument for: where the @@ -145,7 +143,7 @@ fn argumentOf(instructions: []const dte.Instruction, index: usize) ?Argument { if (!pushesOne(instructions[at - 1].opcode) or !follows(instructions, at)) return null; } const param = index - first; - return .{ .param = param, .label = command.params[param].label }; + return .{ .index = param, .param = command.params[param] }; } /// Whether instruction `index` starts where the one before it ends, so that nothing reached from diff --git a/src/formats/dte/source.zig b/src/formats/dte/source.zig index 92e35c4d..f39d864f 100644 --- a/src/formats/dte/source.zig +++ b/src/formats/dte/source.zig @@ -1,8 +1,19 @@ -//! Mission source identity and references (#608). Symbol tables work with the existing format -//! records without copying their fields or keeping a separate list of record kinds. +//! Mission source: a mission as text ([Mission source](../../../docs/formats/mission-source.md)). +//! Its children write it, and this file holds the identity and references its records have +//! (#608): symbol tables that work with the existing format records without copying their fields +//! or keeping a separate list of record kinds. const std = @import("std"); const Allocator = std.mem.Allocator; +/// The names records are declared under. +pub const names = @import("source/names.zig"); +/// The names of the game's values. +pub const vocabulary = @import("source/vocabulary.zig"); +/// The script as text. +pub const script = @import("source/script.zig"); +/// A mission as text. +pub const write = @import("source/write.zig"); + /// Whether `id` matches the source grammar `[A-Za-z_][A-Za-z0-9_-]*`. pub fn validId(id: []const u8) bool { if (id.len == 0 or !(std.ascii.isAlphabetic(id[0]) or id[0] == '_')) return false; @@ -163,3 +174,7 @@ test "new record types and independent script banks use the same symbol table" { try std.testing.expectEqual(11, (try bank_a.resolve(.{ .id = "start" })).payload); try std.testing.expectEqual(22, (try bank_b.resolve(.{ .id = "start" })).payload); } + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/formats/dte/source/names.zig b/src/formats/dte/source/names.zig new file mode 100644 index 00000000..cf23d67d --- /dev/null +++ b/src/formats/dte/source/names.zig @@ -0,0 +1,146 @@ +//! The names mission source declares records under +//! ([Mission source](../../../../docs/formats/mission-source.md#the-round-trip)): Luau identifiers +//! made from the records' display names, each unique in its file. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Luau's keywords, the contextual ones too, which no name can be. +const keywords = [_][]const u8{ + "and", "break", "continue", "do", "else", "elseif", "end", "export", + "false", "for", "function", "if", "in", "local", "nil", "not", + "or", "repeat", "return", "then", "true", "type", "until", "while", +}; + +/// The names mission source gives itself, which no record's name can be either: its constructors, +/// its functions, and the forms the script writes some opcodes in. +pub const reserved = [_][]const u8{ + "mission", "global", "flight_group", "ship", "nav_point", + "waypoint", "point", "curve_point", "squad", "component", + "formation", "curve", "part", "handler", "on", + "any_ship", "command", "spawn", "random", "result", + "game", "thread_value", "last_event", "in_flight_group", "not_in_flight_group", + "in_squad", "not_in_squad", "greater_f", "greater_equal_f", "less_f", + "less_equal_f", "add_f", "sub_f", "mul_f", "div_f", + "add_assign_f", "sub_assign_f", "mul_assign_f", "div_assign_f", "ship_index", + "flight_group_index", "squad_index", "curve_index", "global_index", "part_index", + "object_index", "formation_index", "formation_point_index", +}; + +/// Whether `name` is taken by Luau or by mission source itself, a part's argument such as `arg0` +/// among them. +pub fn isReserved(name: []const u8) bool { + for (keywords ++ reserved) |word| { + if (std.mem.eql(u8, word, name)) return true; + } + const number = std.mem.cutPrefix(u8, name, "arg") orelse return false; + return number.len > 0 and for (number) |byte| { + if (!std.ascii.isDigit(byte)) break false; + } else true; +} + +/// Makes the names of a file's records, each unique among those it has made. +pub const Namer = struct { + arena: Allocator, + taken: std.StringHashMapUnmanaged(void) = .empty, + + pub fn init(arena: Allocator) Namer { + return .{ .arena = arena }; + } + + /// Keeps `name` from the records, such as a name the script gives an event's value. + pub fn reserve(namer: *Namer, name_taken: []const u8) Allocator.Error!void { + try namer.taken.put(namer.arena, name_taken, {}); + } + + /// A name for record `index` of its kind, `kind`, whose display name is `display`: the display + /// name in lower case, a leading tag of letters such as `(F)` or `(GV)` left out, each run of + /// other characters an underscore, and an underscore where a small letter meets a capital. The + /// kind and the index stand in where that leaves nothing, and the kind goes before a name that + /// starts with a digit and after one that is reserved. A name already made gets `_2`, `_3` and + /// so on after it. + pub fn name(namer: *Namer, display: []const u8, kind: []const u8, index: usize) Allocator.Error![]const u8 { + var made: std.ArrayList(u8) = .empty; + var gap = false; + var after_small = false; + for (withoutTag(display)) |byte| { + if (std.ascii.isAlphanumeric(byte)) { + if ((gap or (after_small and std.ascii.isUpper(byte))) and made.items.len > 0) try made.append(namer.arena, '_'); + try made.append(namer.arena, std.ascii.toLower(byte)); + gap = false; + after_small = std.ascii.isLower(byte); + } else { + gap = true; + after_small = false; + } + } + const base: []const u8 = if (made.items.len == 0) + try std.fmt.allocPrint(namer.arena, "{s}_{d}", .{ kind, index }) + else if (std.ascii.isDigit(made.items[0])) + try std.fmt.allocPrint(namer.arena, "{s}_{s}", .{ kind, made.items }) + else if (isReserved(made.items)) + try std.fmt.allocPrint(namer.arena, "{s}_{s}", .{ made.items, kind }) + else + made.items; + var unique = base; + var count: usize = 2; + while (namer.taken.contains(unique)) : (count += 1) unique = try std.fmt.allocPrint(namer.arena, "{s}_{d}", .{ base, count }); + try namer.taken.put(namer.arena, unique, {}); + return unique; + } +}; + +/// `display` without a leading tag of letters in brackets, such as `(F)`, `` or `(GV)`, which +/// the developers' tools put before names to say what they name. +fn withoutTag(display: []const u8) []const u8 { + if (display.len < 3) return display; + const close: u8 = switch (display[0]) { + '(' => ')', + '<' => '>', + else => return display, + }; + const end = std.mem.findScalar(u8, display, close) orelse return display; + if (end < 2) return display; + for (display[1..end]) |byte| if (!std.ascii.isAlphabetic(byte)) return display; + return display[end + 1 ..]; +} + +/// Whether `name` is a Luau identifier that no keyword or name of the source's own takes. +pub fn isName(name: []const u8) bool { + if (name.len == 0 or !(std.ascii.isAlphabetic(name[0]) or name[0] == '_')) return false; + for (name[1..]) |byte| if (!(std.ascii.isAlphanumeric(byte) or byte == '_')) return false; + return !isReserved(name); +} + +test Namer { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + var namer: Namer = .init(arena_state.allocator()); + try std.testing.expectEqualStrings("jumping_to_convoy", try namer.name("(F)Jumping to CONVOY", "part", 1)); + try std.testing.expectEqualStrings("convoykilled", try namer.name("(GV)convoykilled", "global", 0)); + try std.testing.expectEqualStrings("start_launch", try namer.name("Start Launch", "part", 0)); + // A tag with a digit is part of the name. + try std.testing.expectEqualStrings("a1_naginata", try namer.name("(A1)Naginata", "ship", 1)); + try std.testing.expectEqualStrings("player_ship", try namer.name("Player_Ship", "ship", 0)); + try std.testing.expectEqualStrings("viper_s_coyote", try namer.name("(WL)Viper's Coyote", "ship", 5)); + // A small letter meeting a capital starts a word; capitals together and digits don't. + try std.testing.expectEqualStrings("jumped_in_ship", try namer.name("JumpedInShip", "value", 0)); + try std.testing.expectEqualStrings("ussr_sabre1b", try namer.name("USSR_Sabre1b", "ship", 6)); + // A name taken, nothing left, a leading digit, and a reserved word. + try std.testing.expectEqualStrings("player_ship_2", try namer.name("player ship", "ship", 9)); + try std.testing.expectEqualStrings("curve_3", try namer.name("", "curve", 3)); + try std.testing.expectEqualStrings("ship_1st_wing", try namer.name("1st wing", "ship", 4)); + try std.testing.expectEqualStrings("end_part", try namer.name("(F)end", "part", 7)); + try std.testing.expectEqualStrings("game_global", try namer.name("game", "global", 2)); +} + +test isName { + try std.testing.expect(isName("convoy_hit")); + try std.testing.expect(isName("_private")); + try std.testing.expect(!isName("2nd")); + try std.testing.expect(!isName("with-dash")); + try std.testing.expect(!isName("repeat")); + try std.testing.expect(!isName("ship")); + try std.testing.expect(!isName("arg3")); + try std.testing.expect(isName("argument")); +} diff --git a/src/formats/dte/source/script.zig b/src/formats/dte/source/script.zig new file mode 100644 index 00000000..2cb1fb84 --- /dev/null +++ b/src/formats/dte/source/script.zig @@ -0,0 +1,392 @@ +//! The script's statements as Luau text, as mission source writes them +//! ([The script](../../../../docs/formats/mission-source.md#the-script)). + +const std = @import("std"); +const Writer = std.Io.Writer; + +const statements = @import("../statements.zig"); +const Statement = statements.Statement; +const Expression = statements.Expression; +const Operator = statements.Operator; +const commands = @import("../../../engine/game/executor/commands.zig"); +const conditions = @import("../../../engine/vm/conditions.zig"); +const names = @import("names.zig"); +const vocabulary = @import("vocabulary.zig"); +const vm = @import("../../../engine/vm.zig"); + +/// What the text names the mission's records by: their names, by their index among their kind. +pub const Names = struct { + ships: []const []const u8 = &.{}, + flight_groups: []const []const u8 = &.{}, + squads: []const []const u8 = &.{}, + curves: []const []const u8 = &.{}, + globals: []const []const u8 = &.{}, + parts: []const []const u8 = &.{}, + /// The name of each object's record, by its object ID, where it has one. + objects: []const ?[]const u8 = &.{}, + + /// The record of object `id`: a ship, a flight group or a squad. + pub fn object(record_names: Names, id: u32) Indexed { + const list = record_names.objects; + if (id < list.len and list[id] != null) return .{ .name = list[id].? }; + return .{ .fallback = .{ "object_index", id } }; + } +}; + +/// A record, by its name, or by its index form, such as `ship_index(59031)`, where its index lies +/// past the mission's records ([References past the +/// records](../../../../docs/formats/mission-source.md#references-past-the-records)). +pub const Indexed = union(enum) { + name: []const u8, + fallback: struct { []const u8, u32 }, + + pub fn format(record: Indexed, w: *Writer) Writer.Error!void { + switch (record) { + .name => |name| try w.writeAll(name), + .fallback => |fallback| try w.print("{s}({d})", .{ fallback[0], fallback[1] }), + } + } +}; + +/// Record `index` of `list`, whose index form is `fallback`. +pub fn indexed(list: []const []const u8, index: u32, fallback: []const u8) Indexed { + return if (index < list.len) .{ .name = list[index] } else .{ .fallback = .{ fallback, index } }; +} + +/// Where the statements are: in a trigger's function, whose parameters are its event's values, or +/// in a part's. +pub const Scope = struct { + /// The names of the event's values, a trigger's function's parameters. + event: []const []const u8 = &.{}, +}; + +/// The names a trigger's function gives its event's values: their labels in the condition +/// catalogue, as names. A file holds no record of these names (`names.Namer.reserve`), so that +/// none is hidden in a trigger's function. +pub fn eventNames(arena: std.mem.Allocator, values: []const conditions.Value) std.mem.Allocator.Error![]const []const u8 { + const list = try arena.alloc([]const u8, values.len); + var namer: names.Namer = .init(arena); + for (list, values, 0..) |*item, value, index| item.* = try namer.name(value.label, "value", index); + return list; +} + +/// The parameters of a part's function: `arg0`, `arg1` and so on, as the script numbers its +/// arguments. +pub fn writeParameters(writer: *Writer, count: usize) Writer.Error!void { + for (0..count) |index| { + if (index > 0) try writer.writeAll(", "); + try writer.print("arg{d}", .{index}); + } +} + +/// Writes `body`, a line for each statement, indented by `depth` levels. +pub fn write(writer: *Writer, body: []const Statement, record_names: *const Names, scope: Scope, depth: usize) Writer.Error!void { + const printer: Printer = .{ .writer = writer, .names = record_names, .scope = scope }; + try printer.block(body, depth); +} + +const Printer = struct { + writer: *Writer, + names: *const Names, + scope: Scope, + + fn block(p: Printer, body: []const Statement, depth: usize) Writer.Error!void { + for (body) |line| try p.statement(line, depth); + } + + fn indent(p: Printer, depth: usize) Writer.Error!void { + try p.writer.splatByteAll(' ', 4 * depth); + } + + fn statement(p: Printer, line: Statement, depth: usize) Writer.Error!void { + try p.indent(depth); + switch (line) { + .command => |command| { + const entry = commands.find(command.index); + if (entry) |known| { + try p.writer.print("{s}(", .{known.name}); + } else { + try p.writer.print("command({d}", .{command.index}); + } + for (command.arguments, 0..) |argument, index| { + if (index > 0 or entry == null) try p.writer.writeAll(", "); + const kind = if (entry) |known| if (index < known.params.len) vocabulary.of(known.params[index]) else null else null; + try p.expression(argument, kind, .lowest); + } + try p.writer.writeAll(")\n"); + }, + .call => |call| { + try p.writer.print("{f}", .{indexed(p.names.parts, call.part, "part_index")}); + try p.arguments(call.arguments, false); + }, + .spawn => |call| { + try p.writer.print("spawn({f}", .{indexed(p.names.parts, call.part, "part_index")}); + try p.arguments(call.arguments, true); + }, + .assign => |assign| { + const operator_text: ?[]const u8 = switch (assign.update) { + .assign => "=", + .add_assign => "+=", + .sub_assign => "-=", + .mul_assign => "*=", + .div_assign => "/=", + .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f => null, + }; + if (operator_text) |text| { + try p.target(assign.target); + try p.writer.print(" {s} ", .{text}); + try p.expression(assign.value, null, .lowest); + try p.writer.writeByte('\n'); + } else { + try p.writer.print("{t}(", .{assign.update}); + try p.target(assign.target); + try p.writer.writeAll(", "); + try p.expression(assign.value, null, .lowest); + try p.writer.writeAll(")\n"); + } + }, + .@"if" => |branch| try p.ifStatement(branch, depth), + .random => |random| try p.randomStatement(random, depth), + .@"return" => |value| { + try p.writer.writeAll("return "); + try p.expression(value, null, .lowest); + try p.writer.writeByte('\n'); + }, + } + } + + /// A call's arguments and its closing bracket, after a first argument where `after` says so. + fn arguments(p: Printer, list: []const Expression, after: bool) Writer.Error!void { + if (!after) try p.writer.writeByte('('); + for (list, 0..) |argument, index| { + if (index > 0 or after) try p.writer.writeAll(", "); + try p.expression(argument, null, .lowest); + } + try p.writer.writeAll(")\n"); + } + + /// `if`, `elseif` for an `else` that holds one `if` alone, and `else`. + fn ifStatement(p: Printer, first: statements.If, depth: usize) Writer.Error!void { + var branch = first; + try p.writer.writeAll("if "); + while (true) { + try p.expression(branch.condition, null, .lowest); + try p.writer.writeAll(" then\n"); + try p.block(branch.then, depth + 1); + const otherwise = branch.otherwise orelse break; + if (otherwise.len == 1 and otherwise[0] == .@"if") { + branch = otherwise[0].@"if"; + try p.indent(depth); + try p.writer.writeAll("elseif "); + continue; + } + try p.indent(depth); + try p.writer.writeAll("else\n"); + try p.block(otherwise, depth + 1); + break; + } + try p.indent(depth); + try p.writer.writeAll("end\n"); + } + + /// A random choice: one roll, then an `if` over its arms' thresholds, and the default as `else`. + fn randomStatement(p: Printer, random: statements.Random, depth: usize) Writer.Error!void { + try p.writer.writeAll("local roll = random()\n"); + for (random.arms, 0..) |arm, index| { + try p.indent(depth); + try p.writer.print("{s} roll < {d} then\n", .{ if (index == 0) "if" else "elseif", arm.threshold }); + try p.block(arm.body, depth + 1); + } + if (random.default.len > 0) { + try p.indent(depth); + try p.writer.writeAll("else\n"); + try p.block(random.default, depth + 1); + } + try p.indent(depth); + try p.writer.writeAll("end\n"); + } + + fn target(p: Printer, to: statements.Target) Writer.Error!void { + switch (to) { + .global => |index| try p.writer.print("{f}", .{indexed(p.names.globals, index, "global_index")}), + .variable => |number| try p.variable(number), + .argument => |number| try p.writer.print("arg{d}", .{number}), + } + } + + fn variable(p: Printer, number: u8) Writer.Error!void { + if (vocabulary.variable(number)) |known| return p.writer.print("game.{s}", .{known}); + try p.writer.print("game[{d}]", .{number}); + } + + /// How tightly an operator binds in Luau, the loosest first. + const Precedence = enum(u8) { lowest, @"or", @"and", comparison, sum, product, atom }; + + fn precedence(operator: Operator) ?Precedence { + return switch (operator) { + .logical_or => .@"or", + .logical_and => .@"and", + .equal, .not_equal, .greater, .greater_equal, .less, .less_equal => .comparison, + .add, .sub => .sum, + .mul, .div => .product, + // The rest are written as calls. + .greater_f, .greater_equal_f, .less_f, .less_equal_f, .add_f, .sub_f, .mul_f, .div_f => null, + .in_flight_group, .not_in_flight_group, .in_squad, .not_in_squad => null, + }; + } + + fn symbol(operator: Operator) []const u8 { + return switch (operator) { + .logical_or => "or", + .logical_and => "and", + .equal => "==", + .not_equal => "~=", + .greater => ">", + .greater_equal => ">=", + .less => "<", + .less_equal => "<=", + .add => "+", + .sub => "-", + .mul => "*", + .div => "/", + else => @tagName(operator), + }; + } + + /// Writes `value`, where `kind` names the value a command's parameter takes, inside an + /// operator that binds as `outer` does. + fn expression(p: Printer, value: Expression, kind: ?vocabulary.Kind, outer: Precedence) Writer.Error!void { + const w = p.writer; + switch (value) { + .number => |number| { + if (kind) |named| { + if (named == .part) return w.print("{f}", .{indexed(p.names.parts, number, "part_index")}); + if (vocabulary.name(named, number)) |text| return w.print("\"{s}\"", .{text}); + } + try w.print("{d}", .{number}); + }, + .text => |text| try writeString(w, text), + .ship => |index| try w.print("{f}", .{indexed(p.names.ships, index, "ship_index")}), + .component => |component| try w.print("component({f}, {d})", .{ indexed(p.names.ships, component.ship, "ship_index"), component.component }), + .flight_group => |index| try w.print("{f}", .{indexed(p.names.flight_groups, index, "flight_group_index")}), + .squad => |index| try w.print("{f}", .{indexed(p.names.squads, index, "squad_index")}), + .curve => |index| try w.print("{f}", .{indexed(p.names.curves, index, "curve_index")}), + .null => try w.writeAll("nil"), + .global => |index| try w.print("{f}", .{indexed(p.names.globals, index, "global_index")}), + .variable => |number| try p.variable(number), + .argument => |number| try w.print("arg{d}", .{number}), + .local => |number| { + if (number < p.scope.event.len) return w.writeAll(p.scope.event[number]); + try w.print("thread_value({d})", .{number}); + }, + .event_value => |event| { + try w.print("last_event({f}", .{p.names.object(event.object)}); + const condition = conditions.find(event.condition); + if (vocabulary.name(.condition, event.condition)) |text| { + try w.print(", \"{s}\", ", .{text}); + } else { + try w.print(", {d}, ", .{event.condition}); + } + if (condition != null and event.value < condition.?.values.len) { + try writeString(w, condition.?.values[event.value].label); + } else { + try w.print("{d}", .{event.value}); + } + try w.writeByte(')'); + }, + .result => try w.writeAll("result()"), + .binary => |binary| { + const own = precedence(binary.operator) orelse { + try w.print("{s}(", .{symbol(binary.operator)}); + try p.expression(binary.left, null, .lowest); + try w.writeAll(", "); + try p.expression(binary.right, null, .lowest); + return w.writeByte(')'); + }; + const bracket = @backingInt(own) <= @backingInt(outer) and outer != .lowest; + if (bracket) try w.writeByte('('); + try p.expression(binary.left, null, @fromBackingInt(@backingInt(own) - 1)); + try w.print(" {s} ", .{symbol(binary.operator)}); + try p.expression(binary.right, null, own); + if (bracket) try w.writeByte(')'); + }, + } + } +}; + +/// `text` as a Luau string: printable characters as they are, and the rest escaped. +pub fn writeString(writer: *Writer, text: []const u8) Writer.Error!void { + try writer.writeByte('"'); + for (text) |byte| switch (byte) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\t' => try writer.writeAll("\\t"), + ' '...'!', '#'...'[', ']'...'~' => try writer.writeByte(byte), + else => try writer.print("\\x{x:0>2}", .{byte}), + }; + try writer.writeByte('"'); +} + +comptime { + // What the script writes as calls is reserved, so that no record takes its name. + @setEvalBranchQuota(100_000); + for (std.enums.values(Operator)) |operator| { + if (Printer.precedence(operator) == null) std.debug.assert(names.isReserved(@tagName(operator))); + } + for ([_]statements.Update{ .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f }) |update| { + std.debug.assert(names.isReserved(@tagName(update))); + } +} + +test write { + const executor = @import("../../../engine/game/executor.zig"); + const gpa = std.testing.allocator; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + const record_names: Names = .{ + .ships = &.{ "player_ship", "black_guard" }, + .flight_groups = &.{"alpha"}, + .squads = &.{"sabre_targets"}, + .globals = &.{"convoykilled"}, + .parts = &.{ "launch", "go_home" }, + }; + const killed: statements.Binary = .{ .operator = .equal, .left = .{ .global = 0 }, .right = .{ .number = 1 } }; + const either: statements.Binary = .{ .operator = .logical_or, .left = .{ .binary = &killed }, .right = .{ .local = 2 } }; + const both: statements.Binary = .{ .operator = .logical_and, .left = .{ .binary = &either }, .right = .result }; + const inner = [_]Statement{.{ .@"if" = .{ .condition = .result, .then = &.{}, .otherwise = &.{} } }}; + const arms = [_]statements.Random.Arm{ + .{ .threshold = 50, .extra = 7, .body = &.{.{ .call = .{ .part = 1, .arguments = &.{} } }} }, + .{ .threshold = 100, .extra = 0, .body = &.{} }, + }; + const body = [_]Statement{ + .{ .command = .{ .index = executor.commandIndex("SetAI"), .arguments = &.{ .{ .squad = 0 }, statements.testing.order(.find_new_target), .{ .number = 1 }, .null } } }, + .{ .command = .{ .index = executor.commandIndex("CreateTimer"), .arguments = &.{ .{ .number = 3 }, .{ .number = 1 }, .{ .number = 30 }, .{ .number = 1 } } } }, + .{ .command = .{ .index = 250, .arguments = &.{.{ .text = "a \"line\"\n" }} } }, + .{ .assign = .{ .target = .{ .variable = vm.Variables.number("backup_available") }, .update = .assign, .value = .{ .number = 1 } } }, + .{ .assign = .{ .target = .{ .global = 0 }, .update = .add_assign, .value = .{ .number = 1 } } }, + .{ .@"if" = .{ .condition = .{ .binary = &both }, .then = &.{.{ .spawn = .{ .part = 1, .arguments = &.{.{ .ship = 1 }} } }}, .otherwise = &inner } }, + .{ .random = .{ .arms = &arms, .default = &.{} } }, + .{ .@"return" = .{ .number = 1 } }, + }; + try write(&out.writer, &body, &record_names, .{ .event = &.{ "attacker", "shield_damage", "hull_damage" } }, 0); + try std.testing.expectEqualStrings( + \\SetAI(sabre_targets, "find_new_target", 1, nil) + \\CreateTimer(3, go_home, 30, 1) + \\command(250, "a \"line\"\n") + \\game.backup_available = 1 + \\convoykilled += 1 + \\if (convoykilled == 1 or hull_damage) and result() then + \\ spawn(go_home, black_guard) + \\elseif result() then + \\else + \\end + \\local roll = random() + \\if roll < 50 then + \\ go_home() + \\elseif roll < 100 then + \\end + \\return 1 + \\ + , out.written()); +} diff --git a/src/formats/dte/source/vocabulary.zig b/src/formats/dte/source/vocabulary.zig new file mode 100644 index 00000000..f556ecc4 --- /dev/null +++ b/src/formats/dte/source/vocabulary.zig @@ -0,0 +1,90 @@ +//! The names mission source writes the game's values by: StarLancer's tables, named as the +//! scripting API names them ([Mission source](../../../../docs/formats/mission-source.md#records)), +//! so that mission source, mods' scripts and the editor share them. A value without a name, such +//! as a mod's ship type, stays a number. Another game on the engine brings tables of its own +//! ([#1017](https://github.com/OpenReliant/openreliant/issues/1017)). + +const std = @import("std"); + +const dte = @import("../../dte.zig"); +const commands = @import("../../../engine/game/executor/commands.zig"); +const gameobj = @import("../../../engine/game/gameobj.zig"); +const orders = @import("../../../engine/game/ai/orders.zig"); +const pilots = @import("../../../engine/game/pilots.zig"); +const vm = @import("../../../engine/vm.zig"); + +/// A kind of value the game names. +pub const Kind = enum { + ship_type, + pilot, + order, + condition, + /// How a pilot's face moves as it says a line (`pilots.Head`). + head, + /// A part, which mission source names by the part's own name. + part, +}; + +/// The name of value `number` of `kind`, where the game names it. +pub fn name(kind: Kind, number: u32) ?[]const u8 { + return switch (kind) { + .ship_type => std.enums.tagName(gameobj.GameType, @fromBackingInt(number)), + .pilot => std.enums.tagName(pilots.GamePilot, @fromBackingInt(std.math.cast(u8, number) orelse return null)), + .order => std.enums.tagName(orders.Order, @fromBackingInt(std.math.cast(i16, number) orelse return null)), + .condition => std.enums.tagName(dte.Condition, @fromBackingInt(std.math.cast(u8, number) orelse return null)), + .head => std.enums.tagName(pilots.Head, @fromBackingInt(number)), + .part => null, + }; +} + +/// The name of one of the game's variables (`vm.GameVariable`), where it has one that isn't a +/// placeholder such as `_unknown_6`. +pub fn variable(number: u8) ?[]const u8 { + const found = std.enums.tagName(vm.GameVariable, @fromBackingInt(number)) orelse return null; + return if (found[0] == '_') null else found; +} + +/// The kind of value a command's parameter takes, where it takes a named one: a part, a condition, +/// or a named constant the parameter says what of (`commands.Extra.refers_to`), and the order +/// `SetAI` takes. +pub fn of(param: commands.Param) ?Kind { + if (param.kinds.part) return .part; + if (param.kinds.condition) return .condition; + if (!param.kinds.constant) return null; + if (std.mem.eql(u8, param.label, ai_mode)) return .order; + return switch (param.extra.refers_to) { + refers_to_pilot => .pilot, + refers_to_head => .head, + else => null, + }; +} + +/// The label of the parameter that takes an order, `SetAI`'s. +const ai_mode = "AI Mode"; +/// What `commands.Extra.refers_to` says a named constant is: a pilot, and a head movement. +const refers_to_pilot = 3; +const refers_to_head = 4; + +test name { + try std.testing.expectEqualStrings("grendel", name(.ship_type, @backingInt(gameobj.GameType.grendel)).?); + try std.testing.expectEqualStrings("find_new_target", name(.order, @intCast(@backingInt(orders.Order.find_new_target))).?); + try std.testing.expectEqualStrings("shot_at", name(.condition, @backingInt(dte.Condition.shot_at)).?); + try std.testing.expectEqualStrings("laughing", name(.head, @backingInt(pilots.Head.laughing)).?); + // A value the game doesn't name, such as a mod's ship type, has none. + try std.testing.expectEqual(null, name(.ship_type, 100_000)); + try std.testing.expectEqual(null, name(.order, 70_000)); +} + +test variable { + try std.testing.expectEqualStrings("backup_available", variable(vm.Variables.number("backup_available")).?); + try std.testing.expectEqual(null, variable(vm.Variables.number("_unknown_6"))); +} + +test of { + const executor = @import("../../../engine/game/executor.zig"); + const set_ai = commands.find(executor.commandIndex("SetAI")).?; + try std.testing.expectEqual(.order, of(set_ai.params[1]).?); + try std.testing.expectEqual(null, of(set_ai.params[2])); + const timer = commands.find(executor.commandIndex("CreateTimer")).?; + try std.testing.expectEqual(.part, of(timer.params[1]).?); +} diff --git a/src/formats/dte/source/write.zig b/src/formats/dte/source/write.zig new file mode 100644 index 00000000..7829ba6a --- /dev/null +++ b/src/formats/dte/source/write.zig @@ -0,0 +1,573 @@ +//! A mission as Luau source text, as `sltool dte decompile` writes it +//! ([Mission source](../../../../docs/formats/mission-source.md)): its records as declarations, in +//! the order of their tables, then its triggers and its parts with their code. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +const dte = @import("../../dte.zig"); +const decompile = @import("../decompile.zig"); +const conditions = @import("../../../engine/vm/conditions.zig"); +const names = @import("names.zig"); +const script = @import("script.zig"); +const vocabulary = @import("vocabulary.zig"); +const indexed = script.indexed; + +pub const Error = Writer.Error || Allocator.Error || dte.Error || decompile.Error; + +/// Writes `mission` as source, allocating with `arena`. +pub fn write(writer: *Writer, arena: Allocator, mission: dte.Mission) Error!void { + var source: Source = try .init(arena, mission); + try source.write(writer); +} + +/// The longest line a declaration's fields keep to before they go on lines of their own. +const line_length = 100; + +const Source = struct { + arena: Allocator, + mission: dte.Mission, + ships: []align(1) const dte.Ship, + flight_groups: []align(1) const dte.FlightGroup, + squads: []align(1) const dte.Squad, + members: []align(1) const dte.SquadMember, + formations: []align(1) const dte.Formation, + points: []align(1) const dte.FormationPoint, + curves: []align(1) const dte.Curve, + globals: []align(1) const dte.Global, + parts: []align(1) const dte.Part, + triggers: []align(1) const dte.Trigger, + /// The object each trigger belongs to, by its object ID, where an object's slice holds it. + owners: []const ?u16, + routines: []const dte.Routine, + record_names: script.Names, + formation_names: []const []const u8, + /// The names of each condition's event values, by condition. + events: [conditions.table.len][]const []const u8, + /// The name of each block that more than one trigger runs, by the block's place in the script. + handlers: std.AutoArrayHashMapUnmanaged(usize, []const u8) = .empty, + namer: names.Namer, + + fn init(arena: Allocator, mission: dte.Mission) Error!Source { + var source: Source = .{ + .arena = arena, + .mission = mission, + .ships = try mission.ships(), + .flight_groups = try mission.flightGroups(), + .squads = try mission.squads(), + .members = try mission.squadMembers(), + .formations = try mission.formations(), + .points = try mission.formationPoints(), + .curves = try mission.curves(), + .globals = try mission.globals(), + .parts = try mission.parts(), + .triggers = try mission.triggers(), + .owners = try mission.triggerObjects(arena), + .routines = try mission.routines(arena), + .record_names = .{}, + .formation_names = &.{}, + .events = undefined, + .namer = .init(arena), + }; + // The script names events' values after their labels, which no record may take. + for (&source.events, conditions.table) |*event, condition| { + event.* = try script.eventNames(arena, condition.values); + for (event.*) |value| try source.namer.reserve(value); + } + source.record_names = .{ + .ships = try source.nameAll(dte.Ship, source.ships, "ship"), + .flight_groups = try source.nameAll(dte.FlightGroup, source.flight_groups, "flight_group"), + .squads = try source.nameAll(dte.Squad, source.squads, "squad"), + .curves = try source.nameCurves(), + .globals = try source.nameAll(dte.Global, source.globals, "global"), + .parts = try source.nameAll(dte.Part, source.parts, "part"), + .objects = try source.objectNames(), + }; + source.formation_names = try source.nameAll(dte.Formation, source.formations, "formation"); + try source.nameHandlers(); + return source; + } + + /// Names each of `records` after its display name. + fn nameAll(source: *Source, comptime Record: type, records: []align(1) const Record, kind: []const u8) Allocator.Error![]const []const u8 { + const list = try source.arena.alloc([]const u8, records.len); + for (list, records, 0..) |*item, record, index| item.* = try source.namer.name(source.mission.name(record.name), kind, index); + return list; + } + + /// Names the curves, which have no display names, by their index. + fn nameCurves(source: *Source) Allocator.Error![]const []const u8 { + const list = try source.arena.alloc([]const u8, source.curves.len); + for (list, 0..) |*item, index| item.* = try source.namer.name("", "curve", index); + return list; + } + + /// The name of each object's record, by its object ID: a ship's, a flight group's or a squad's. + fn objectNames(source: *Source) Error![]const ?[]const u8 { + const list = try source.arena.alloc(?[]const u8, (try source.mission.objects()).len); + @memset(list, null); + for (source.ships, source.record_names.ships) |ship, name| place(list, ship.object_id, name); + for (source.flight_groups, source.record_names.flight_groups) |group, name| place(list, group.object_id, name); + for (source.squads, source.record_names.squads) |squad, name| place(list, squad.object_id, name); + return list; + } + + fn place(list: []?[]const u8, object: u32, name: []const u8) void { + if (object < list.len) list[object] = name; + } + + /// Names each block more than one trigger runs, after its first trigger's subject and + /// condition. + fn nameHandlers(source: *Source) Error!void { + var counts: std.AutoArrayHashMapUnmanaged(usize, usize) = .empty; + for (source.triggers) |trigger| { + const start = trigger.block() orelse continue; + if (source.routineAt(start) == null) continue; + const slot = try counts.getOrPut(source.arena, start); + slot.value_ptr.* = if (slot.found_existing) slot.value_ptr.* + 1 else 1; + } + for (source.triggers, source.owners, 0..) |trigger, owner, index| { + const start = trigger.block() orelse continue; + if ((counts.get(start) orelse 0) < 2 or source.handlers.contains(start)) continue; + const subject = if (owner) |object| source.record_names.objects[object] orelse "" else ""; + const condition = vocabulary.name(.condition, @backingInt(trigger.condition)) orelse ""; + const display = try std.fmt.allocPrint(source.arena, "{s} {s}", .{ subject, condition }); + try source.handlers.put(source.arena, start, try source.namer.name(display, "handler", index)); + } + } + + /// The block a trigger runs that starts at `start` in the script, where one does. + fn routineAt(source: Source, start: usize) ?dte.Routine { + for (source.routines) |routine| { + if (routine.start == start and routine.owner == .triggers) return routine; + } + return null; + } + + fn write(source: *Source, w: *Writer) Error!void { + if (source.mission.openReliantName()) |name| { + try w.writeAll("mission({ name = "); + try script.writeString(w, name); + try w.writeAll(" })\n"); + } + try source.writeGlobals(w); + try source.writeFlightGroups(w); + try source.writeShips(w); + try source.writeSquads(w); + try source.writeFormations(w); + try source.writeCurves(w); + try source.writeHandlers(w); + try source.writeTriggers(w); + try source.writeParts(w); + } + + /// A blank line and `title` as a comment, where `count` records follow. + fn heading(w: *Writer, title: []const u8, count: usize) Writer.Error!void { + if (count > 0) try w.print("\n-- {s}\n\n", .{title}); + } + + fn writeGlobals(source: *Source, w: *Writer) Error!void { + try heading(w, "Globals", source.globals.len); + for (source.globals, source.record_names.globals) |global, name| { + try w.print("{s} = global(", .{name}); + try script.writeString(w, source.mission.name(global.name)); + try w.print(", {d})\n", .{global.value}); + } + } + + fn writeFlightGroups(source: *Source, w: *Writer) Error!void { + try heading(w, "Flight groups", source.flight_groups.len); + for (source.flight_groups, source.record_names.flight_groups) |group, name| { + var fields: Fields = .init(source.arena); + if (group.wing != .none) { + if (std.enums.tagName(dte.FlightGroup.Wing, group.wing)) |wing| { + try fields.add("wing", "\"{s}\"", .{wing}); + } else { + try fields.add("wing", "{d}", .{@backingInt(group.wing)}); + } + } + if (group.formation.index != dte.Reference.unset) try fields.add("formation", "{f}", .{indexed(source.formation_names, group.formation.index, "formation_index")}); + try source.declare(w, name, "flight_group", group.name, &fields); + } + } + + fn writeShips(source: *Source, w: *Writer) Error!void { + try heading(w, "Ships", source.ships.len); + for (source.ships, source.record_names.ships) |ship, name| { + var fields: Fields = .init(source.arena); + const constructor: []const u8 = switch (ship.kind) { + dte.Ship.nav_point_kind => "nav_point", + dte.Ship.waypoint_kind => "waypoint", + dte.Ship.point_kind => "point", + dte.Ship.curve_point_kind => "curve_point", + else => constructor: { + try fields.add("type", "{f}", .{named(.ship_type, ship.kind)}); + break :constructor "ship"; + }, + }; + if (ship.flightGroup()) |group| try fields.add("group", "{f}", .{indexed(source.record_names.flight_groups, group, "flight_group_index")}); + if (ship.pilotRecord()) |pilot| try fields.add("pilot", "{f}", .{named(.pilot, pilot)}); + if (!std.mem.allEqual(f32, &ship.position, 0)) try fields.add("position", "{f}", .{vector(ship.position)}); + if (ship.yaw != 0) try fields.add("yaw", "{d}", .{ship.yaw}); + if (ship.pitch != 0) try fields.add("pitch", "{d}", .{ship.pitch}); + if (ship.roll != 0) try fields.add("roll", "{d}", .{ship.roll}); + if (ship.launchGate()) |gate| try fields.add("launch", "{{ from = {f}, gate = {d} }}", .{ named(.ship_type, ship.launch_from), gate }); + if (ship.formationPoint()) |point| try fields.add("formation_point", "{s}", .{try source.pointName(point)}); + if (ship.tier != dte.Ship.campaign_tier) try fields.add("tier", "{d}", .{ship.tier}); + if (ship.markedCurve()) |curve| try fields.add("marker", "{{ curve = {f}, at = {f} }}", .{ indexed(source.record_names.curves, curve, "curve_index"), number(ship.marker_at) }); + try source.declare(w, name, constructor, ship.name, &fields); + } + } + + fn writeSquads(source: *Source, w: *Writer) Error!void { + try heading(w, "Squads", source.squads.len); + for (source.squads, source.record_names.squads, 0..) |squad, name, index| { + try w.print("{s} = squad(", .{name}); + try script.writeString(w, source.mission.name(squad.name)); + try w.writeAll(", {"); + var first = true; + for (source.members) |member| { + if (member.squad != index) continue; + try w.writeAll(if (first) " " else ", "); + first = false; + const object = source.record_names.object(member.object_id); + if (member.part()) |component| { + try w.print("component({f}, {d})", .{ object, component }); + } else { + try w.print("{f}", .{object}); + } + } + try w.writeAll(if (first) "})\n" else " })\n"); + } + } + + fn writeFormations(source: *Source, w: *Writer) Error!void { + try heading(w, "Formations", source.formations.len); + for (source.formations, source.formation_names, 0..) |formation, name, index| { + try w.print("{s} = formation(", .{name}); + try script.writeString(w, source.mission.name(formation.name)); + try w.writeAll(", {"); + var first = true; + for (source.points) |point| { + if (point.formation != index) continue; + try w.print("{s}{f}", .{ if (first) " " else ", ", vector(point.offset) }); + first = false; + } + try w.writeAll(if (first) "})\n" else " })\n"); + } + } + + fn writeCurves(source: *Source, w: *Writer) Error!void { + try heading(w, "Curves", source.curves.len); + for (source.curves, source.record_names.curves) |curve, name| { + var fields: Fields = .init(source.arena); + if (curve.startShip()) |ship| try fields.add("start", "{f}", .{indexed(source.record_names.ships, ship, "ship_index")}); + if (curve.endShip()) |ship| try fields.add("finish", "{f}", .{indexed(source.record_names.ships, ship, "ship_index")}); + try fields.add("from", "{f}", .{vector(curve.from)}); + try fields.add("to", "{f}", .{vector(curve.to)}); + if (curve.leaving_handle.index != dte.Reference.unset) try fields.add("leaving_handle", "{f}", .{indexed(source.record_names.ships, curve.leaving_handle.index, "ship_index")}); + if (curve.arriving_handle.index != dte.Reference.unset) try fields.add("arriving_handle", "{f}", .{indexed(source.record_names.ships, curve.arriving_handle.index, "ship_index")}); + try fields.add("leaving", "{f}", .{vector(curve.leaving)}); + try fields.add("arriving", "{f}", .{vector(curve.arriving)}); + try w.print("{s} = curve(", .{name}); + try fields.write(w, name.len + " = curve(".len); + try w.writeAll(")\n"); + } + } + + fn writeHandlers(source: *Source, w: *Writer) Error!void { + try heading(w, "Code more than one trigger runs", source.handlers.count()); + for (source.handlers.keys(), source.handlers.values()) |start, name| { + const routine = source.routineAt(start).?; + try w.print("{s} = handler(", .{name}); + try source.function(w, routine, source.eventsOf(source.triggers[routine.owner.triggers[0]].condition), null); + try w.writeAll(")\n"); + } + } + + fn eventsOf(source: Source, condition: dte.Condition) []const []const u8 { + const index = @backingInt(condition); + return if (index < source.events.len) source.events[index] else &.{}; + } + + fn writeTriggers(source: *Source, w: *Writer) Error!void { + try heading(w, "Triggers", source.triggers.len); + for (source.triggers, source.owners) |trigger, owner| { + try w.writeAll("on("); + if (owner) |object| try w.print("{f}", .{source.record_names.object(object)}) else try w.writeAll("nil"); + try w.print(", {f}", .{named(.condition, @backingInt(trigger.condition))}); + var fields: Fields = .init(source.arena); + switch (trigger.repeat) { + .once => {}, + .always => try fields.add("repeats", "\"always\"", .{}), + .counted => if (trigger.repeat_counter == trigger.repeat_count) + try fields.add("repeats", "{{ counted = {d} }}", .{trigger.repeat_count}) + else + try fields.add("repeats", "{{ counted = {d}, left = {d} }}", .{ trigger.repeat_count, trigger.repeat_counter }), + _ => try fields.add("repeats", "{d}", .{@backingInt(trigger.repeat)}), + } + if (trigger.component()) |component| try fields.add("component", "{d}", .{component}); + if (trigger.deferred != 0) try fields.add("deferred", "true", .{}); + try source.filters(&fields, trigger); + if (fields.list.items.len > 0) { + try w.writeAll(", "); + try fields.write(w, "on(".len); + } + const start = trigger.block(); + if (start != null and source.handlers.get(start.?) != null) { + try w.print(", {s})\n", .{source.handlers.get(start.?).?}); + } else if (owner != null and start != null and source.routineAt(start.?) != null) { + try w.writeAll(", "); + try source.function(w, source.routineAt(start.?).?, source.eventsOf(trigger.condition), null); + try w.writeAll(")\n"); + } else { + try w.writeAll(")\n"); + } + } + } + + /// The values a trigger's event must have, by their names, for the values its condition + /// checks. + fn filters(source: *Source, fields: *Fields, trigger: dte.Trigger) Error!void { + const condition = trigger.condition.descriptor() orelse return; + const event = source.eventsOf(trigger.condition); + for (condition.values, event, 0..) |value, name, slot| { + if (!value.checked or slot >= trigger.operands.len) continue; + switch (dte.Operand.read(trigger.operands[slot], value.kinds)) { + .unset => {}, + .number => |known| try fields.add(name, "{d}", .{known}), + .any_ship => try fields.add(name, "any_ship", .{}), + .ship => |index| try fields.add(name, "{f}", .{indexed(source.record_names.ships, index, "ship_index")}), + .flight_group => |index| try fields.add(name, "{f}", .{indexed(source.record_names.flight_groups, index, "flight_group_index")}), + .squad => |index| try fields.add(name, "{f}", .{indexed(source.record_names.squads, index, "squad_index")}), + .other => |known| try fields.add(name, "{d}", .{known}), + } + } + } + + fn writeParts(source: *Source, w: *Writer) Error!void { + try heading(w, "Parts", source.parts.len); + for (source.parts, source.record_names.parts, 0..) |part, name, index| { + try w.print("{s} = part(", .{name}); + try script.writeString(w, source.mission.name(part.name)); + const routine = for (source.routines) |routine| { + if (routine.owner == .part and routine.owner.part == index) break routine; + } else null; + if (routine) |found| { + try w.writeAll(", "); + try source.function(w, found, &.{}, part.arguments); + } else if (part.flags.start) { + try w.writeAll(", nil"); + } + if (part.flags.start) try w.writeAll(", { start = true }"); + try w.writeAll(")\n"); + } + } + + /// A function of `routine`'s statements, whose parameters are the event's values `event`, or + /// `arguments` arguments for a part. + fn function(source: *Source, w: *Writer, routine: dte.Routine, event: []const []const u8, arguments: ?u8) Error!void { + try w.writeAll("function("); + if (arguments) |count| { + try script.writeParameters(w, count); + } else { + for (event, 0..) |name, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", name }); + } + try w.writeAll(")\n"); + const body = try decompile.routine(source.arena, source.mission, routine); + try script.write(w, body, &source.record_names, .{ .event = event }, 1); + try w.writeAll("end"); + } + + /// `name = constructor("display", { fields })`, or without fields where it has none. + fn declare(source: *Source, w: *Writer, name: []const u8, constructor: []const u8, display: u16, fields: *const Fields) Error!void { + try w.print("{s} = {s}(", .{ name, constructor }); + try script.writeString(w, source.mission.name(display)); + if (fields.list.items.len > 0) { + try w.writeAll(", "); + try fields.write(w, name.len + constructor.len + source.mission.name(display).len + 8); + } + try w.writeAll(")\n"); + } + + /// Point `point` of `formation_points` as its formation's name indexed by its place among the + /// formation's points, from 1. + fn pointName(source: Source, point: u16) Allocator.Error![]const u8 { + if (point >= source.points.len) return std.fmt.allocPrint(source.arena, "formation_point_index({d})", .{point}); + const formation = source.points[point].formation; + const first = if (formation < source.formations.len) source.formations[formation].first_point else point; + return std.fmt.allocPrint(source.arena, "{f}[{d}]", .{ indexed(source.formation_names, formation, "formation_index"), point -| first + 1 }); + } +}; + +/// A value the game names, by its name as a string, or its number where it has none. +const Named = struct { + kind: vocabulary.Kind, + value: u32, + + pub fn format(named_value: Named, w: *Writer) Writer.Error!void { + if (vocabulary.name(named_value.kind, named_value.value)) |name| return w.print("\"{s}\"", .{name}); + try w.print("{d}", .{named_value.value}); + } +}; + +fn named(kind: vocabulary.Kind, value: u32) Named { + return .{ .kind = kind, .value = value }; +} + +/// A number as Luau writes it: the shortest that reads back as the same `f32`, and `0/0`, `1/0` +/// and `-1/0` for those Luau has no literal for. +const Number = struct { + value: f32, + + pub fn format(shown: Number, w: *Writer) Writer.Error!void { + if (std.math.isNan(shown.value)) return w.writeAll("0/0"); + if (std.math.isInf(shown.value)) return w.writeAll(if (shown.value > 0) "1/0" else "-1/0"); + try w.print("{d}", .{shown.value}); + } +}; + +fn number(value: f32) Number { + return .{ .value = value }; +} + +/// A place as `{ x, y, z }`. +const Vector = struct { + value: [3]f32, + + pub fn format(shown: Vector, w: *Writer) Writer.Error!void { + try w.print("{{ {f}, {f}, {f} }}", .{ number(shown.value[0]), number(shown.value[1]), number(shown.value[2]) }); + } +}; + +fn vector(value: [3]f32) Vector { + return .{ .value = value }; +} + +/// A declaration's fields, which go on its line where they fit, and on lines of their own where +/// they don't. +const Fields = struct { + arena: Allocator, + list: std.ArrayList([2][]const u8) = .empty, + + fn init(arena: Allocator) Fields { + return .{ .arena = arena }; + } + + fn add(fields: *Fields, key: []const u8, comptime format: []const u8, args: anytype) Allocator.Error!void { + try fields.list.append(fields.arena, .{ key, try std.fmt.allocPrint(fields.arena, format, args) }); + } + + /// Writes the fields as a table, starting `column` characters into its line, which the + /// declaration's bracket ends. + fn write(fields: Fields, w: *Writer, column: usize) Writer.Error!void { + if (fields.list.items.len == 0) return w.writeAll("{}"); + var length = column + "{ })".len; + for (fields.list.items, 0..) |field, index| { + length += field[0].len + " = ".len + field[1].len + @as(usize, if (index > 0) ", ".len else 0); + } + if (length <= line_length) { + try w.writeAll("{ "); + for (fields.list.items, 0..) |field, index| try w.print("{s}{s} = {s}", .{ if (index > 0) ", " else "", field[0], field[1] }); + return w.writeAll(" }"); + } + try w.writeAll("{\n"); + for (fields.list.items) |field| try w.print(" {s} = {s},\n", .{ field[0], field[1] }); + try w.writeAll("}"); + } +}; + +test write { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const executor = @import("../../../engine/game/executor.zig"); + const gameobj = @import("../../../engine/game/gameobj.zig"); + const statements = @import("../statements.zig"); + + // A ship and a nav point, a flight group, a global, a trigger on the ship and a start part. + const pool = "Player\x00Nav\x00(FG)Alpha\x00(GV)done\x00(F)start\x00"; + // The records hold none where the shipped ones do: no formation point, and no formation. + var player = dte.testing.ship(0, 0, @intCast(@backingInt(gameobj.GameType.grendel))); + player.position = .{ 100, 200, 300 }; + player.yaw = 90; + player.formation_point = dte.Ship.no_formation_point; + var nav = dte.testing.ship(1, dte.Ship.no_flight_group, dte.Ship.nav_point_kind); + nav.name = 7; + nav.yaw = 45; + nav.formation_point = dte.Ship.no_formation_point; + var group = dte.testing.flightGroup(2, .player); + group.name = 11; + group.formation = dte.FlightGroup.no_formation; + var global = std.mem.zeroes(dte.Global); + global.name = 21; + var trigger = std.mem.zeroes(dte.Trigger); + trigger.condition = .destroyed; + trigger.qualifier = dte.Trigger.whole_object; + trigger.operands = @splat(std.math.maxInt(u32)); + const block = try statements.bytes(arena, &.{ + .{ .assign = .{ .target = .{ .global = 0 }, .update = .assign, .value = .{ .number = 1 } } }, + .{ .@"return" = .{ .number = 1 } }, + }, .{}); + const routine = try statements.bytes(arena, &.{ + .{ .command = .{ .index = executor.commandIndex("Wait"), .arguments = &.{.{ .number = 3 }} } }, + .{ .@"return" = .{ .number = 1 } }, + }, .{}); + var part = std.mem.zeroes(dte.Part); + part.name = 30; + part.offset = @intCast(block.len / 2); + part.length = @intCast(routine.len / 2); + part.flags.start = true; + const code = try std.mem.concat(arena, u8, &.{ block, routine }); + + var sections: dte.write.Sections = @splat(.{}); + dte.write.set(§ions, .strings, pool.len, pool); + dte.write.set(§ions, .ships, 2, std.mem.sliceAsBytes(&[_]dte.Ship{ player, nav })); + dte.write.set(§ions, .flight_groups, 1, std.mem.asBytes(&group)); + dte.write.set(§ions, .globals, 1, std.mem.asBytes(&global)); + dte.write.set(§ions, .triggers, 1, std.mem.asBytes(&trigger)); + dte.write.set(§ions, .objects, 3, std.mem.sliceAsBytes(&[_]dte.Object{ + dte.testing.object(.ship, 0, 1), + dte.testing.object(.ship, 0, 0), + dte.testing.object(.flight_group, 0, 0), + })); + dte.write.set(§ions, .parts, 1, std.mem.asBytes(&part)); + dte.write.set(§ions, .script, code.len / 2, code); + const mission: dte.Mission = try .parse(try dte.write.write(arena, §ions, .{})); + + var out: Writer.Allocating = .init(gpa); + defer out.deinit(); + try write(&out.writer, arena, mission); + try std.testing.expectEqualStrings( + \\ + \\-- Globals + \\ + \\done = global("(GV)done", 0) + \\ + \\-- Flight groups + \\ + \\alpha = flight_group("(FG)Alpha", { wing = "player" }) + \\ + \\-- Ships + \\ + \\player = ship("Player", { type = "grendel", group = alpha, position = { 100, 200, 300 }, yaw = 90 }) + \\nav = nav_point("Nav", { yaw = 45 }) + \\ + \\-- Triggers + \\ + \\on(player, "destroyed", function(killer, victim) + \\ done = 1 + \\ return 1 + \\end) + \\ + \\-- Parts + \\ + \\start = part("(F)start", function() + \\ Wait(3) + \\ return 1 + \\end, { start = true }) + \\ + , out.written()); +} diff --git a/src/tools/sltool/dte.zig b/src/tools/sltool/dte.zig index 8e52317b..fe4bd43c 100644 --- a/src/tools/sltool/dte.zig +++ b/src/tools/sltool/dte.zig @@ -23,6 +23,8 @@ pub const Command = union(enum) { script: struct { mission: []const u8 }, /// Writes the mission and its script again, and checks they come back the same. check: struct { mission: []const u8 }, + /// Writes the mission as Luau source. + decompile: struct { mission: []const u8 }, pub const usage = \\ dte info summarise a mission @@ -34,6 +36,7 @@ pub const Command = union(enum) { \\ dte script disassemble the script bytecode \\ dte check rewrite the mission and its script, and check that \\ nothing changes + \\ dte decompile write the mission as Luau source \\ ; @@ -64,10 +67,18 @@ pub const Command = union(enum) { .parts => try parts(ctx, mission), .script => try script(ctx, mission, models, .starlancer), .check => try check(ctx, mission), + .decompile => try decompile(ctx, mission, path), } } }; +/// Writes `mission`, read from `path`, as Luau source +/// ([Mission source](../../../docs/formats/mission-source.md)). +fn decompile(ctx: Context, mission: dte.Mission, path: []const u8) !void { + try ctx.stdout.print("-- {s} as mission source, written by sltool dte decompile.\n", .{std.Io.Dir.path.basename(path)}); + try dte.source.write.write(ctx.stdout, ctx.arena, mission); +} + fn info(ctx: Context, mission: dte.Mission) !void { const ship_list = try mission.ships(); var named: usize = 0; From 753e25ba50f2141286607ef8b952618bda4c416a Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 01:01:30 +0200 Subject: [PATCH 2/2] refactor: mission source's own words as an enum, and plainer writers names.Word holds the words mission source gives itself, its constructors, functions, index forms and the opcodes written as calls, so the writers print them typed and the parser to come reads the same list. One infix table gives each operator's symbol and binding, the writer shares one list helper, and the listing asks the vocabulary for a parameter directly. --- src/formats/dte/listing.zig | 20 +-- src/formats/dte/source/names.zig | 74 +++++++-- src/formats/dte/source/script.zig | 256 +++++++++++++++--------------- src/formats/dte/source/write.zig | 194 +++++++++++----------- 4 files changed, 302 insertions(+), 242 deletions(-) diff --git a/src/formats/dte/listing.zig b/src/formats/dte/listing.zig index 0b553a2a..5bff9b67 100644 --- a/src/formats/dte/listing.zig +++ b/src/formats/dte/listing.zig @@ -101,15 +101,15 @@ pub fn writeInstruction(writer: *Writer, mission: dte.Mission, instruction: dte. } /// Writes instruction `index` of `instructions` as `writeInstruction` does, and where it pushes a -/// command's argument (`argumentOf`) that the listing can name, the argument's name: the order of +/// command's argument (`parameterOf`) that the listing can name, the argument's name: the order of /// `SetAI`'s AI mode, by the developers' name for it, such as `push_byte 13 Object Attach`. Only /// StarLancer's commands are known. pub fn writeInstructionAt(writer: *Writer, mission: dte.Mission, instructions: []const dte.Instruction, index: usize, constants: []align(1) const u32, options: Options) Writer.Error!void { const instruction = instructions[index]; try writeInstruction(writer, mission, instruction, constants, options); if (options.commands != .starlancer) return; - const argument = argumentOf(instructions, index) orelse return; - if (vocabulary.of(argument.param) != .order) return; + const param = parameterOf(instructions, index) orelse return; + if (vocabulary.of(param) != .order) return; const number = pushedValue(instruction, constants) orelse return; const order: orders.Order = @fromBackingInt(std.math.cast(i16, number) orelse return); const info = orders.info(order) orelse return; @@ -120,18 +120,11 @@ pub fn writeInstructionAt(writer: *Writer, mission: dte.Mission, instructions: [ } } -/// A command's parameter that a push fills: its place among the command's parameters, and the -/// parameter in the command catalogue. -const Argument = struct { - index: usize, - param: commands.Param, -}; - /// The parameter instruction `index` of `instructions` pushes the argument for: where the /// instructions just before a `command`, one after another with nothing reaching in between, are /// pushes, one for each of its parameters, the first push fills the first parameter. Null where it /// isn't such a push, or the command isn't one of StarLancer's. -fn argumentOf(instructions: []const dte.Instruction, index: usize) ?Argument { +fn parameterOf(instructions: []const dte.Instruction, index: usize) ?commands.Param { if (!pushesOne(instructions[index].opcode)) return null; var end = index + 1; while (end < instructions.len and pushesOne(instructions[end].opcode) and follows(instructions, end)) end += 1; @@ -142,8 +135,7 @@ fn argumentOf(instructions: []const dte.Instruction, index: usize) ?Argument { for (first + 1..index + 1) |at| { if (!pushesOne(instructions[at - 1].opcode) or !follows(instructions, at)) return null; } - const param = index - first; - return .{ .index = param, .param = command.params[param] }; + return command.params[index - first]; } /// Whether instruction `index` starts where the one before it ends, so that nothing reached from @@ -289,5 +281,5 @@ test writeInstructionAt { var cut = instructions; cut[3] = dte.decodeAt(&[_]u8{@backingInt(dte.Opcode.add)}, 0).?; cut[3].address = instructions[3].address; - try std.testing.expectEqual(null, argumentOf(&cut, 1)); + try std.testing.expectEqual(null, parameterOf(&cut, 1)); } diff --git a/src/formats/dte/source/names.zig b/src/formats/dte/source/names.zig index cf23d67d..dbe9e73d 100644 --- a/src/formats/dte/source/names.zig +++ b/src/formats/dte/source/names.zig @@ -12,27 +12,71 @@ const keywords = [_][]const u8{ "or", "repeat", "return", "then", "true", "type", "until", "while", }; -/// The names mission source gives itself, which no record's name can be either: its constructors, -/// its functions, and the forms the script writes some opcodes in. -pub const reserved = [_][]const u8{ - "mission", "global", "flight_group", "ship", "nav_point", - "waypoint", "point", "curve_point", "squad", "component", - "formation", "curve", "part", "handler", "on", - "any_ship", "command", "spawn", "random", "result", - "game", "thread_value", "last_event", "in_flight_group", "not_in_flight_group", - "in_squad", "not_in_squad", "greater_f", "greater_equal_f", "less_f", - "less_equal_f", "add_f", "sub_f", "mul_f", "div_f", - "add_assign_f", "sub_assign_f", "mul_assign_f", "div_assign_f", "ship_index", - "flight_group_index", "squad_index", "curve_index", "global_index", "part_index", - "object_index", "formation_index", "formation_point_index", +/// The words mission source gives itself, which no record's name can be either: its +/// constructors, its functions and values, the forms of a reference past the records, and the +/// forms the script writes some opcodes in. +pub const Word = enum { + // Constructors. + mission, + global, + flight_group, + ship, + nav_point, + waypoint, + point, + curve_point, + squad, + formation, + curve, + part, + handler, + on, + // Functions and values. + component, + any_ship, + command, + spawn, + random, + result, + game, + thread_value, + last_event, + // References past the records. + ship_index, + flight_group_index, + squad_index, + curve_index, + global_index, + part_index, + object_index, + formation_index, + formation_point_index, + // Opcodes the script writes as calls. + in_flight_group, + not_in_flight_group, + in_squad, + not_in_squad, + greater_f, + greater_equal_f, + less_f, + less_equal_f, + add_f, + sub_f, + mul_f, + div_f, + add_assign_f, + sub_assign_f, + mul_assign_f, + div_assign_f, }; /// Whether `name` is taken by Luau or by mission source itself, a part's argument such as `arg0` /// among them. pub fn isReserved(name: []const u8) bool { - for (keywords ++ reserved) |word| { - if (std.mem.eql(u8, word, name)) return true; + for (keywords) |keyword| { + if (std.mem.eql(u8, keyword, name)) return true; } + if (std.meta.stringToEnum(Word, name) != null) return true; const number = std.mem.cutPrefix(u8, name, "arg") orelse return false; return number.len > 0 and for (number) |byte| { if (!std.ascii.isDigit(byte)) break false; diff --git a/src/formats/dte/source/script.zig b/src/formats/dte/source/script.zig index 2cb1fb84..d4321c5e 100644 --- a/src/formats/dte/source/script.zig +++ b/src/formats/dte/source/script.zig @@ -8,9 +8,11 @@ const statements = @import("../statements.zig"); const Statement = statements.Statement; const Expression = statements.Expression; const Operator = statements.Operator; +const Update = statements.Update; const commands = @import("../../../engine/game/executor/commands.zig"); const conditions = @import("../../../engine/vm/conditions.zig"); const names = @import("names.zig"); +const Word = names.Word; const vocabulary = @import("vocabulary.zig"); const vm = @import("../../../engine/vm.zig"); @@ -29,7 +31,7 @@ pub const Names = struct { pub fn object(record_names: Names, id: u32) Indexed { const list = record_names.objects; if (id < list.len and list[id] != null) return .{ .name = list[id].? }; - return .{ .fallback = .{ "object_index", id } }; + return .{ .fallback = .{ .object_index, id } }; } }; @@ -38,18 +40,18 @@ pub const Names = struct { /// records](../../../../docs/formats/mission-source.md#references-past-the-records)). pub const Indexed = union(enum) { name: []const u8, - fallback: struct { []const u8, u32 }, + fallback: struct { Word, u32 }, pub fn format(record: Indexed, w: *Writer) Writer.Error!void { switch (record) { .name => |name| try w.writeAll(name), - .fallback => |fallback| try w.print("{s}({d})", .{ fallback[0], fallback[1] }), + .fallback => |fallback| try w.print("{t}({d})", .{ fallback[0], fallback[1] }), } } }; /// Record `index` of `list`, whose index form is `fallback`. -pub fn indexed(list: []const []const u8, index: u32, fallback: []const u8) Indexed { +pub fn indexed(list: []const []const u8, index: u32, fallback: Word) Indexed { return if (index < list.len) .{ .name = list[index] } else .{ .fallback = .{ fallback, index } }; } @@ -61,8 +63,8 @@ pub const Scope = struct { }; /// The names a trigger's function gives its event's values: their labels in the condition -/// catalogue, as names. A file holds no record of these names (`names.Namer.reserve`), so that -/// none is hidden in a trigger's function. +/// catalogue, as names. No record takes them (`names.Namer.reserve`), so that the function's +/// parameters never hide one. pub fn eventNames(arena: std.mem.Allocator, values: []const conditions.Value) std.mem.Allocator.Error![]const []const u8 { const list = try arena.alloc([]const u8, values.len); var namer: names.Namer = .init(arena); @@ -99,70 +101,56 @@ const Printer = struct { } fn statement(p: Printer, line: Statement, depth: usize) Writer.Error!void { + const w = p.writer; try p.indent(depth); switch (line) { .command => |command| { const entry = commands.find(command.index); - if (entry) |known| { - try p.writer.print("{s}(", .{known.name}); - } else { - try p.writer.print("command({d}", .{command.index}); - } + if (entry) |known| try w.print("{s}(", .{known.name}) else try w.print("{t}({d}", .{ Word.command, command.index }); for (command.arguments, 0..) |argument, index| { - if (index > 0 or entry == null) try p.writer.writeAll(", "); - const kind = if (entry) |known| if (index < known.params.len) vocabulary.of(known.params[index]) else null else null; - try p.expression(argument, kind, .lowest); + if (index > 0 or entry == null) try w.writeAll(", "); + try p.expression(argument, kindOf(entry, index), .lowest); } - try p.writer.writeAll(")\n"); + try w.writeAll(")\n"); }, .call => |call| { - try p.writer.print("{f}", .{indexed(p.names.parts, call.part, "part_index")}); - try p.arguments(call.arguments, false); + try w.print("{f}(", .{indexed(p.names.parts, call.part, .part_index)}); + try p.values(call.arguments, false); + try w.writeAll(")\n"); }, .spawn => |call| { - try p.writer.print("spawn({f}", .{indexed(p.names.parts, call.part, "part_index")}); - try p.arguments(call.arguments, true); + try w.print("{t}({f}", .{ Word.spawn, indexed(p.names.parts, call.part, .part_index) }); + try p.values(call.arguments, true); + try w.writeAll(")\n"); }, - .assign => |assign| { - const operator_text: ?[]const u8 = switch (assign.update) { - .assign => "=", - .add_assign => "+=", - .sub_assign => "-=", - .mul_assign => "*=", - .div_assign => "/=", - .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f => null, - }; - if (operator_text) |text| { - try p.target(assign.target); - try p.writer.print(" {s} ", .{text}); - try p.expression(assign.value, null, .lowest); - try p.writer.writeByte('\n'); - } else { - try p.writer.print("{t}(", .{assign.update}); - try p.target(assign.target); - try p.writer.writeAll(", "); - try p.expression(assign.value, null, .lowest); - try p.writer.writeAll(")\n"); - } + .assign => |assign| if (assignSymbol(assign.update)) |symbol_text| { + try p.target(assign.target); + try w.print(" {s} ", .{symbol_text}); + try p.expression(assign.value, null, .lowest); + try w.writeByte('\n'); + } else { + try w.print("{t}(", .{assign.update}); + try p.target(assign.target); + try w.writeAll(", "); + try p.expression(assign.value, null, .lowest); + try w.writeAll(")\n"); }, .@"if" => |branch| try p.ifStatement(branch, depth), .random => |random| try p.randomStatement(random, depth), .@"return" => |value| { - try p.writer.writeAll("return "); + try w.writeAll("return "); try p.expression(value, null, .lowest); - try p.writer.writeByte('\n'); + try w.writeByte('\n'); }, } } - /// A call's arguments and its closing bracket, after a first argument where `after` says so. - fn arguments(p: Printer, list: []const Expression, after: bool) Writer.Error!void { - if (!after) try p.writer.writeByte('('); - for (list, 0..) |argument, index| { - if (index > 0 or after) try p.writer.writeAll(", "); - try p.expression(argument, null, .lowest); + /// Writes `list`, its values separated by commas, with one before the first where `leading`. + fn values(p: Printer, list: []const Expression, leading: bool) Writer.Error!void { + for (list, 0..) |value, index| { + if (index > 0 or leading) try p.writer.writeAll(", "); + try p.expression(value, null, .lowest); } - try p.writer.writeAll(")\n"); } /// `if`, `elseif` for an `else` that holds one `if` alone, and `else`. @@ -191,7 +179,7 @@ const Printer = struct { /// A random choice: one roll, then an `if` over its arms' thresholds, and the default as `else`. fn randomStatement(p: Printer, random: statements.Random, depth: usize) Writer.Error!void { - try p.writer.writeAll("local roll = random()\n"); + try p.writer.print("local roll = {t}()\n", .{Word.random}); for (random.arms, 0..) |arm, index| { try p.indent(depth); try p.writer.print("{s} roll < {d} then\n", .{ if (index == 0) "if" else "elseif", arm.threshold }); @@ -208,49 +196,16 @@ const Printer = struct { fn target(p: Printer, to: statements.Target) Writer.Error!void { switch (to) { - .global => |index| try p.writer.print("{f}", .{indexed(p.names.globals, index, "global_index")}), + .global => |index| try p.writer.print("{f}", .{indexed(p.names.globals, index, .global_index)}), .variable => |number| try p.variable(number), .argument => |number| try p.writer.print("arg{d}", .{number}), } } + /// One of the game's variables, by its name where it has one. fn variable(p: Printer, number: u8) Writer.Error!void { - if (vocabulary.variable(number)) |known| return p.writer.print("game.{s}", .{known}); - try p.writer.print("game[{d}]", .{number}); - } - - /// How tightly an operator binds in Luau, the loosest first. - const Precedence = enum(u8) { lowest, @"or", @"and", comparison, sum, product, atom }; - - fn precedence(operator: Operator) ?Precedence { - return switch (operator) { - .logical_or => .@"or", - .logical_and => .@"and", - .equal, .not_equal, .greater, .greater_equal, .less, .less_equal => .comparison, - .add, .sub => .sum, - .mul, .div => .product, - // The rest are written as calls. - .greater_f, .greater_equal_f, .less_f, .less_equal_f, .add_f, .sub_f, .mul_f, .div_f => null, - .in_flight_group, .not_in_flight_group, .in_squad, .not_in_squad => null, - }; - } - - fn symbol(operator: Operator) []const u8 { - return switch (operator) { - .logical_or => "or", - .logical_and => "and", - .equal => "==", - .not_equal => "~=", - .greater => ">", - .greater_equal => ">=", - .less => "<", - .less_equal => "<=", - .add => "+", - .sub => "-", - .mul => "*", - .div => "/", - else => @tagName(operator), - }; + if (vocabulary.variable(number)) |known| return p.writer.print("{t}.{s}", .{ Word.game, known }); + try p.writer.print("{t}[{d}]", .{ Word.game, number }); } /// Writes `value`, where `kind` names the value a command's parameter takes, inside an @@ -260,82 +215,128 @@ const Printer = struct { switch (value) { .number => |number| { if (kind) |named| { - if (named == .part) return w.print("{f}", .{indexed(p.names.parts, number, "part_index")}); + if (named == .part) return w.print("{f}", .{indexed(p.names.parts, number, .part_index)}); if (vocabulary.name(named, number)) |text| return w.print("\"{s}\"", .{text}); } try w.print("{d}", .{number}); }, .text => |text| try writeString(w, text), - .ship => |index| try w.print("{f}", .{indexed(p.names.ships, index, "ship_index")}), - .component => |component| try w.print("component({f}, {d})", .{ indexed(p.names.ships, component.ship, "ship_index"), component.component }), - .flight_group => |index| try w.print("{f}", .{indexed(p.names.flight_groups, index, "flight_group_index")}), - .squad => |index| try w.print("{f}", .{indexed(p.names.squads, index, "squad_index")}), - .curve => |index| try w.print("{f}", .{indexed(p.names.curves, index, "curve_index")}), + .ship => |index| try w.print("{f}", .{indexed(p.names.ships, index, .ship_index)}), + .component => |component| try w.print("{t}({f}, {d})", .{ Word.component, indexed(p.names.ships, component.ship, .ship_index), component.component }), + .flight_group => |index| try w.print("{f}", .{indexed(p.names.flight_groups, index, .flight_group_index)}), + .squad => |index| try w.print("{f}", .{indexed(p.names.squads, index, .squad_index)}), + .curve => |index| try w.print("{f}", .{indexed(p.names.curves, index, .curve_index)}), .null => try w.writeAll("nil"), - .global => |index| try w.print("{f}", .{indexed(p.names.globals, index, "global_index")}), + .global => |index| try w.print("{f}", .{indexed(p.names.globals, index, .global_index)}), .variable => |number| try p.variable(number), .argument => |number| try w.print("arg{d}", .{number}), .local => |number| { if (number < p.scope.event.len) return w.writeAll(p.scope.event[number]); - try w.print("thread_value({d})", .{number}); - }, - .event_value => |event| { - try w.print("last_event({f}", .{p.names.object(event.object)}); - const condition = conditions.find(event.condition); - if (vocabulary.name(.condition, event.condition)) |text| { - try w.print(", \"{s}\", ", .{text}); - } else { - try w.print(", {d}, ", .{event.condition}); - } - if (condition != null and event.value < condition.?.values.len) { - try writeString(w, condition.?.values[event.value].label); - } else { - try w.print("{d}", .{event.value}); - } - try w.writeByte(')'); + try w.print("{t}({d})", .{ Word.thread_value, number }); }, - .result => try w.writeAll("result()"), + .event_value => |event| try p.lastEvent(event), + .result => try w.print("{t}()", .{Word.result}), .binary => |binary| { - const own = precedence(binary.operator) orelse { - try w.print("{s}(", .{symbol(binary.operator)}); + const operator = infix(binary.operator) orelse { + try w.print("{t}(", .{binary.operator}); try p.expression(binary.left, null, .lowest); try w.writeAll(", "); try p.expression(binary.right, null, .lowest); return w.writeByte(')'); }; - const bracket = @backingInt(own) <= @backingInt(outer) and outer != .lowest; + // An operand that binds more loosely than the operator takes brackets, and on the + // right one that binds as loosely too, since Luau groups its operators to the left. + const own = operator.precedence; + const bracket = @backingInt(own) <= @backingInt(outer); if (bracket) try w.writeByte('('); try p.expression(binary.left, null, @fromBackingInt(@backingInt(own) - 1)); - try w.print(" {s} ", .{symbol(binary.operator)}); + try w.print(" {s} ", .{operator.symbol}); try p.expression(binary.right, null, own); if (bracket) try w.writeByte(')'); }, } } + + /// `last_event(object, condition, label)`: a value an object keeps of its last event, by its + /// condition's name and the value's label where the catalogue has them. + fn lastEvent(p: Printer, event: statements.EventValue) Writer.Error!void { + const w = p.writer; + try w.print("{t}({f}, ", .{ Word.last_event, p.names.object(event.object) }); + if (vocabulary.name(.condition, event.condition)) |name| try w.print("\"{s}\"", .{name}) else try w.print("{d}", .{event.condition}); + try w.writeAll(", "); + const condition = conditions.find(event.condition); + if (condition != null and event.value < condition.?.values.len) { + try writeString(w, condition.?.values[event.value].label); + } else { + try w.print("{d}", .{event.value}); + } + try w.writeByte(')'); + } }; +/// The kind of named value argument `index` of the command `entry` takes, where it takes one. +fn kindOf(entry: ?commands.Command, index: usize) ?vocabulary.Kind { + const known = entry orelse return null; + return if (index < known.params.len) vocabulary.of(known.params[index]) else null; +} + +/// How tightly an operator binds in Luau, the loosest first. +const Precedence = enum(u8) { lowest, @"or", @"and", comparison, sum, product }; + +/// How Luau writes an operator between its operands: its symbol, and how tightly it binds. +const Infix = struct { symbol: []const u8, precedence: Precedence }; + +/// How Luau writes `operator` between its operands, or null for one written as a call. +fn infix(operator: Operator) ?Infix { + return switch (operator) { + .logical_or => .{ .symbol = "or", .precedence = .@"or" }, + .logical_and => .{ .symbol = "and", .precedence = .@"and" }, + .equal => .{ .symbol = "==", .precedence = .comparison }, + .not_equal => .{ .symbol = "~=", .precedence = .comparison }, + .greater => .{ .symbol = ">", .precedence = .comparison }, + .greater_equal => .{ .symbol = ">=", .precedence = .comparison }, + .less => .{ .symbol = "<", .precedence = .comparison }, + .less_equal => .{ .symbol = "<=", .precedence = .comparison }, + .add => .{ .symbol = "+", .precedence = .sum }, + .sub => .{ .symbol = "-", .precedence = .sum }, + .mul => .{ .symbol = "*", .precedence = .product }, + .div => .{ .symbol = "/", .precedence = .product }, + .greater_f, .greater_equal_f, .less_f, .less_equal_f, .add_f, .sub_f, .mul_f, .div_f => null, + .in_flight_group, .not_in_flight_group, .in_squad, .not_in_squad => null, + }; +} + +/// The symbol Luau writes `update` with, or null for one written as a call. +fn assignSymbol(update: Update) ?[]const u8 { + return switch (update) { + .assign => "=", + .add_assign => "+=", + .sub_assign => "-=", + .mul_assign => "*=", + .div_assign => "/=", + .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f => null, + }; +} + /// `text` as a Luau string: printable characters as they are, and the rest escaped. pub fn writeString(writer: *Writer, text: []const u8) Writer.Error!void { try writer.writeByte('"'); for (text) |byte| switch (byte) { - '"' => try writer.writeAll("\\\""), - '\\' => try writer.writeAll("\\\\"), + '"', '\\' => try writer.print("\\{c}", .{byte}), '\n' => try writer.writeAll("\\n"), '\t' => try writer.writeAll("\\t"), - ' '...'!', '#'...'[', ']'...'~' => try writer.writeByte(byte), - else => try writer.print("\\x{x:0>2}", .{byte}), + else => if (std.ascii.isPrint(byte)) try writer.writeByte(byte) else try writer.print("\\x{x:0>2}", .{byte}), }; try writer.writeByte('"'); } comptime { - // What the script writes as calls is reserved, so that no record takes its name. - @setEvalBranchQuota(100_000); + // What the script writes as calls are words of the source's own, which no record takes. for (std.enums.values(Operator)) |operator| { - if (Printer.precedence(operator) == null) std.debug.assert(names.isReserved(@tagName(operator))); + if (infix(operator) == null) std.debug.assert(@hasField(Word, @tagName(operator))); } - for ([_]statements.Update{ .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f }) |update| { - std.debug.assert(names.isReserved(@tagName(update))); + for (std.enums.values(Update)) |update| { + if (assignSymbol(update) == null) std.debug.assert(@hasField(Word, @tagName(update))); } } @@ -390,3 +391,10 @@ test write { \\ , out.written()); } + +test writeString { + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try writeString(&out.writer, "a\\b\"c\x01\xe9"); + try std.testing.expectEqualStrings("\"a\\\\b\\\"c\\x01\\xe9\"", out.written()); +} diff --git a/src/formats/dte/source/write.zig b/src/formats/dte/source/write.zig index 7829ba6a..1a1f0c2b 100644 --- a/src/formats/dte/source/write.zig +++ b/src/formats/dte/source/write.zig @@ -12,6 +12,7 @@ const conditions = @import("../../../engine/vm/conditions.zig"); const names = @import("names.zig"); const script = @import("script.zig"); const vocabulary = @import("vocabulary.zig"); +const Word = names.Word; const indexed = script.indexed; pub const Error = Writer.Error || Allocator.Error || dte.Error || decompile.Error; @@ -76,30 +77,30 @@ const Source = struct { for (event.*) |value| try source.namer.reserve(value); } source.record_names = .{ - .ships = try source.nameAll(dte.Ship, source.ships, "ship"), - .flight_groups = try source.nameAll(dte.FlightGroup, source.flight_groups, "flight_group"), - .squads = try source.nameAll(dte.Squad, source.squads, "squad"), + .ships = try source.nameAll(dte.Ship, source.ships, .ship), + .flight_groups = try source.nameAll(dte.FlightGroup, source.flight_groups, .flight_group), + .squads = try source.nameAll(dte.Squad, source.squads, .squad), .curves = try source.nameCurves(), - .globals = try source.nameAll(dte.Global, source.globals, "global"), - .parts = try source.nameAll(dte.Part, source.parts, "part"), + .globals = try source.nameAll(dte.Global, source.globals, .global), + .parts = try source.nameAll(dte.Part, source.parts, .part), .objects = try source.objectNames(), }; - source.formation_names = try source.nameAll(dte.Formation, source.formations, "formation"); + source.formation_names = try source.nameAll(dte.Formation, source.formations, .formation); try source.nameHandlers(); return source; } - /// Names each of `records` after its display name. - fn nameAll(source: *Source, comptime Record: type, records: []align(1) const Record, kind: []const u8) Allocator.Error![]const []const u8 { + /// Names each of `records`, declared with `constructor`, after its display name. + fn nameAll(source: *Source, comptime Record: type, records: []align(1) const Record, constructor: Word) Allocator.Error![]const []const u8 { const list = try source.arena.alloc([]const u8, records.len); - for (list, records, 0..) |*item, record, index| item.* = try source.namer.name(source.mission.name(record.name), kind, index); + for (list, records, 0..) |*item, record, index| item.* = try source.namer.name(source.mission.name(record.name), @tagName(constructor), index); return list; } /// Names the curves, which have no display names, by their index. fn nameCurves(source: *Source) Allocator.Error![]const []const u8 { const list = try source.arena.alloc([]const u8, source.curves.len); - for (list, 0..) |*item, index| item.* = try source.namer.name("", "curve", index); + for (list, 0..) |*item, index| item.* = try source.namer.name("", @tagName(Word.curve), index); return list; } @@ -133,7 +134,7 @@ const Source = struct { const subject = if (owner) |object| source.record_names.objects[object] orelse "" else ""; const condition = vocabulary.name(.condition, @backingInt(trigger.condition)) orelse ""; const display = try std.fmt.allocPrint(source.arena, "{s} {s}", .{ subject, condition }); - try source.handlers.put(source.arena, start, try source.namer.name(display, "handler", index)); + try source.handlers.put(source.arena, start, try source.namer.name(display, @tagName(Word.handler), index)); } } @@ -147,7 +148,7 @@ const Source = struct { fn write(source: *Source, w: *Writer) Error!void { if (source.mission.openReliantName()) |name| { - try w.writeAll("mission({ name = "); + try w.print("{t}({{ name = ", .{Word.mission}); try script.writeString(w, name); try w.writeAll(" })\n"); } @@ -167,11 +168,26 @@ const Source = struct { if (count > 0) try w.print("\n-- {s}\n\n", .{title}); } + /// `name = constructor("display"`, the start of a declaration. + fn open(source: *Source, w: *Writer, name: []const u8, constructor: Word, display: u16) Writer.Error!void { + try w.print("{s} = {t}(", .{ name, constructor }); + try script.writeString(w, source.mission.name(display)); + } + + /// `name = constructor("display", { fields })`, or without fields where it has none. + fn declare(source: *Source, w: *Writer, name: []const u8, constructor: Word, display: u16, fields: *const Fields) Error!void { + try source.open(w, name, constructor, display); + if (fields.list.items.len > 0) { + try w.writeAll(", "); + try fields.write(w, name.len + @tagName(constructor).len + source.mission.name(display).len + " = (\"\", ".len); + } + try w.writeAll(")\n"); + } + fn writeGlobals(source: *Source, w: *Writer) Error!void { try heading(w, "Globals", source.globals.len); for (source.globals, source.record_names.globals) |global, name| { - try w.print("{s} = global(", .{name}); - try script.writeString(w, source.mission.name(global.name)); + try source.open(w, name, .global, global.name); try w.print(", {d})\n", .{global.value}); } } @@ -187,8 +203,8 @@ const Source = struct { try fields.add("wing", "{d}", .{@backingInt(group.wing)}); } } - if (group.formation.index != dte.Reference.unset) try fields.add("formation", "{f}", .{indexed(source.formation_names, group.formation.index, "formation_index")}); - try source.declare(w, name, "flight_group", group.name, &fields); + if (group.formation.index != dte.Reference.unset) try fields.add("formation", "{f}", .{indexed(source.formation_names, group.formation.index, .formation_index)}); + try source.declare(w, name, .flight_group, group.name, &fields); } } @@ -196,17 +212,17 @@ const Source = struct { try heading(w, "Ships", source.ships.len); for (source.ships, source.record_names.ships) |ship, name| { var fields: Fields = .init(source.arena); - const constructor: []const u8 = switch (ship.kind) { - dte.Ship.nav_point_kind => "nav_point", - dte.Ship.waypoint_kind => "waypoint", - dte.Ship.point_kind => "point", - dte.Ship.curve_point_kind => "curve_point", + const constructor: Word = switch (ship.kind) { + dte.Ship.nav_point_kind => .nav_point, + dte.Ship.waypoint_kind => .waypoint, + dte.Ship.point_kind => .point, + dte.Ship.curve_point_kind => .curve_point, else => constructor: { try fields.add("type", "{f}", .{named(.ship_type, ship.kind)}); - break :constructor "ship"; + break :constructor .ship; }, }; - if (ship.flightGroup()) |group| try fields.add("group", "{f}", .{indexed(source.record_names.flight_groups, group, "flight_group_index")}); + if (ship.flightGroup()) |group| try fields.add("group", "{f}", .{indexed(source.record_names.flight_groups, group, .flight_group_index)}); if (ship.pilotRecord()) |pilot| try fields.add("pilot", "{f}", .{named(.pilot, pilot)}); if (!std.mem.allEqual(f32, &ship.position, 0)) try fields.add("position", "{f}", .{vector(ship.position)}); if (ship.yaw != 0) try fields.add("yaw", "{d}", .{ship.yaw}); @@ -215,7 +231,7 @@ const Source = struct { if (ship.launchGate()) |gate| try fields.add("launch", "{{ from = {f}, gate = {d} }}", .{ named(.ship_type, ship.launch_from), gate }); if (ship.formationPoint()) |point| try fields.add("formation_point", "{s}", .{try source.pointName(point)}); if (ship.tier != dte.Ship.campaign_tier) try fields.add("tier", "{d}", .{ship.tier}); - if (ship.markedCurve()) |curve| try fields.add("marker", "{{ curve = {f}, at = {f} }}", .{ indexed(source.record_names.curves, curve, "curve_index"), number(ship.marker_at) }); + if (ship.markedCurve()) |curve| try fields.add("marker", "{{ curve = {f}, at = {f} }}", .{ indexed(source.record_names.curves, curve, .curve_index), number(ship.marker_at) }); try source.declare(w, name, constructor, ship.name, &fields); } } @@ -223,54 +239,46 @@ const Source = struct { fn writeSquads(source: *Source, w: *Writer) Error!void { try heading(w, "Squads", source.squads.len); for (source.squads, source.record_names.squads, 0..) |squad, name, index| { - try w.print("{s} = squad(", .{name}); - try script.writeString(w, source.mission.name(squad.name)); - try w.writeAll(", {"); - var first = true; + var members: std.ArrayList([]const u8) = .empty; for (source.members) |member| { if (member.squad != index) continue; - try w.writeAll(if (first) " " else ", "); - first = false; const object = source.record_names.object(member.object_id); - if (member.part()) |component| { - try w.print("component({f}, {d})", .{ object, component }); - } else { - try w.print("{f}", .{object}); - } + try members.append(source.arena, if (member.part()) |component| + try std.fmt.allocPrint(source.arena, "{t}({f}, {d})", .{ Word.component, object, component }) + else + try std.fmt.allocPrint(source.arena, "{f}", .{object})); } - try w.writeAll(if (first) "})\n" else " })\n"); + try source.open(w, name, .squad, squad.name); + try writeList(w, members.items); } } fn writeFormations(source: *Source, w: *Writer) Error!void { try heading(w, "Formations", source.formations.len); for (source.formations, source.formation_names, 0..) |formation, name, index| { - try w.print("{s} = formation(", .{name}); - try script.writeString(w, source.mission.name(formation.name)); - try w.writeAll(", {"); - var first = true; + var points: std.ArrayList([]const u8) = .empty; for (source.points) |point| { - if (point.formation != index) continue; - try w.print("{s}{f}", .{ if (first) " " else ", ", vector(point.offset) }); - first = false; + if (point.formation == index) try points.append(source.arena, try std.fmt.allocPrint(source.arena, "{f}", .{vector(point.offset)})); } - try w.writeAll(if (first) "})\n" else " })\n"); + try source.open(w, name, .formation, formation.name); + try writeList(w, points.items); } } fn writeCurves(source: *Source, w: *Writer) Error!void { try heading(w, "Curves", source.curves.len); for (source.curves, source.record_names.curves) |curve, name| { + const ships = source.record_names.ships; var fields: Fields = .init(source.arena); - if (curve.startShip()) |ship| try fields.add("start", "{f}", .{indexed(source.record_names.ships, ship, "ship_index")}); - if (curve.endShip()) |ship| try fields.add("finish", "{f}", .{indexed(source.record_names.ships, ship, "ship_index")}); + if (curve.startShip()) |ship| try fields.add("start", "{f}", .{indexed(ships, ship, .ship_index)}); + if (curve.endShip()) |ship| try fields.add("finish", "{f}", .{indexed(ships, ship, .ship_index)}); try fields.add("from", "{f}", .{vector(curve.from)}); try fields.add("to", "{f}", .{vector(curve.to)}); - if (curve.leaving_handle.index != dte.Reference.unset) try fields.add("leaving_handle", "{f}", .{indexed(source.record_names.ships, curve.leaving_handle.index, "ship_index")}); - if (curve.arriving_handle.index != dte.Reference.unset) try fields.add("arriving_handle", "{f}", .{indexed(source.record_names.ships, curve.arriving_handle.index, "ship_index")}); + if (curve.leaving_handle.index != dte.Reference.unset) try fields.add("leaving_handle", "{f}", .{indexed(ships, curve.leaving_handle.index, .ship_index)}); + if (curve.arriving_handle.index != dte.Reference.unset) try fields.add("arriving_handle", "{f}", .{indexed(ships, curve.arriving_handle.index, .ship_index)}); try fields.add("leaving", "{f}", .{vector(curve.leaving)}); try fields.add("arriving", "{f}", .{vector(curve.arriving)}); - try w.print("{s} = curve(", .{name}); + try w.print("{s} = {t}(", .{ name, Word.curve }); try fields.write(w, name.len + " = curve(".len); try w.writeAll(")\n"); } @@ -280,8 +288,9 @@ const Source = struct { try heading(w, "Code more than one trigger runs", source.handlers.count()); for (source.handlers.keys(), source.handlers.values()) |start, name| { const routine = source.routineAt(start).?; - try w.print("{s} = handler(", .{name}); - try source.function(w, routine, source.eventsOf(source.triggers[routine.owner.triggers[0]].condition), null); + const condition = source.triggers[routine.owner.triggers[0]].condition; + try w.print("{s} = {t}(", .{ name, Word.handler }); + try source.function(w, routine, .{ .event = source.eventsOf(condition) }); try w.writeAll(")\n"); } } @@ -294,7 +303,7 @@ const Source = struct { fn writeTriggers(source: *Source, w: *Writer) Error!void { try heading(w, "Triggers", source.triggers.len); for (source.triggers, source.owners) |trigger, owner| { - try w.writeAll("on("); + try w.print("{t}(", .{Word.on}); if (owner) |object| try w.print("{f}", .{source.record_names.object(object)}) else try w.writeAll("nil"); try w.print(", {f}", .{named(.condition, @backingInt(trigger.condition))}); var fields: Fields = .init(source.arena); @@ -314,19 +323,22 @@ const Source = struct { try w.writeAll(", "); try fields.write(w, "on(".len); } - const start = trigger.block(); - if (start != null and source.handlers.get(start.?) != null) { - try w.print(", {s})\n", .{source.handlers.get(start.?).?}); - } else if (owner != null and start != null and source.routineAt(start.?) != null) { - try w.writeAll(", "); - try source.function(w, source.routineAt(start.?).?, source.eventsOf(trigger.condition), null); - try w.writeAll(")\n"); - } else { - try w.writeAll(")\n"); - } + try source.triggerCode(w, trigger, owner); + try w.writeAll(")\n"); } } + /// The code a trigger runs: the name of the block it shares with other triggers, or its own, + /// where it can fire and has one. + fn triggerCode(source: *Source, w: *Writer, trigger: dte.Trigger, owner: ?u16) Error!void { + const start = trigger.block() orelse return; + if (source.handlers.get(start)) |handler| return w.print(", {s}", .{handler}); + if (owner == null) return; + const routine = source.routineAt(start) orelse return; + try w.writeAll(", "); + try source.function(w, routine, .{ .event = source.eventsOf(trigger.condition) }); + } + /// The values a trigger's event must have, by their names, for the values its condition /// checks. fn filters(source: *Source, fields: *Fields, trigger: dte.Trigger) Error!void { @@ -337,10 +349,10 @@ const Source = struct { switch (dte.Operand.read(trigger.operands[slot], value.kinds)) { .unset => {}, .number => |known| try fields.add(name, "{d}", .{known}), - .any_ship => try fields.add(name, "any_ship", .{}), - .ship => |index| try fields.add(name, "{f}", .{indexed(source.record_names.ships, index, "ship_index")}), - .flight_group => |index| try fields.add(name, "{f}", .{indexed(source.record_names.flight_groups, index, "flight_group_index")}), - .squad => |index| try fields.add(name, "{f}", .{indexed(source.record_names.squads, index, "squad_index")}), + .any_ship => try fields.add(name, "{t}", .{Word.any_ship}), + .ship => |index| try fields.add(name, "{f}", .{indexed(source.record_names.ships, index, .ship_index)}), + .flight_group => |index| try fields.add(name, "{f}", .{indexed(source.record_names.flight_groups, index, .flight_group_index)}), + .squad => |index| try fields.add(name, "{f}", .{indexed(source.record_names.squads, index, .squad_index)}), .other => |known| try fields.add(name, "{d}", .{known}), } } @@ -349,14 +361,13 @@ const Source = struct { fn writeParts(source: *Source, w: *Writer) Error!void { try heading(w, "Parts", source.parts.len); for (source.parts, source.record_names.parts, 0..) |part, name, index| { - try w.print("{s} = part(", .{name}); - try script.writeString(w, source.mission.name(part.name)); + try source.open(w, name, .part, part.name); const routine = for (source.routines) |routine| { if (routine.owner == .part and routine.owner.part == index) break routine; } else null; if (routine) |found| { try w.writeAll(", "); - try source.function(w, found, &.{}, part.arguments); + try source.function(w, found, .{ .arguments = part.arguments }); } else if (part.flags.start) { try w.writeAll(", nil"); } @@ -365,42 +376,47 @@ const Source = struct { } } - /// A function of `routine`'s statements, whose parameters are the event's values `event`, or - /// `arguments` arguments for a part. - fn function(source: *Source, w: *Writer, routine: dte.Routine, event: []const []const u8, arguments: ?u8) Error!void { + /// What a function's parameters are: the values of a trigger's event, or a part's arguments. + const Parameters = union(enum) { + event: []const []const u8, + arguments: u8, + }; + + /// A function of `routine`'s statements, with `parameters`. + fn function(source: *Source, w: *Writer, routine: dte.Routine, parameters: Parameters) Error!void { try w.writeAll("function("); - if (arguments) |count| { - try script.writeParameters(w, count); - } else { - for (event, 0..) |name, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", name }); + switch (parameters) { + .event => |event| for (event, 0..) |name, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", name }), + .arguments => |count| try script.writeParameters(w, count), } try w.writeAll(")\n"); const body = try decompile.routine(source.arena, source.mission, routine); + const event = switch (parameters) { + .event => |names_of_values| names_of_values, + .arguments => &.{}, + }; try script.write(w, body, &source.record_names, .{ .event = event }, 1); try w.writeAll("end"); } - /// `name = constructor("display", { fields })`, or without fields where it has none. - fn declare(source: *Source, w: *Writer, name: []const u8, constructor: []const u8, display: u16, fields: *const Fields) Error!void { - try w.print("{s} = {s}(", .{ name, constructor }); - try script.writeString(w, source.mission.name(display)); - if (fields.list.items.len > 0) { - try w.writeAll(", "); - try fields.write(w, name.len + constructor.len + source.mission.name(display).len + 8); - } - try w.writeAll(")\n"); - } - /// Point `point` of `formation_points` as its formation's name indexed by its place among the /// formation's points, from 1. fn pointName(source: Source, point: u16) Allocator.Error![]const u8 { - if (point >= source.points.len) return std.fmt.allocPrint(source.arena, "formation_point_index({d})", .{point}); + if (point >= source.points.len) return std.fmt.allocPrint(source.arena, "{t}({d})", .{ Word.formation_point_index, point }); const formation = source.points[point].formation; const first = if (formation < source.formations.len) source.formations[formation].first_point else point; - return std.fmt.allocPrint(source.arena, "{f}[{d}]", .{ indexed(source.formation_names, formation, "formation_index"), point -| first + 1 }); + return std.fmt.allocPrint(source.arena, "{f}[{d}]", .{ indexed(source.formation_names, formation, .formation_index), point -| first + 1 }); } }; +/// `, { a, b })` and a new line: a declaration's list and its end, or `, {})` for an empty one. +fn writeList(w: *Writer, items: []const []const u8) Writer.Error!void { + if (items.len == 0) return w.writeAll(", {})\n"); + try w.writeAll(", { "); + for (items, 0..) |item, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", item }); + try w.writeAll(" })\n"); +} + /// A value the game names, by its name as a string, or its number where it has none. const Named = struct { kind: vocabulary.Kind,