From 62992f931efaf707c61fee5579f54ed4f703d066 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 03:58:09 +0200 Subject: [PATCH 1/4] feat: sltool dte build compiles mission source into a mission file The compiler reads the declarations in the file's one namespace, builds each kind's records in the order of its declarations, and compiles the code of parts and triggers through the statements model. Errors stop the build with their line and column. The shipped missions number their objects in the order their authors made them, with gaps, which the source doesn't keep. The compiler numbers the objects that last_event names first, since the script names those by a byte, then the ships, flight groups and squads. Unread fields take what most shipped records hold (the records' blank values, which mission 0 now shares), and unread sections stay empty. dte/same.zig defines the round trip's "same mission": the records and code decoded, objects by the records the game takes them for. sltool dte check decompiles each mission, builds it again, compares the two and binds the result. Every mission in the game folder comes back as the same mission. The decompiler now names objects, lists squad members and lists formation points as the game finds them, and keeps triggers, squads and formations to 100 columns. source.Symbols, the YAML plan's table, goes unused and is removed. Closes #1136 --- docs/README.md | 2 +- docs/formats/dte.md | 27 +- docs/formats/mission-source.md | 60 +- src/engine/game/mission/bind.zig | 4 +- src/formats/dte.zig | 180 +++++ src/formats/dte/same.zig | 277 +++++++ src/formats/dte/source.zig | 155 +--- src/formats/dte/source/compile.zig | 937 ++++++++++++++++++++++++ src/formats/dte/source/compile/code.zig | 377 ++++++++++ src/formats/dte/source/names.zig | 28 +- src/formats/dte/source/syntax.zig | 57 +- src/formats/dte/source/vocabulary.zig | 52 ++ src/formats/dte/source/write.zig | 168 +++-- src/formats/dte/statements.zig | 91 +++ src/openreliant/mission0.zig | 55 +- src/source_parser.zig | 237 +++++- src/source_parser/luau.cpp | 13 +- src/source_parser/luau.h | 3 +- src/tools/sltool/dte.zig | 40 +- 19 files changed, 2453 insertions(+), 310 deletions(-) create mode 100644 src/formats/dte/same.zig create mode 100644 src/formats/dte/source/compile.zig create mode 100644 src/formats/dte/source/compile/code.zig diff --git a/docs/README.md b/docs/README.md index 3051d372..8168586b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +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/mission-source.md`](formats/mission-source.md) | Mission source: a mission as Luau text, which `sltool dte decompile` writes and `sltool dte build` compiles. | | [`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 9cdb9797..0fcdfad8 100644 --- a/docs/formats/dte.md +++ b/docs/formats/dte.md @@ -654,7 +654,7 @@ alone. A mission of OpenReliant's making holds what the template's missions hold: its section 24 is theirs (`write.template.command_flags`), and its records carry the values most of theirs carry where their -fields are not known. Mission 0, the sandbox, is written so by the build +fields are not known (`Ship.blank` and the other records'). Mission 0, the sandbox, is written so by the build ([`mission0.zig`](../../src/openreliant/mission0.zig)). ### Writing the script @@ -720,7 +720,7 @@ the original: the same instructions at the same places, and the same constants. instruction reaches may differ: the block's padding, the unused arm slots of a random choice and the constant table's filler, which hold stale bytes in the shipped missions. -## Mission source groundwork +## Mission source Mission source compiles to a standard `.DTE` ([#1126](https://github.com/OpenReliant/openreliant/issues/1126)), and [Mission @@ -729,24 +729,11 @@ model is the one [#608](https://github.com/OpenReliant/openreliant/issues/608) s declared under names of their own, apart from their display names, references that say what kind of record they name, and nothing dropped without a word. Its script is the statements above. -[`dte/source.zig`](../../src/formats/dte/source.zig) has `Symbols(Record)`, a table of one kind of -record, each under its name ([#609](https://github.com/OpenReliant/openreliant/issues/609)). Each -kind of record, and each script bank, has a table of its own, so the same name can stand for -records in two of them. A name has nothing to do with the record's display name, its object id or -its place in the file. The table gives a record's index by its name, and the compiler checks that -the index fits the file's field. - -Names don't depend on the base game's content either. A record can use a ship type a mod adds, by -its number, and the compiler is to look up named content in the game's records with its mods, in -their load order. - -The table borrows the records and their names. After a name or the order changes, a new table -gives each record its new index. - -Luau's parser reads the text ([Reading the text](mission-source.md#reading-the-text)). **Not -implemented yet:** building missions from text, which follows under -[#1126](https://github.com/OpenReliant/openreliant/issues/1126). The binary reader, writer and -assembler stay the format's implementation. +[`dte/source.zig`](../../src/formats/dte/source.zig) and its children write a mission as source, +hold the text as Luau's parser reads it, and compile it into a mission file through the writer and +the assembler above ([Building a mission](mission-source.md#building-a-mission)). +[`dte/same.zig`](../../src/formats/dte/same.zig) checks that a mission built again is the same +mission ([The round trip](mission-source.md#the-round-trip)). ## Prior art diff --git a/docs/formats/mission-source.md b/docs/formats/mission-source.md index 6a14d377..f39f043d 100644 --- a/docs/formats/mission-source.md +++ b/docs/formats/mission-source.md @@ -4,8 +4,7 @@ Mission source is a mission written as text. `sltool dte decompile` writes a mis 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, which Luau's own parser reads as the mission is built: nothing in the file runs, in -OpenReliant or anywhere else. **Not implemented yet:** `sltool dte build`, the compiler on top of -the parser ([Reading the text](#reading-the-text)). +OpenReliant or anywhere else. ```luau -- mission1.dte as source. @@ -50,8 +49,9 @@ At its top level it holds declarations and nothing else: - `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. +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, a command's name 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 @@ -257,11 +257,17 @@ The source holds what the game reads of a mission. It leaves out: 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 objects' numbers. The shipped missions number their ships, flight groups and squads in the + order their authors made them, with gaps. The compiler numbers them itself: first the objects + that `last_event` names, in the order the source names them, since the script names those by a + byte, then the ships, the flight groups and the squads, each in order; - 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. +compiler writes the fields as most of the shipped missions' records hold them (`dte.Ship.blank` and +the other records'), or 0 where they hold no value most, and leaves the sections empty, as mission 0 +does. 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. @@ -282,7 +288,22 @@ original's disassembly, reads none. 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`. +`formation_point_index`. `object_index` names an object by its number in the built mission, and a +number past its records gets an entry of no record in the object table. + +## Building a mission + +```bash +sltool dte decompile mission1.dte > mission1.mission.luau +sltool dte build mission1.mission.luau mission1.dte +``` + +`sltool dte build` compiles the source with +[`compile.zig`](../../src/formats/dte/source/compile.zig). It stops at the first error, which it +gives with its line and column, such as ``patrol.mission.luau:12:5: `nobody` is not declared``. Each +kind's records are written in the order of their declarations. The script holds the blocks the +triggers run, in the order the triggers first run them, then the parts' blocks in order, each +written as the developers' compiler wrote its statements ([Statements](dte.md#statements)). ## Reading the text @@ -299,17 +320,22 @@ unsupported node with its place, for the compiler to refuse. An expression in br type assertion such as `x :: number`, is the expression itself, type annotations on locals and parameters change nothing, and a string in backquotes without values is text like any other. -`sltool dte check` writes each mission it checks as source and parses it, and fails on an error or -a construct mission source doesn't use. - ## 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. +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. A squad's members and a +formation's points are written as the game finds them, from the squad's first member and the +formation's first point. + +Built again, a decompiled mission is the same mission +([`same.zig`](../../src/formats/dte/same.zig)): the same records and code, decoded, with names +compared as text, an object by the record the game takes it for, and a formation's point by its +place among the formation's. The string pool's layout, its stale strings, the objects' numbers, the +sections' layout and the parked bytes may differ. Every shipped mission comes back as the same +mission. To keep a shipped mission byte for byte, edit its `.DTE` in place instead. + +`sltool dte check` runs the round trip: it writes the mission as source, parses it, fails on an +error or a construct mission source doesn't use, builds the source again, compares the two missions +and binds the new one as starting a mission does. diff --git a/src/engine/game/mission/bind.zig b/src/engine/game/mission/bind.zig index 6cc3af69..11daebaf 100644 --- a/src/engine/game/mission/bind.zig +++ b/src/engine/game/mission/bind.zig @@ -549,7 +549,7 @@ fn listWaypoints(gpa: Allocator, ships: []align(1) dte.Ship) Allocator.Error![]M /// `mission_list_group_ships` (`0x00452EC0`): each flight group's ships, in the order the mission /// lists them, and the group's count of them and the first one's place. -fn listGroupShips(gpa: Allocator, groups: []align(1) dte.FlightGroup, ships: []align(1) const dte.Ship) Allocator.Error![]u16 { +pub fn listGroupShips(gpa: Allocator, groups: []align(1) dte.FlightGroup, ships: []align(1) const dte.Ship) Allocator.Error![]u16 { var listed: std.ArrayList(u16) = .empty; errdefer listed.deinit(gpa); for (groups, 0..) |*group, index| { @@ -568,7 +568,7 @@ fn listGroupShips(gpa: Allocator, groups: []align(1) dte.FlightGroup, ships: []a /// `mission_resolve_objects` (`0x00452DB0`): for each entry of the object table, the record it /// stands for (`mission_object_record`, `0x00452DF0`): of its kind, the first whose object ID is /// the entry's. -fn resolveObjects(gpa: Allocator, file: dte.Mission) !([]?Mission.Record) { +pub fn resolveObjects(gpa: Allocator, file: dte.Mission) !([]?Mission.Record) { const objects = try file.objects(); const records = try gpa.alloc(?Mission.Record, objects.len); errdefer gpa.free(records); diff --git a/src/formats/dte.zig b/src/formats/dte.zig index d95ec24f..e4d807da 100644 --- a/src/formats/dte.zig +++ b/src/formats/dte.zig @@ -29,6 +29,8 @@ pub const listing = @import("dte/listing.zig"); pub const statements = @import("dte/statements.zig"); /// Reading a routine's bytecode as statements. pub const decompile = @import("dte/decompile.zig"); +/// Whether two missions are the same mission. +pub const same = @import("dte/same.zig"); /// Identity and typed references for mission source collections (#608). pub const source = @import("dte/source.zig"); @@ -253,6 +255,33 @@ pub const Ship = extern struct { /// The `tier` most records hold, which asks for the campaign's loadout tier. pub const campaign_tier: u8 = 0xFF; + /// The `launch_from` of a ship that doesn't launch, as every shipped record holds. + pub const no_launch_from: u16 = 0xFFFF; + + /// The `marker_curve` of a record that marks no curve. + pub const no_marked_curve: i16 = -1; + + /// A ship with nothing set: in no flight group, flown by no pilot, launching from nothing, in + /// no formation and marking no curve, with every component intact. The bytes the engine + /// doesn't read hold what most of the shipped missions' ships hold. + pub const blank: Ship = blank: { + var ship = std.mem.zeroes(Ship); + ship.flight_group = no_flight_group; + ship.pilot = no_pilot; + ship.launch_from = no_launch_from; + ship._unknown_2a = 0xFF; + ship.launch_gate = no_launch; + ship.intact_components = all_intact; + ship.formation_point = no_formation_point; + ship.formation_point_tag = .none; + ship._unknown_37 = 0xFF; + ship._unknown_3c = 0xFF; + ship.tier = campaign_tier; + ship.marker_curve = no_marked_curve; + ship._unknown_42 = .{ 0xFF, 0xFF }; + break :blank ship; + }; + /// The `kind` of a waypoint: a point a flight group's Patrol Route flies through, in the order /// the mission lists them. pub const waypoint_kind: u16 = 0x3E5; @@ -374,6 +403,19 @@ pub const Curve = extern struct { arriving: [3]f32, _unknown_40: u32, + /// A curve between no ships, at the origin, its last word as most shipped curves hold it. + pub const blank: Curve = .{ + .start = .none, + .end = .none, + .from = @splat(0), + .to = @splat(0), + .leaving_handle = .none, + .arriving_handle = .none, + .leaving = @splat(0), + .arriving = @splat(0), + ._unknown_40 = 0xFFFFFFFF, + }; + /// The ship it starts at, where it starts at one. pub fn startShip(curve: Curve) ?u16 { return if (curve.start.index == Reference.unset) null else curve.start.index; @@ -428,6 +470,22 @@ pub const Part = extern struct { /// An `offset` meaning the part has no block. pub const no_block: u16 = 0xFFFF; + /// A part with no block, which takes no arguments and doesn't run at the start. The bytes the + /// engine doesn't read hold what most of the shipped missions' parts hold. + pub const blank: Part = .{ + .name = 0, + ._unknown_02 = 0, + ._unknown_04 = @splat(0), + .offset = no_block, + .flags = .{ .start = false, ._unknown = 0 }, + .arguments = 0, + ._unknown_0e = 0, + .length = 0, + ._unknown_12 = .{ 0, 0, 0, 0, 0xFF, 0xFF, 0xFF }, + .kind = 0, + ._unknown_1a = 2, + }; + pub const Flags = packed struct(u8) { /// Run when the mission starts, before any trigger is armed. Every mission has one such /// part (`mission_script_start`, `0x0045CBC0`). @@ -508,8 +566,12 @@ pub const Global = extern struct { name: u16, _unknown_02: u16, value: u32, + /// 7 in every shipped mission. _unknown_08: u32, + /// A global that starts at 0, its last word as every shipped global holds it. + pub const blank: Global = .{ .name = 0, ._unknown_02 = 0, .value = 0, ._unknown_08 = 7 }; + comptime { assert(@sizeOf(Global) == 0x0C); } @@ -564,6 +626,24 @@ pub const Trigger = extern struct { /// The qualifier of an event on the subject itself rather than one of its components. pub const whole_object: u8 = 0xFF; + /// A trigger on Shot At that runs nothing, fires once, watches the whole subject and checks no + /// value of its event. The bytes the engine doesn't read hold what most of the shipped + /// missions' triggers hold. + pub const blank: Trigger = .{ + .condition = .shot_at, + .repeat = .once, + .link = Part.no_block, + ._unknown_04 = @splat(0), + .armed = 1, + .qualifier = whole_object, + .deferred = 0, + ._unknown_17 = .{ 0xFF, 0 }, + .repeat_counter = 0, + .repeat_count = 0, + ._unknown_1b = 0x88, + .operands = @splat(Operand.write(.unset)), + }; + pub const Repeat = enum(u8) { /// Disarms when it fires. once = 0, @@ -645,6 +725,15 @@ pub const Reference = packed struct(u32) { /// An operand whose low halfword is this is not set, and is not checked. pub const unset: u16 = 0xFFFF; + /// A reference to nothing, every bit set, as an unset trigger operand holds. + pub const none: Reference = .{ .index = unset, .tag = .none, ._unknown_24 = 0xFF }; + + /// A reference to record `index` of the table `tag` names, as the shipped missions write one: + /// its last byte 0xFF. + pub fn of(tag: Tag, index: u16) Reference { + return .{ .index = index, .tag = tag, ._unknown_24 = 0xFF }; + } + /// Set in the index of an operand for a ship value, it matches any of the players' ships: in a /// game of one, the player's. pub const any_ship: u16 = 0x2000; @@ -665,6 +754,19 @@ pub const Operand = union(enum) { /// A tag the matcher cannot resolve. other: u32, + /// The raw operand that reads back as `operand`, as the shipped missions write one + /// (`Reference.of`), and every bit set for none. + pub fn write(operand: Operand) u32 { + return switch (operand) { + .unset => @bitCast(Reference.none), + .number, .other => |raw| raw, + .any_ship => @bitCast(Reference.of(.ship, Reference.any_ship)), + .ship => |index| @bitCast(Reference.of(.ship, index)), + .flight_group => |index| @bitCast(Reference.of(.flight_group, index)), + .squad => |index| @bitCast(Reference.of(.squad, index)), + }; + } + pub fn read(raw: u32, kinds: commands.Kinds) Operand { const reference: Reference = @bitCast(raw); if (reference.index == Reference.unset) return .unset; @@ -692,6 +794,12 @@ test Operand { try std.testing.expectEqual(Operand{ .other = 0x000C0050 }, Operand.read(0x000C0050, @bitCast(@as(u32, 0x1000)))); // The matcher doesn't resolve a reference to a squad's member. try std.testing.expectEqual(Operand{ .other = 0xFF170003 }, Operand.read(0xFF170003, ship)); + // Written as the shipped missions write them, operands read back as they were. + for ([_]Operand{ .unset, .any_ship, .{ .ship = 7 }, .{ .flight_group = 4 }, .{ .squad = 2 }, .{ .other = 0xFF170003 } }) |operand| { + try std.testing.expectEqual(operand, Operand.read(Operand.write(operand), ship)); + } + try std.testing.expectEqual(Operand{ .number = 50 }, Operand.read(Operand.write(.{ .number = 50 }), number)); + try std.testing.expectEqual(0xFF002000, Operand.write(.any_ship)); } /// One entry of the object table, section `objects`, indexed by object ID. @@ -720,6 +828,9 @@ pub const Object = extern struct { _unknown_5: u27 = 0, }; + /// The `first` of an object with no triggers, as every shipped mission's holds. + pub const no_triggers: u16 = 0xFFFF; + /// Where its slice starts and ends in a trigger list of `triggers` triggers, as far as the list /// reaches. pub fn triggerBounds(object: Object, triggers: usize) [2]usize { @@ -822,6 +933,20 @@ pub const FlightGroup = extern struct { /// The `formation` of a group with none, as every shipped mission's has. pub const no_formation: Reference = .{ .index = Reference.unset, .tag = .formation, ._unknown_24 = 0xFF }; + /// A group of no wing and no formation, whose ships binding the mission lists + /// (`mission.bind.listGroupShips`), its unread bytes 0, as in every shipped mission. + pub const blank: FlightGroup = .{ + .object_id = 0, + ._unknown_02 = 0, + .name = 0, + ._unknown_06 = 0, + .wing = .none, + .ship_count = 0, + ._unknown_0a = 0, + .first_ship = no_ship, + .formation = no_formation, + }; + /// A flight group's wing, as a byte: the player's, which `mission_ship_create` tests for /// (`0x00457D39`), two more, or none. pub const Wing = enum(u8) { @@ -869,6 +994,17 @@ pub const Squad = extern struct { /// The `first_member` of a squad with none. pub const no_member: u16 = 0xFFFF; + /// A squad with no members, its unread bytes as every shipped squad holds them. + pub const blank: Squad = .{ + .object_id = 0, + ._unknown_02 = 0, + .name = 0, + ._unknown_06 = 0, + .first_member = no_member, + .first_member_tag = .none, + ._unknown_0b = 0xFF, + }; + /// Its first record in `squad_members`, where it has one. pub fn firstMember(squad: Squad) ?u16 { return if (squad.first_member == no_member) null else squad.first_member; @@ -896,6 +1032,16 @@ pub const SquadMember = extern struct { component: u8, _unknown_09: [3]u8, + /// A member that is a whole object, its unread bytes as most shipped members hold them. + pub const blank: SquadMember = .{ + .object_id = 0, + ._unknown_02 = 0, + .squad = 0, + ._unknown_06 = 0, + .component = Trigger.whole_object, + ._unknown_09 = .{ 0x87, 0x1E, 0x0D }, + }; + /// The component it names, or null for the whole object (`Trigger.whole_object`). pub fn part(member: SquadMember) ?u8 { return if (member.component == Trigger.whole_object) null else member.component; @@ -920,6 +1066,10 @@ pub const Formation = extern struct { first_point_tag: Reference.Tag, _unknown_07: u8, + /// A formation whose points start at the first, its unread byte as every shipped formation + /// holds it. + pub const blank: Formation = .{ .name = 0, ._unknown_02 = 0, .first_point = 0, .first_point_tag = .formation_point, ._unknown_07 = 0xFF }; + comptime { assert(@offsetOf(Formation, "first_point") == 0x04); assert(@sizeOf(Formation) == 8); @@ -937,6 +1087,10 @@ pub const FormationPoint = extern struct { /// Where it stands from the formation's origin. offset: [3]f32, + /// A point of the first formation at its origin, its unread byte as every shipped point holds + /// it. + pub const blank: FormationPoint = .{ .formation = 0, .formation_tag = .formation, ._unknown_03 = 0xFF, .offset = @splat(0) }; + comptime { assert(@offsetOf(FormationPoint, "offset") == 0x04); assert(@sizeOf(FormationPoint) == 0x10); @@ -1715,6 +1869,32 @@ pub const Mission = struct { return mission.records(FormationPoint, .formation_points); } + /// The members of squad `squad`, as the game walks them (`squad_walk`, `0x00401D80`): from the + /// squad's first on, while they are its own. None for a squad the mission lacks. + pub fn membersOf(mission: Mission, squad: usize) Error![]align(1) const SquadMember { + const all = try mission.squadMembers(); + const list = try mission.squads(); + if (squad >= list.len) return &.{}; + const first = @min(list[squad].first_member, all.len); + var end = first; + while (end < all.len and all[end].squad == squad) end += 1; + return all[first..end]; + } + + /// The points of formation `formation`, by their indices, as the game finds them + /// (`formation_leader`, `0x00404230`): from the formation's first point on, those that are its + /// own. None for a formation the mission lacks. + pub fn pointsOf(mission: Mission, allocator: Allocator, formation: usize) (Error || Allocator.Error)![]u16 { + const all = try mission.formationPoints(); + const list = try mission.formations(); + if (formation >= list.len) return &.{}; + var found: std.ArrayList(u16) = .empty; + for (all[@min(list[formation].first_point, all.len)..], list[formation].first_point..) |point, index| { + if (point.formation == formation) try found.append(allocator, @intCast(index)); + } + return found.toOwnedSlice(allocator); + } + /// For each trigger, the ID of the object whose slice holds it, or null when none does and /// the trigger can never fire. No shipped trigger is in two slices; for one that is, the first /// object's, as the engine finds it (`0x00453530`). diff --git a/src/formats/dte/same.zig b/src/formats/dte/same.zig new file mode 100644 index 00000000..b6b3977e --- /dev/null +++ b/src/formats/dte/same.zig @@ -0,0 +1,277 @@ +//! Whether two missions are the same mission +//! ([The round trip](../../../docs/formats/mission-source.md#the-round-trip)): what the game reads of +//! them is equal. Records are compared field by field, their names as text, an object by the record +//! the game takes it for, a formation's point by its place among the formation's, and the code of +//! parts and triggers as statements. The string pool's layout, the objects' numbers, the sections' +//! layout and the bytes the game doesn't read may differ. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const dte = @import("../dte.zig"); +const decompile = @import("decompile.zig"); +const statements = @import("statements.zig"); +const bind = @import("../../engine/game/mission/bind.zig"); + +pub const Error = dte.Error || decompile.Error; + +/// The first way `b` differs from `a`, as a message, or null where they are the same mission. +pub fn difference(arena: Allocator, a: dte.Mission, b: dte.Mission) Error!?[]const u8 { + var comparison: Comparison = .{ + .arena = arena, + .a = try .init(arena, a), + .b = try .init(arena, b), + }; + return comparison.run(); +} + +/// A mission and what comparing it needs worked out once. +const Side = struct { + mission: dte.Mission, + /// The record each object stands for, by its object ID. + objects: []const ?bind.Mission.Record, + /// The object whose slice holds each trigger, where one does. + owners: []const ?u16, + /// Each formation's points, by their indices, as the game finds them. + points: []const []const u16, + + fn init(arena: Allocator, mission: dte.Mission) Error!Side { + const points = try arena.alloc([]const u16, (try mission.formations()).len); + for (points, 0..) |*list, index| list.* = try mission.pointsOf(arena, index); + return .{ + .mission = mission, + .objects = try bind.resolveObjects(arena, mission), + .owners = try mission.triggerObjects(arena), + .points = points, + }; + } + + /// What object `id` stands for: its record, or the ID itself where it stands for none. + fn object(side: Side, id: u32) Object { + if (id < side.objects.len) if (side.objects[id]) |record| return .{ .record = record }; + return .{ .id = id }; + } + + /// What formation point `point` is: its place among its formation's points, or the index + /// itself where it is none of its formation's. + fn point(side: Side, index: u16) Point { + const all = side.mission.formationPoints() catch &.{}; + if (index < all.len and all[index].formation < side.points.len) { + const formation = all[index].formation; + if (std.mem.findScalar(u16, side.points[formation], index)) |place| return .{ .place = .{ formation, place } }; + } + return .{ .index = index }; + } + + /// The statements of the block `start` bytes into the script. + fn code(side: Side, arena: Allocator, start: usize) Error![]const statements.Statement { + return decompile.block(arena, try side.mission.script(), start); + } +}; + +const Object = union(enum) { + record: bind.Mission.Record, + id: u32, +}; + +const Point = union(enum) { + place: struct { u16, usize }, + index: u16, +}; + +const Comparison = struct { + arena: Allocator, + a: Side, + b: Side, + + fn say(c: *Comparison, comptime message: []const u8, args: anytype) Error!?[]const u8 { + return try std.fmt.allocPrint(c.arena, message, args); + } + + /// Objects compared by the records they stand for, for `statements.same`. + fn objects(c: *const Comparison) Objects { + return .{ .a = &c.a, .b = &c.b }; + } + + const Objects = struct { + a: *const Side, + b: *const Side, + + pub fn same(sides: Objects, x: u8, y: u8) bool { + return std.meta.eql(sides.a.object(x), sides.b.object(y)); + } + }; + + fn sameName(c: *Comparison, a: u16, b: u16) bool { + return std.mem.eql(u8, c.a.mission.name(a), c.b.mission.name(b)); + } + + fn run(c: *Comparison) Error!?[]const u8 { + const a = c.a.mission; + const b = c.b.mission; + if (!sameText(a.openReliantName(), b.openReliantName())) return c.say("the mission's name differs", .{}); + inline for (.{ + .{ "global", dte.Mission.globals, Comparison.global }, + .{ "flight group", dte.Mission.flightGroups, Comparison.flightGroup }, + .{ "ship", dte.Mission.ships, Comparison.ship }, + .{ "squad", dte.Mission.squads, Comparison.squad }, + .{ "formation", dte.Mission.formations, Comparison.formation }, + .{ "curve", dte.Mission.curves, Comparison.curve }, + .{ "part", dte.Mission.parts, Comparison.part }, + .{ "trigger", dte.Mission.triggers, Comparison.trigger }, + }) |kind| { + const left = try kind[1](a); + const right = try kind[1](b); + if (left.len != right.len) return c.say("one mission has {d} " ++ kind[0] ++ "s, and the other {d}", .{ left.len, right.len }); + for (left, right, 0..) |x, y, index| { + if (try kind[2](c, x, y, index)) |field| return c.say(kind[0] ++ " {d} differs in its {s}", .{ index, field }); + } + } + if ((try a.records(dte.Part, .parts_b)).len != (try b.records(dte.Part, .parts_b)).len) return c.say("the second script's parts differ", .{}); + return null; + } + + fn global(c: *Comparison, x: dte.Global, y: dte.Global, _: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + if (x.value != y.value) return "value"; + return null; + } + + fn flightGroup(c: *Comparison, x: dte.FlightGroup, y: dte.FlightGroup, _: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + if (x.wing != y.wing) return "wing"; + if (x.formation.index != y.formation.index) return "formation"; + return null; + } + + fn ship(c: *Comparison, x: dte.Ship, y: dte.Ship, _: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + if (x.kind != y.kind) return "type"; + if (x.flight_group != y.flight_group) return "flight group"; + if (x.pilot != y.pilot) return "pilot"; + if (!sameFloats(x.position, y.position)) return "position"; + if (x.yaw != y.yaw or x.pitch != y.pitch or x.roll != y.roll) return "angles"; + if (x.launch_gate != y.launch_gate or (x.launchGate() != null and x.launch_from != y.launch_from)) return "launch"; + if ((x.formationPoint() == null) != (y.formationPoint() == null)) return "formation point"; + if (x.formationPoint()) |point| if (!std.meta.eql(c.a.point(point), c.b.point(y.formation_point))) return "formation point"; + if (x.tier != y.tier) return "tier"; + if (x.kind == dte.Ship.point_kind) { + if (!std.meta.eql(x.markedCurve(), y.markedCurve())) return "marked curve"; + if (x.markedCurve() != null and !sameFloat(x.marker_at, y.marker_at)) return "place on its curve"; + } + return null; + } + + fn squad(c: *Comparison, x: dte.Squad, y: dte.Squad, index: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + const left = try c.a.mission.membersOf(index); + const right = try c.b.mission.membersOf(index); + if (left.len != right.len) return "members"; + for (left, right) |p, q| { + if (p.component != q.component or !std.meta.eql(c.a.object(p.object_id), c.b.object(q.object_id))) return "members"; + } + return null; + } + + fn formation(c: *Comparison, x: dte.Formation, y: dte.Formation, index: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + const left = c.a.points[index]; + const right = c.b.points[index]; + if (left.len != right.len) return "points"; + const left_all = try c.a.mission.formationPoints(); + const right_all = try c.b.mission.formationPoints(); + for (left, right) |p, q| if (!sameFloats(left_all[p].offset, right_all[q].offset)) return "points"; + return null; + } + + fn curve(_: *Comparison, x: dte.Curve, y: dte.Curve, _: usize) Error!?[]const u8 { + if (x.start.index != y.start.index or x.end.index != y.end.index) return "ships"; + if (x.leaving_handle.index != y.leaving_handle.index or x.arriving_handle.index != y.arriving_handle.index) return "handles"; + if (!sameFloats(x.from, y.from) or !sameFloats(x.to, y.to)) return "ends"; + if (!sameFloats(x.leaving, y.leaving) or !sameFloats(x.arriving, y.arriving)) return "tangents"; + return null; + } + + fn part(c: *Comparison, x: dte.Part, y: dte.Part, _: usize) Error!?[]const u8 { + if (!c.sameName(x.name, y.name)) return "name"; + if (x.flags.start != y.flags.start) return "start"; + if (x.isEmpty() != y.isEmpty()) return "code"; + if (x.isEmpty()) return null; + if (x.arguments != y.arguments) return "arguments"; + if (!statements.same(try c.a.code(c.arena, x.start()), try c.b.code(c.arena, y.start()), c.objects())) return "code"; + return null; + } + + fn trigger(c: *Comparison, x: dte.Trigger, y: dte.Trigger, index: usize) Error!?[]const u8 { + if (x.condition != y.condition) return "condition"; + if (x.repeat != y.repeat) return "repeats"; + if (x.repeat == .counted and (x.repeat_count != y.repeat_count or x.repeat_counter != y.repeat_counter)) return "repeats"; + if (x.qualifier != y.qualifier) return "component"; + if ((x.deferred != 0) != (y.deferred != 0)) return "deferred"; + if (x.condition.descriptor()) |condition| { + for (condition.values, 0..) |value, slot| { + if (!value.checked or slot >= x.operands.len) continue; + if (!std.meta.eql(dte.Operand.read(x.operands[slot], value.kinds), dte.Operand.read(y.operands[slot], value.kinds))) return "values it checks"; + } + } + const left_owner = c.a.owners[index]; + const right_owner = c.b.owners[index]; + if ((left_owner == null) != (right_owner == null)) return "subject"; + // A trigger no object holds never fires, so what it would run doesn't count. + const owner = left_owner orelse return null; + if (!std.meta.eql(c.a.object(owner), c.b.object(right_owner.?))) return "subject"; + if ((x.block() == null) != (y.block() == null)) return "code"; + const start = x.block() orelse return null; + if (!statements.same(try c.a.code(c.arena, start), try c.b.code(c.arena, y.block().?), c.objects())) return "code"; + return null; + } +}; + +fn sameText(a: ?[]const u8, b: ?[]const u8) bool { + const left = a orelse return b == null; + return std.mem.eql(u8, left, b orelse return false); +} + +fn sameFloat(a: f32, b: f32) bool { + return a == b and std.math.signbit(a) == std.math.signbit(b) or std.math.isNan(a) and std.math.isNan(b); +} + +fn sameFloats(a: [3]f32, b: [3]f32) bool { + for (a, b) |x, y| if (!sameFloat(x, y)) return false; + return true; +} + +test difference { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const first = try testMission(arena, .{ .ship = 0, .group = 1 }, 90); + // The same records, with their objects numbered the other way round, are the same mission. + try std.testing.expectEqual(null, try difference(arena, first, try testMission(arena, .{ .ship = 1, .group = 0 }, 90))); + // A ship turned another way is not. + const turned = try testMission(arena, .{ .ship = 0, .group = 1 }, 45); + try std.testing.expectEqualStrings("ship 0 differs in its angles", (try difference(arena, first, turned)).?); +} + +/// A mission of a ship and its flight group, with a trigger on the group's destruction. The ship +/// and the group take the object IDs `ids` gives, and the ship is turned `yaw` degrees. +fn testMission(arena: Allocator, ids: struct { ship: u16, group: u16 }, yaw: i16) !dte.Mission { + const gameobj = @import("../../engine/game/gameobj.zig"); + var ship = dte.testing.ship(ids.ship, 0, @intCast(@backingInt(gameobj.GameType.grendel))); + ship.yaw = yaw; + var group = dte.testing.flightGroup(ids.group, .player); + group.name = "Player\x00".len; + var trigger: dte.Trigger = .blank; + trigger.condition = .destroyed; + var objects: [2]dte.Object = undefined; + objects[ids.ship] = dte.testing.object(.ship, dte.Object.no_triggers, 0); + objects[ids.group] = dte.testing.object(.flight_group, 0, 1); + const pool = "Player\x00(FG)Alpha\x00"; + var sections: dte.write.Sections = @splat(.{}); + dte.write.set(§ions, .strings, pool.len, pool); + dte.write.set(§ions, .ships, 1, std.mem.asBytes(&ship)); + dte.write.set(§ions, .flight_groups, 1, std.mem.asBytes(&group)); + dte.write.set(§ions, .triggers, 1, std.mem.asBytes(&trigger)); + dte.write.set(§ions, .objects, objects.len, std.mem.sliceAsBytes(&objects)); + return .parse(try dte.write.write(arena, §ions, .{})); +} diff --git a/src/formats/dte/source.zig b/src/formats/dte/source.zig index 096741a9..7c58968e 100644 --- a/src/formats/dte/source.zig +++ b/src/formats/dte/source.zig @@ -1,9 +1,7 @@ //! Mission source: a mission as text ([Mission source](../../../docs/formats/mission-source.md)). -//! Its children write the text and hold it as Luau's parser reads it. This file has the tables -//! that find a record by its name (#608): one table for each kind of record, over the format's own -//! records, so that a new kind of record or a new field needs no change here. +//! Its children write the text, hold it as Luau's parser reads it, and compile it into a mission +//! file. const std = @import("std"); -const Allocator = std.mem.Allocator; /// The names records are declared under. pub const names = @import("source/names.zig"); @@ -15,9 +13,11 @@ pub const script = @import("source/script.zig"); pub const write = @import("source/write.zig"); /// The text as Luau's parser reads it. pub const syntax = @import("source/syntax.zig"); +/// The text compiled into a mission file. +pub const compile = @import("source/compile.zig"); /// Whether `id` is made of letters, digits, `_` and `-`, and starts with a letter or `_`: -/// `[A-Za-z_][A-Za-z0-9_-]*`. Mods' scripts name what they register this way too. +/// `[A-Za-z_][A-Za-z0-9_-]*`. Mods' scripts name what they register this way. pub fn validId(id: []const u8) bool { if (id.len == 0 or !(std.ascii.isAlphabetic(id[0]) or id[0] == '_')) return false; for (id[1..]) |char| { @@ -26,67 +26,6 @@ pub fn validId(id: []const u8) bool { return true; } -/// A reference by name to a record of type `Record`. Each type of record has its own type of -/// reference, and the caller picks the table to look it up in, such as a script bank's. -pub fn Reference(comptime Record: type) type { - return struct { - pub const RecordType = Record; - id: []const u8, - }; -} - -/// A table of one kind of record, each under its name. A record's index is its place in the -/// table. Build one table for each kind of record or script bank. The table doesn't check an -/// index against the file's fields: an index here is a `usize`, and the compiler checks that it -/// fits. -pub fn Symbols(comptime Record: type) type { - return struct { - const Self = @This(); - pub const Ref = Reference(Record); - pub const Entry = struct { - id: []const u8, - record: Record, - }; - pub const InitError = Allocator.Error || error{ InvalidId, DuplicateId }; - pub const ResolveError = error{ InvalidId, MissingId }; - - entries: []const Entry, - indices: std.StringHashMapUnmanaged(usize) = .empty, - - /// Makes a table of `entries`, which it borrows with their names until `deinit`. After a - /// name or the order changes, make a new table. Only the names count, never the display - /// names or the object ids. - pub fn init(gpa: Allocator, entries: []const Entry) InitError!Self { - var table: Self = .{ .entries = entries }; - errdefer table.deinit(gpa); - for (entries, 0..) |entry, index| { - if (!validId(entry.id)) return error.InvalidId; - const slot = try table.indices.getOrPut(gpa, entry.id); - if (slot.found_existing) return error.DuplicateId; - slot.value_ptr.* = index; - } - return table; - } - - pub fn deinit(table: *Self, gpa: Allocator) void { - table.indices.deinit(gpa); - table.* = undefined; - } - - /// The index of the record that `reference` names, in this table only. A name that isn't - /// valid gives `InvalidId`, and a valid name the table lacks `MissingId`, so that the - /// compiler can say which. - pub fn indexOf(table: Self, reference: Ref) ResolveError!usize { - if (!validId(reference.id)) return error.InvalidId; - return table.indices.get(reference.id) orelse error.MissingId; - } - - pub fn resolve(table: Self, reference: Ref) ResolveError!*const Record { - return &table.entries[try table.indexOf(reference)].record; - } - }; -} - test validId { for ([_][]const u8{ "player", "_start", "Alpha_2-escort" }) |id| try std.testing.expect(validId(id)); for ([_][]const u8{ "", "2player", "-player", "A B", "A.B", "A\x00B", "é" }) |id| try std.testing.expect(!validId(id)); @@ -100,90 +39,6 @@ test validId { } } -test "source IDs resolve format records independently of display names and binary IDs" { - const dte = @import("../dte.zig"); - const Ships = Symbols(dte.Ship); - const Groups = Symbols(dte.FlightGroup); - const gpa = std.testing.allocator; - const entries = [_]Ships.Entry{ - .{ .id = "player", .record = dte.testing.ship(42, 0, 0) }, - .{ .id = "escort", .record = dte.testing.ship(7, 0, 60000) }, - }; - var ships = try Ships.init(gpa, &entries); - defer ships.deinit(gpa); - const group_entries = [_]Groups.Entry{.{ .id = "player", .record = dte.testing.flightGroup(91, .player) }}; - var groups = try Groups.init(gpa, &group_entries); - defer groups.deinit(gpa); - - // Both ships have the same display name, and the same name can stand for a record of another - // kind. - try std.testing.expectEqual(entries[0].record.name, entries[1].record.name); - try std.testing.expectEqual(42, (try ships.resolve(.{ .id = "player" })).object_id); - try std.testing.expectEqual(91, (try groups.resolve(.{ .id = "player" })).object_id); - // A mod's ship type needs no name in the base game, and keeps its number. - try std.testing.expectEqual(60000, (try ships.resolve(.{ .id = "escort" })).kind); - try std.testing.expect(Ships.Ref != Groups.Ref); - try std.testing.expectError(error.MissingId, groups.indexOf(.{ .id = "escort" })); - - // Reordering the records and changing a display name change neither a record's name nor its - // object id. - var reordered = [_]Ships.Entry{ entries[1], entries[0] }; - reordered[1].record.name = 123; - var rebuilt = try Ships.init(gpa, &reordered); - defer rebuilt.deinit(gpa); - try std.testing.expectEqual(0, try ships.indexOf(.{ .id = "player" })); - try std.testing.expectEqual(1, try rebuilt.indexOf(.{ .id = "player" })); - try std.testing.expectEqual(42, (try rebuilt.resolve(.{ .id = "player" })).object_id); - try std.testing.expectEqual(123, (try rebuilt.resolve(.{ .id = "player" })).name); -} - -test "symbol errors and allocation failures release the index" { - const Table = Symbols(u32); - const gpa = std.testing.allocator; - const entries = [_]Table.Entry{ .{ .id = "one", .record = 1 }, .{ .id = "two", .record = 2 } }; - var table = try Table.init(gpa, &entries); - defer table.deinit(gpa); - try std.testing.expectError(error.InvalidId, table.indexOf(.{ .id = "bad.id" })); - try std.testing.expectError(error.MissingId, table.resolve(.{ .id = "absent" })); - const duplicate = [_]Table.Entry{ entries[0], entries[0] }; - try std.testing.expectError(error.DuplicateId, Table.init(gpa, &duplicate)); - const invalid = [_]Table.Entry{ entries[0], .{ .id = "bad.id", .record = 0 } }; - try std.testing.expectError(error.InvalidId, Table.init(gpa, &invalid)); - // Enough records for the map to grow several times, so that a failed allocation also frees a - // map that exists. - var many: [300]Table.Entry = undefined; - var ids: [many.len][16]u8 = undefined; - for (&many, &ids, 0..) |*entry, *id, index| entry.* = .{ - .id = try std.mem.print(id, "record_{d}", .{index}), - .record = @intCast(index), - }; - try std.testing.checkAllAllocationFailures(gpa, struct { - fn run(allocator: Allocator, records: []const Table.Entry) !void { - var made = try Table.init(allocator, records); - defer made.deinit(allocator); - try std.testing.expectEqual(299, try made.indexOf(.{ .id = "record_299" })); - try std.testing.expectError(error.MissingId, made.indexOf(.{ .id = "Record_299" })); - } - }.run, .{&many}); - var empty = try Table.init(gpa, &.{}); - defer empty.deinit(gpa); - try std.testing.expectError(error.MissingId, empty.indexOf(.{ .id = "one" })); -} - -test "new record types and independent script banks use the same symbol table" { - const ExtendedRecord = struct { payload: u64 }; - const Table = Symbols(ExtendedRecord); - const gpa = std.testing.allocator; - const first = [_]Table.Entry{.{ .id = "start", .record = .{ .payload = 11 } }}; - const second = [_]Table.Entry{.{ .id = "start", .record = .{ .payload = 22 } }}; - var bank_a = try Table.init(gpa, &first); - defer bank_a.deinit(gpa); - var bank_b = try Table.init(gpa, &second); - defer bank_b.deinit(gpa); - 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/compile.zig b/src/formats/dte/source/compile.zig new file mode 100644 index 00000000..8b327b94 --- /dev/null +++ b/src/formats/dte/source/compile.zig @@ -0,0 +1,937 @@ +//! Mission source compiled into a standard mission file, as `sltool dte build` does it +//! ([Mission source](../../../../docs/formats/mission-source.md)). The declarations become the +//! records, each kind in the order of its declarations, and the code of the parts and triggers +//! becomes the script (`compile/code.zig`). What the source leaves out takes what most shipped +//! records hold (`dte.Ship.blank` and the others), and the compiler numbers the objects itself +//! (`Compiler.numberObjects`). + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const dte = @import("../../dte.zig"); +const statements = @import("../statements.zig"); +const bind = @import("../../../engine/game/mission/bind.zig"); +const commands = @import("../../../engine/game/executor/commands.zig"); +const conditions = @import("../../../engine/vm/conditions.zig"); +const names = @import("names.zig"); +const script = @import("script.zig"); +const syntax = @import("syntax.zig"); +const vocabulary = @import("vocabulary.zig"); +const Word = names.Word; +const Index = syntax.Index; + +/// The code of parts and triggers, read into statements. +pub const code = @import("compile/code.zig"); + +/// Why a file doesn't compile, and where. +pub const Failure = struct { + at: syntax.Position, + message: []const u8, + + /// Writes the failure as `line:column: message`. + pub fn format(failure: Failure, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("{f}: {s}", .{ failure.at, failure.message }); + } +}; + +pub const Error = Allocator.Error || error{ + /// The source has an error, which the failure describes. + Invalid, +}; + +/// Compiles `tree` into a mission file, allocated with `arena`. Where the source has an error, the +/// parser's or the compiler's, `failure` says what and where, and the error is `Invalid`. +pub fn compile(arena: Allocator, tree: syntax.Tree, failure: *Failure) Error![]u8 { + if (tree.errors.len > 0) { + failure.* = .{ .at = tree.errors[0].at, .message = tree.errors[0].message }; + return error.Invalid; + } + var compiler: Compiler = .{ .arena = arena, .tree = tree, .failure = failure }; + try compiler.declare(); + return compiler.build(); +} + +/// The kinds of record a name can declare. +pub const Kind = enum { + global, + flight_group, + ship, + squad, + formation, + curve, + part, + handler, + + /// What a record of the kind is called in a message. + pub fn noun(kind: Kind) []const u8 { + return switch (kind) { + .flight_group => "flight group", + else => @tagName(kind), + }; + } + + /// The form that names a record of the kind by its index, such as `ship_index(59031)`, where + /// it has one. + fn indexForm(kind: Kind) ?Word { + return switch (kind) { + inline else => |tag| if (@hasField(Word, @tagName(tag) ++ "_index")) @field(Word, @tagName(tag) ++ "_index") else null, + }; + } +}; + +/// What a name declares: a record of a kind, by its index among the records of its kind, and the +/// call that declares it. +pub const Declared = struct { + kind: Kind, + index: u32, + call: Index, +}; + +/// The fields each constructor takes. +const ship_fields = [_][]const u8{ "type", "group", "pilot", "position", "yaw", "pitch", "roll", "launch", "formation_point", "tier" }; +/// A marker's: a ship's but `type`, and a point's `marker` too. +const marker_fields = ship_fields[1..].*; +const point_fields = marker_fields ++ [_][]const u8{"marker"}; +const curve_fields = [_][]const u8{ "start", "finish", "from", "to", "leaving_handle", "arriving_handle", "leaving", "arriving" }; + +pub const Compiler = struct { + arena: Allocator, + tree: syntax.Tree, + failure: *Failure, + /// What each name of the file declares. + names: std.StringHashMapUnmanaged(Declared) = .empty, + /// The calls that declare each kind's records, in order. + declarations: std.EnumArray(Kind, std.ArrayList(Index)) = .initFill(.empty), + /// The calls of `on`, in order. + triggers: std.ArrayList(Index) = .empty, + /// The call of `mission`, where the file has one. + mission: ?Index = null, + /// The object table's length: the records', or more where `object_index` names an object past + /// them. + object_count: u32 = 0, + /// The object ID of each ship, flight group and squad, by its index (`numberObjects`). + object_ids: std.EnumArray(ObjectKind, []u32) = .initFill(&.{}), + + /// The kinds of record the object table numbers. + const ObjectKind = enum { ship, flight_group, squad }; + + /// Records why the compilation fails, at `node`, and gives `Invalid`. + pub fn fail(c: *Compiler, node: Index, comptime message: []const u8, args: anytype) Error { + const text = std.fmt.allocPrint(c.arena, message, args) catch return error.OutOfMemory; + c.failure.* = .{ .at = c.tree.at(node), .message = text }; + return error.Invalid; + } + + /// The word of the source's own that `node` calls, such as `ship` in `ship(...)`. + pub fn wordOf(c: *Compiler, node: Index) ?Word { + const call = switch (c.tree.get(node)) { + .call => |call| call, + else => return null, + }; + return switch (c.tree.get(call.function)) { + .global_name => |name| std.meta.stringToEnum(Word, name), + else => null, + }; + } + + /// What `name`, at `node`, declares. + pub fn lookup(c: *Compiler, node: Index, name: []const u8) Error!Declared { + return c.names.get(name) orelse c.fail(node, "`{s}` is not declared", .{name}); + } + + fn count(c: *Compiler, kind: Kind) u32 { + return @intCast(c.declarations.get(kind).items.len); + } + + /// The arguments of the call `node`, from `least` to `most` of them, as `usage` says. + fn arguments(c: *Compiler, node: Index, least: usize, most: usize, comptime usage: []const u8) Error![]const Index { + const list = c.tree.get(node).call.arguments; + if (list.len < least or list.len > most) return c.fail(node, usage, .{}); + return list; + } + + // The declarations. + + fn declare(c: *Compiler) Error!void { + const usage = "the top level holds declarations, `on(...)` and `mission(...)`"; + for (c.tree.get(c.tree.root).block) |line| switch (c.tree.get(line)) { + .assign => |binding| try c.declaration(line, binding), + .call_statement => |call| { + const called = c.wordOf(call) orelse return c.fail(line, usage, .{}); + switch (called) { + .on => try c.triggers.append(c.arena, call), + .mission => { + if (c.mission != null) return c.fail(line, "the mission's details are given twice", .{}); + c.mission = call; + }, + else => return c.fail(line, usage, .{}), + } + }, + .unsupported => |what| return c.fail(line, "{s}, which mission source doesn't use", .{what}), + else => return c.fail(line, usage, .{}), + }; + try c.numberObjects(); + } + + /// Numbers the objects: first those that `last_event` names, in the order the source names + /// them, since the script names those by a byte, then the rest, the ships, the flight groups + /// and the squads, each in order. + fn numberObjects(c: *Compiler) Error!void { + const unnumbered = std.math.maxInt(u32); + for (std.enums.values(ObjectKind)) |kind| { + const ids = try c.arena.alloc(u32, c.count(kindOf(kind))); + @memset(ids, unnumbered); + c.object_ids.set(kind, ids); + } + var next: u32 = 0; + for (0..c.tree.nodes.len) |index| { + const node: Index = @intCast(index); + if (c.wordOf(node) != .last_event) continue; + const given = c.tree.get(node).call.arguments; + if (given.len == 0 or c.tree.get(given[0]) != .global_name) continue; + const declared = c.names.get(c.tree.get(given[0]).global_name) orelse continue; + const kind = objectKind(declared.kind) orelse continue; + const id = &c.object_ids.get(kind)[declared.index]; + if (id.* != unnumbered) continue; + id.* = next; + next += 1; + } + for (std.enums.values(ObjectKind)) |kind| { + for (c.object_ids.get(kind)) |*id| if (id.* == unnumbered) { + id.* = next; + next += 1; + }; + } + c.object_count = next; + } + + fn kindOf(kind: ObjectKind) Kind { + return switch (kind) { + inline else => |tag| @field(Kind, @tagName(tag)), + }; + } + + fn objectKind(kind: Kind) ?ObjectKind { + return std.meta.stringToEnum(ObjectKind, @tagName(kind)); + } + + /// `name = constructor(...)`: the record a name declares. + fn declaration(c: *Compiler, line: Index, binding: syntax.Syntax.Binding) Error!void { + if (binding.targets.len != 1 or binding.values.len != 1) return c.fail(line, "a declaration names one record: `name = ship(...)`", .{}); + const target = binding.targets[0]; + const name = switch (c.tree.get(target)) { + .global_name => |name| name, + else => return c.fail(target, "a record is declared under a name: `name = ship(...)`", .{}), + }; + const value = binding.values[0]; + const kind: Kind = switch (c.wordOf(value) orelse return c.fail(value, "`{s}` is declared by a constructor, such as `ship(...)`", .{name})) { + .global => .global, + .flight_group => .flight_group, + .ship, .nav_point, .waypoint, .point, .curve_point => .ship, + .squad => .squad, + .formation => .formation, + .curve => .curve, + .part => .part, + .handler => .handler, + else => |other| return c.fail(value, "`{t}(...)` declares no record", .{other}), + }; + if (names.isReserved(name) or code.isCommand(name)) return c.fail(target, "`{s}` is a name mission source keeps for itself", .{name}); + const records = c.declarations.getPtr(kind); + const slot = try c.names.getOrPut(c.arena, name); + if (slot.found_existing) return c.fail(target, "`{s}` is declared already, at {f}", .{ name, c.tree.at(slot.value_ptr.call) }); + slot.value_ptr.* = .{ .kind = kind, .index = @intCast(records.items.len), .call = value }; + try records.append(c.arena, value); + } + + // The values the declarations give. + + fn string(c: *Compiler, node: Index) Error![]const u8 { + return switch (c.tree.get(node)) { + .string => |text| text, + else => c.fail(node, "text in quotes goes here", .{}), + }; + } + + fn boolean(c: *Compiler, node: Index) Error!bool { + return switch (c.tree.get(node)) { + .boolean => |value| value, + else => c.fail(node, "`true` or `false` goes here", .{}), + }; + } + + /// The number `node` writes: a number, one negated, or a quotient such as `1/0`, as the + /// source writes those Luau has no literal for. + fn number(c: *Compiler, node: Index) Error!f64 { + switch (c.tree.get(node)) { + .number => |value| return value, + .unary => |unary| if (unary.operator == .minus) return -(try c.number(unary.operand)), + .binary => |pair| if (pair.operator == .div) return try c.number(pair.left) / try c.number(pair.right), + else => {}, + } + return c.fail(node, "a number goes here", .{}); + } + + /// The whole number `node` writes, which `T` holds. + pub fn whole(c: *Compiler, comptime T: type, node: Index) Error!T { + const value = try c.number(node); + if (@trunc(value) != value or value < std.math.minInt(T) or value > std.math.maxInt(T)) { + return c.fail(node, "a whole number from {d} to {d} goes here", .{ std.math.minInt(T), std.math.maxInt(T) }); + } + return @intFromFloat(value); + } + + /// A whole number of 32 bits, as the script and the records keep one: from -2147483648, kept + /// as the bits it is written in, to 4294967295. + pub fn word(c: *Compiler, node: Index) Error!u32 { + const value = try c.number(node); + if (@trunc(value) != value or value < std.math.minInt(i32) or value > std.math.maxInt(u32)) { + return c.fail(node, "a whole number from -2147483648 to 4294967295 goes here", .{}); + } + return if (value < 0) @bitCast(@as(i32, @intFromFloat(value))) else @intFromFloat(value); + } + + fn float(c: *Compiler, node: Index) Error!f32 { + const value = try c.number(node); + if (std.math.isFinite(value) and @abs(value) > std.math.floatMax(f32)) return c.fail(node, "this number is too large for 32 bits", .{}); + return @floatCast(value); + } + + /// `value`, the number of the `what`, as `T`, where `T` holds it. + pub fn narrow(c: *Compiler, comptime T: type, node: Index, value: u32, what: []const u8) Error!T { + return std.math.cast(T, value) orelse c.fail(node, "{s} {d} doesn't fit here, which holds up to {d}", .{ what, value, std.math.maxInt(T) }); + } + + /// The items of the list `node`: `{ a, b, c }`. + fn listOf(c: *Compiler, node: Index) Error![]const Index { + const items = switch (c.tree.get(node)) { + .table => |items| items, + else => return c.fail(node, "a list goes here: `{{ a, b }}`", .{}), + }; + const values = try c.arena.alloc(Index, items.len); + for (values, items) |*value, item| value.* = switch (c.tree.get(item).item) { + .list => |found| found, + else => return c.fail(item, "a list holds values alone, without names", .{}), + }; + return values; + } + + /// A place, `{ x, y, z }`. + fn vector(c: *Compiler, node: Index) Error![3]f32 { + const items = try c.listOf(node); + if (items.len != 3) return c.fail(node, "a place is three numbers: `{{ x, y, z }}`", .{}); + var place: [3]f32 = undefined; + for (&place, items) |*axis, item| axis.* = try c.float(item); + return place; + } + + /// The fields of the table `node`, `{ name = value }`, each one of `allowed`. + fn fields(c: *Compiler, node: Index, allowed: []const []const u8) Error!Fields { + const items = switch (c.tree.get(node)) { + .table => |items| items, + else => return c.fail(node, "the fields go in a table: `{{ name = value }}`", .{}), + }; + const keys = try c.arena.alloc([]const u8, items.len); + const values = try c.arena.alloc(Index, items.len); + for (items, keys, values, 0..) |item, *key, *value, place| { + const record = switch (c.tree.get(item).item) { + .record => |record| record, + else => return c.fail(item, "a field is written `name = value`", .{}), + }; + if (!contains(allowed, record.key)) return c.fail(item, "`{s}` isn't a field here, which takes {f}", .{ record.key, Listed{ .names = allowed } }); + if (contains(keys[0..place], record.key)) return c.fail(item, "`{s}` is given twice", .{record.key}); + key.* = record.key; + value.* = record.value; + } + return .{ .keys = keys, .values = values }; + } + + /// The index of the record of `kind` that `node` names: by its name, or by its index form for + /// one past the mission's records, such as `ship_index(59031)` (`Kind.indexForm`). + pub fn reference(c: *Compiler, node: Index, kind: Kind) Error!u32 { + switch (c.tree.get(node)) { + .global_name => |name| { + const declared = try c.lookup(node, name); + if (declared.kind != kind) return c.fail(node, "`{s}` is a {s}, not a {s}", .{ name, declared.kind.noun(), kind.noun() }); + return declared.index; + }, + .call => |call| if (c.wordOf(node)) |form| if (form == kind.indexForm()) { + if (call.arguments.len != 1) return c.fail(node, "`{t}(...)` takes the record's index", .{form}); + return c.whole(u32, call.arguments[0]); + }, + else => {}, + } + return c.fail(node, "a {s} goes here", .{kind.noun()}); + } + + /// `reference` as `T`, where `T` holds the index. + pub fn referenceAs(c: *Compiler, comptime T: type, node: Index, kind: Kind) Error!T { + return c.narrow(T, node, try c.reference(node, kind), kind.noun()); + } + + /// The value of `kind` that `node` gives: by the name the game gives it, in quotes, or by its + /// number, for one the game doesn't name, such as a mod's. + pub fn named(c: *Compiler, node: Index, kind: vocabulary.Kind) Error!u32 { + return switch (c.tree.get(node)) { + .string => |text| vocabulary.value(kind, text) orelse c.fail(node, "the game has no {s} named \"{s}\"", .{ kind.noun(), text }), + else => c.word(node), + }; + } + + /// The object ID of the ship, flight group or squad `node` names (`numberObjects`), or of the + /// object `object_index(N)` gives. + pub fn objectOf(c: *Compiler, node: Index) Error!u32 { + if (c.wordOf(node) == .object_index) { + const call = c.tree.get(node).call; + if (call.arguments.len != 1) return c.fail(node, "`object_index(...)` takes the object's ID", .{}); + const id = try c.whole(u16, call.arguments[0]); + c.object_count = @max(c.object_count, @as(u32, id) + 1); + return id; + } + const name = switch (c.tree.get(node)) { + .global_name => |name| name, + else => return c.fail(node, "a ship, a flight group or a squad goes here", .{}), + }; + const declared = try c.lookup(node, name); + const kind = objectKind(declared.kind) orelse return c.fail(node, "`{s}` is a {s}, not a ship, a flight group or a squad", .{ name, declared.kind.noun() }); + return c.object_ids.get(kind)[declared.index]; + } + + // The records. + + fn build(c: *Compiler) Error![]u8 { + var pool: Pool = .{}; + const globals = try c.buildGlobals(&pool); + const formations = try c.buildFormations(&pool); + const curves = try c.buildCurves(); + const groups = try c.buildFlightGroups(&pool); + const ships = try c.buildShips(&pool, formations); + // Each group's count of ships and the first one's place, as binding the mission works them + // out. + _ = try bind.listGroupShips(c.arena, groups, ships); + const squads = try c.buildSquads(&pool); + const parts = try c.buildParts(&pool); + const triggers = try c.buildTriggers(); + const code_bytes = try c.buildScript(parts, triggers); + // After the code, whose `object_index` can name objects past the records. + const objects = try c.buildObjects(ships, try c.slices(triggers)); + + var sections: dte.write.Sections = @splat(.{}); + try c.section(§ions, .strings, pool.bytes.items.len, pool.bytes.items, "names"); + try c.section(§ions, .globals, globals.len, std.mem.sliceAsBytes(globals), "globals"); + try c.section(§ions, .ships, ships.len, std.mem.sliceAsBytes(ships), "ships"); + try c.section(§ions, .flight_groups, groups.len, std.mem.sliceAsBytes(groups), "flight groups"); + try c.section(§ions, .triggers, triggers.records.len, std.mem.sliceAsBytes(triggers.records), "triggers"); + try c.section(§ions, .script, code_bytes.len / 2, code_bytes, "halfwords of script"); + try c.section(§ions, .objects, objects.len, std.mem.sliceAsBytes(objects), "objects"); + try c.section(§ions, .parts, parts.records.len, std.mem.sliceAsBytes(parts.records), "parts"); + const flags = try c.arena.alloc(u8, code_bytes.len); + @memset(flags, 0); + try c.section(§ions, .script_flags, flags.len, flags, "script flags"); + try c.section(§ions, .squads, squads.records.len, std.mem.sliceAsBytes(squads.records), "squads"); + try c.section(§ions, .squad_members, squads.members.len, std.mem.sliceAsBytes(squads.members), "squad members"); + try c.section(§ions, .formations, formations.records.len, std.mem.sliceAsBytes(formations.records), "formations"); + try c.section(§ions, .formation_points, formations.points.len, std.mem.sliceAsBytes(formations.points), "formation points"); + try c.section(§ions, .curves, curves.len, std.mem.sliceAsBytes(curves), "curves"); + const command_flags = &dte.write.template.command_flags; + try c.section(§ions, .command_flags, command_flags.len, std.mem.sliceAsBytes(command_flags), "commands"); + return dte.write.write(c.arena, §ions, .{ .name = try c.missionName() }) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.NameTooLong => c.fail(c.mission.?, "the mission's name is longer than its section counts", .{}), + // Each section's size is checked as it is set. + error.SectionTooLarge => unreachable, + }; + } + + /// Puts `bytes`, `amount` of what `noun` names, in section `which`, where the mission file has + /// room for them. + fn section(c: *Compiler, sections: *dte.write.Sections, which: dte.Section, amount: usize, bytes: []const u8, comptime noun: []const u8) Error!void { + const room = dte.write.template.room(@backingInt(which)); + if (bytes.len > room or amount > std.math.maxInt(u16)) { + return c.fail(c.tree.root, "a mission file has room for {d} " ++ noun ++ ", and this one has {d}", .{ room / (which.stride() orelse 1), amount }); + } + dte.write.set(sections, which, amount, bytes); + } + + fn missionName(c: *Compiler) Error!?[]const u8 { + const call = c.mission orelse return null; + const given = try c.fields((try c.arguments(call, 1, 1, "`mission(...)` takes a table: `{{ name = \"Patrol\" }}`"))[0], &.{"name"}); + return if (given.get("name")) |node| try c.string(node) else null; + } + + fn buildGlobals(c: *Compiler, pool: *Pool) Error![]dte.Global { + const calls = c.declarations.get(.global).items; + const records = try c.arena.alloc(dte.Global, calls.len); + for (records, calls) |*record, call| { + const given = try c.arguments(call, 2, 2, "a global takes its display name and the value it starts with"); + record.* = .blank; + record.name = try pool.add(c, given[0]); + record.value = try c.word(given[1]); + } + return records; + } + + const Formations = struct { + records: []dte.Formation, + points: []dte.FormationPoint, + + /// How many points formation `index` has. + fn pointCount(formations: Formations, index: usize) usize { + const end = if (index + 1 < formations.records.len) formations.records[index + 1].first_point else formations.points.len; + return end - formations.records[index].first_point; + } + }; + + fn buildFormations(c: *Compiler, pool: *Pool) Error!Formations { + const calls = c.declarations.get(.formation).items; + const records = try c.arena.alloc(dte.Formation, calls.len); + var points: std.ArrayList(dte.FormationPoint) = .empty; + for (records, calls, 0..) |*record, call, index| { + const given = try c.arguments(call, 2, 2, "a formation takes its display name and its points: `{{ {{ x, y, z }} }}`"); + record.* = .blank; + record.name = try pool.add(c, given[0]); + record.first_point = try c.narrow(u16, call, @intCast(points.items.len), "formation point"); + for (try c.listOf(given[1])) |item| { + var point: dte.FormationPoint = .blank; + point.formation = try c.narrow(u16, call, @intCast(index), "formation"); + point.offset = try c.vector(item); + try points.append(c.arena, point); + } + } + return .{ .records = records, .points = points.items }; + } + + fn buildCurves(c: *Compiler) Error![]dte.Curve { + const calls = c.declarations.get(.curve).items; + const records = try c.arena.alloc(dte.Curve, calls.len); + for (records, calls) |*record, call| { + const given = try c.fields((try c.arguments(call, 1, 1, "a curve takes a table of its fields"))[0], &curve_fields); + record.* = .blank; + inline for (.{ .{ "start", "start" }, .{ "finish", "end" }, .{ "leaving_handle", "leaving_handle" }, .{ "arriving_handle", "arriving_handle" } }) |pair| { + if (given.get(pair[0])) |node| @field(record, pair[1]) = .of(.ship, try c.referenceAs(u16, node, .ship)); + } + inline for (.{ "from", "to", "leaving", "arriving" }) |name| { + if (given.get(name)) |node| @field(record, name) = try c.vector(node); + } + } + return records; + } + + fn buildFlightGroups(c: *Compiler, pool: *Pool) Error![]dte.FlightGroup { + const calls = c.declarations.get(.flight_group).items; + const records = try c.arena.alloc(dte.FlightGroup, calls.len); + for (records, calls, 0..) |*record, call, index| { + const given = try c.arguments(call, 1, 2, "a flight group takes its display name and a table of its fields"); + record.* = .blank; + record.object_id = try c.narrow(u16, call, c.object_ids.get(.flight_group)[index], "object"); + record.name = try pool.add(c, given[0]); + const details: Fields = if (given.len > 1) try c.fields(given[1], &.{ "wing", "formation" }) else .{}; + if (details.get("wing")) |node| record.wing = switch (c.tree.get(node)) { + .string => |text| std.meta.stringToEnum(dte.FlightGroup.Wing, text) orelse return c.fail(node, "a wing is \"player\", \"second\", \"third\" or \"none\"", .{}), + else => @fromBackingInt(try c.whole(u8, node)), + }; + if (details.get("formation")) |node| record.formation = .of(.formation, try c.referenceAs(u16, node, .formation)); + } + return records; + } + + fn buildShips(c: *Compiler, pool: *Pool, formations: Formations) Error![]dte.Ship { + const calls = c.declarations.get(.ship).items; + const records = try c.arena.alloc(dte.Ship, calls.len); + for (records, calls, 0..) |*record, call, index| { + const constructor = c.wordOf(call).?; + const given = try c.arguments(call, 1, 2, "a ship or a marker takes its display name and a table of its fields"); + record.* = .blank; + record.object_id = c.object_ids.get(.ship)[index]; + record.name = try pool.add(c, given[0]); + const allowed: []const []const u8 = switch (constructor) { + .ship => &ship_fields, + .point => &point_fields, + else => &marker_fields, + }; + const details: Fields = if (given.len > 1) try c.fields(given[1], allowed) else .{}; + record.kind = switch (constructor) { + .nav_point => dte.Ship.nav_point_kind, + .waypoint => dte.Ship.waypoint_kind, + .point => dte.Ship.point_kind, + .curve_point => dte.Ship.curve_point_kind, + else => kind: { + const node = details.get("type") orelse return c.fail(call, "a ship needs its `type`", .{}); + break :kind try c.narrow(u16, node, try c.named(node, .ship_type), "ship type"); + }, + }; + if (details.get("group")) |node| { + const group = try c.reference(node, .flight_group); + if (group >= dte.Ship.no_flight_group) return c.fail(node, "a ship's flight group is one of the first 255", .{}); + record.flight_group = @intCast(group); + } + if (details.get("pilot")) |node| { + const pilot = try c.named(node, .pilot); + if (pilot >= dte.Ship.no_pilot) return c.fail(node, "a pilot is one of the first 255 records of pilotstats.bin", .{}); + record.pilot = @intCast(pilot); + } + if (details.get("position")) |node| { + record.position = try c.vector(node); + record.runtime_position = record.position; + } + inline for (.{ .{ "yaw", "runtime_yaw" }, .{ "pitch", "runtime_pitch" }, .{ "roll", "runtime_roll" } }) |pair| { + if (details.get(pair[0])) |node| { + @field(record, pair[0]) = try c.whole(i16, node); + @field(record, pair[1]) = @field(record, pair[0]); + } + } + if (details.get("launch")) |node| { + const launch = try c.fields(node, &.{ "from", "gate" }); + const from = launch.get("from") orelse return c.fail(node, "a launch gives the type of ship it is `from`", .{}); + const gate = launch.get("gate") orelse return c.fail(node, "a launch gives the `gate` the ship launches through", .{}); + record.launch_from = try c.narrow(u16, from, try c.named(from, .ship_type), "ship type"); + record.launch_gate = try c.whole(u8, gate); + if (record.launch_gate == dte.Ship.no_launch) return c.fail(gate, "a gate is from 0 to 254", .{}); + } + if (details.get("formation_point")) |node| { + record.formation_point = try c.formationPoint(node, formations); + record.formation_point_tag = .formation_point; + } + if (details.get("tier")) |node| record.tier = try c.whole(u8, node); + if (details.get("marker")) |node| { + const marker = try c.fields(node, &.{ "curve", "at" }); + const curve = marker.get("curve") orelse return c.fail(node, "a marker gives the `curve` it marks a place on", .{}); + record.marker_curve = try c.referenceAs(i16, curve, .curve); + if (marker.get("at")) |at| record.marker_at = try c.float(at); + } + } + return records; + } + + /// A ship's place in a formation: `sabres[2]`, the formation's second point, or + /// `formation_point_index(N)`. + fn formationPoint(c: *Compiler, node: Index, formations: Formations) Error!u16 { + switch (c.tree.get(node)) { + .index => |indexed| { + const formation = try c.reference(indexed.object, .formation); + if (formation >= formations.records.len) return c.fail(node, "the points of a formation past the mission's aren't known", .{}); + const place = try c.whole(u32, indexed.key); + const points = formations.pointCount(formation); + if (place == 0 or place > points) return c.fail(indexed.key, "the formation has {d} points, numbered from 1", .{points}); + return try c.narrow(u16, node, formations.records[formation].first_point + place - 1, "formation point"); + }, + .call => |call| if (c.wordOf(node) == .formation_point_index) { + if (call.arguments.len != 1) return c.fail(node, "`formation_point_index(...)` takes the point's index", .{}); + const point = try c.whole(u16, call.arguments[0]); + if (point == dte.Ship.no_formation_point) return c.fail(node, "a formation point's index is from 0 to 65534", .{}); + return point; + }, + else => {}, + } + return c.fail(node, "a formation's point goes here, such as `sabres[2]`", .{}); + } + + const Squads = struct { + records: []dte.Squad, + members: []dte.SquadMember, + }; + + fn buildSquads(c: *Compiler, pool: *Pool) Error!Squads { + const calls = c.declarations.get(.squad).items; + const records = try c.arena.alloc(dte.Squad, calls.len); + var members: std.ArrayList(dte.SquadMember) = .empty; + for (records, calls, 0..) |*record, call, index| { + const given = try c.arguments(call, 2, 2, "a squad takes its display name and its members: `{{ a, b }}`"); + record.* = .blank; + record.object_id = try c.narrow(u16, call, c.object_ids.get(.squad)[index], "object"); + record.name = try pool.add(c, given[0]); + const items = try c.listOf(given[1]); + if (items.len > 0) { + record.first_member = try c.narrow(u16, call, @intCast(members.items.len), "squad member"); + record.first_member_tag = .squad_member; + } + for (items) |item| { + var member: dte.SquadMember = .blank; + member.squad = @intCast(index); + var object = item; + if (c.wordOf(item) == .component) { + const parts = c.tree.get(item).call.arguments; + if (parts.len != 2) return c.fail(item, "`component(...)` takes the object and the component's index", .{}); + object = parts[0]; + member.component = try c.whole(u8, parts[1]); + if (member.component == dte.Trigger.whole_object) return c.fail(parts[1], "a component's index is from 0 to 254", .{}); + } + member.object_id = try c.narrow(u16, item, try c.objectOf(object), "object"); + try members.append(c.arena, member); + } + } + return .{ .records = records, .members = members.items }; + } + + const Parts = struct { + records: []dte.Part, + /// Each part's function, where it has one. + functions: []?Index, + }; + + fn buildParts(c: *Compiler, pool: *Pool) Error!Parts { + const calls = c.declarations.get(.part).items; + const records = try c.arena.alloc(dte.Part, calls.len); + const functions = try c.arena.alloc(?Index, calls.len); + for (records, functions, calls) |*record, *function, call| { + const given = try c.arguments(call, 1, 3, "a part takes its display name, its code and a table of its fields"); + record.* = .blank; + record.name = try pool.add(c, given[0]); + function.* = null; + if (given.len > 1) switch (c.tree.get(given[1])) { + .function => |found| { + function.* = given[1]; + record.arguments = @intCast(found.parameters.len); + }, + .nil => {}, + else => return c.fail(given[1], "a part's code is a function, or `nil` for none", .{}), + }; + if (given.len > 2) { + const details = try c.fields(given[2], &.{"start"}); + if (details.get("start")) |node| record.flags.start = try c.boolean(node); + } + } + return .{ .records = records, .functions = functions }; + } + + const Triggers = struct { + records: []dte.Trigger, + /// The object ID of each trigger's subject, where it has one. + subjects: []?u32, + /// The function each trigger runs, its own or a handler's, where it runs one. + functions: []?Index, + }; + + fn buildTriggers(c: *Compiler) Error!Triggers { + const calls = c.triggers.items; + const built: Triggers = .{ + .records = try c.arena.alloc(dte.Trigger, calls.len), + .subjects = try c.arena.alloc(?u32, calls.len), + .functions = try c.arena.alloc(?Index, calls.len), + }; + for (built.records, built.subjects, built.functions, calls) |*record, *subject, *function, call| { + const given = try c.arguments(call, 2, 4, "`on(...)` takes the subject, the condition, a table of its fields and its code"); + record.* = .blank; + subject.* = if (c.tree.get(given[0]) == .nil) null else try c.objectOf(given[0]); + record.condition = @fromBackingInt(try c.narrow(u8, given[1], try c.named(given[1], .condition), "condition")); + var rest = given[2..]; + if (rest.len > 0 and c.tree.get(rest[0]) == .table) { + try c.triggerFields(record, rest[0]); + rest = rest[1..]; + } + function.* = null; + if (rest.len > 1) return c.fail(rest[1], "a trigger runs one function", .{}); + if (rest.len == 1) function.* = switch (c.tree.get(rest[0])) { + .function => rest[0], + .global_name => |name| handler: { + const declared = try c.lookup(rest[0], name); + if (declared.kind != .handler) return c.fail(rest[0], "`{s}` is a {s}, not a handler", .{ name, declared.kind.noun() }); + const handler = (try c.arguments(declared.call, 1, 1, "a handler takes its function"))[0]; + if (c.tree.get(handler) != .function) return c.fail(handler, "a handler's code is a function", .{}); + break :handler handler; + }, + .nil => null, + else => return c.fail(rest[0], "a trigger's code is a function or a handler", .{}), + }; + } + return built; + } + + /// A trigger's fields: how it repeats, the component it watches, whether its thread waits for + /// the scheduler, and what the values its condition checks must be. + fn triggerFields(c: *Compiler, record: *dte.Trigger, node: Index) Error!void { + const values = if (record.condition.descriptor()) |condition| condition.values else &.{}; + const event = try script.eventNames(c.arena, values); + const allowed = try std.mem.concat(c.arena, []const u8, &.{ &.{ "repeats", "component", "deferred" }, event }); + const details = try c.fields(node, allowed); + if (details.get("repeats")) |repeats| switch (c.tree.get(repeats)) { + .string => |text| record.repeat = if (std.mem.eql(u8, text, "once")) .once else if (std.mem.eql(u8, text, "always")) .always else { + return c.fail(repeats, "a trigger repeats \"once\", \"always\", or `{{ counted = 3 }}`", .{}); + }, + .table => { + const counted = try c.fields(repeats, &.{ "counted", "left" }); + record.repeat = .counted; + record.repeat_count = try c.whole(u8, counted.get("counted") orelse return c.fail(repeats, "a counted trigger gives how many times it fires: `{{ counted = 3 }}`", .{})); + record.repeat_counter = if (counted.get("left")) |left| try c.whole(u8, left) else record.repeat_count; + }, + else => record.repeat = @fromBackingInt(try c.whole(u8, repeats)), + }; + if (details.get("component")) |component| { + record.qualifier = try c.whole(u8, component); + if (record.qualifier == dte.Trigger.whole_object) return c.fail(component, "a component's index is from 0 to 254", .{}); + } + if (details.get("deferred")) |deferred| record.deferred = @intFromBool(try c.boolean(deferred)); + for (event, 0..) |name, slot| { + const filter = details.get(name) orelse continue; + if (!values[slot].checked or slot >= record.operands.len) return c.fail(filter, "the event's `{s}` isn't checked, so it takes no value here", .{name}); + record.operands[slot] = dte.Operand.write(try c.operand(filter, values[slot].kinds)); + } + } + + /// What a value of an event must be, for a trigger to fire: a number, a ship, a flight group + /// or a squad, `any_ship`, or the operand's bits as a number. + fn operand(c: *Compiler, node: Index, kinds: commands.Kinds) Error!dte.Operand { + if (kinds.number) return c.checked(node, .{ .number = try c.word(node) }, kinds); + const usage = "a ship, a flight group, a squad or `any_ship` goes here"; + const kind: Kind = switch (c.tree.get(node)) { + .global_name => |name| kind: { + if (std.mem.eql(u8, name, @tagName(Word.any_ship))) return c.checked(node, .any_ship, kinds); + break :kind (try c.lookup(node, name)).kind; + }, + .call => switch (c.wordOf(node) orelse return c.fail(node, usage, .{})) { + .ship_index => .ship, + .flight_group_index => .flight_group, + .squad_index => .squad, + else => return c.fail(node, usage, .{}), + }, + // The operand's bits, where the matcher can't read it as a record. + else => return c.checked(node, .{ .other = try c.word(node) }, kinds), + }; + return c.checked(node, switch (kind) { + .ship => .{ .ship = try c.referenceAs(u16, node, .ship) }, + .flight_group => .{ .flight_group = try c.referenceAs(u16, node, .flight_group) }, + .squad => .{ .squad = try c.referenceAs(u16, node, .squad) }, + else => return c.fail(node, usage, .{}), + }, kinds); + } + + /// `found`, where it reads back as itself: a value written otherwise can't be. + fn checked(c: *Compiler, node: Index, found: dte.Operand, kinds: commands.Kinds) Error!dte.Operand { + if (!std.meta.eql(dte.Operand.read(dte.Operand.write(found), kinds), found)) return c.fail(node, "this value reads back as another, or as none", .{}); + return found; + } + + /// Each object's slice of the trigger list: its triggers, which follow one another. + fn slices(c: *Compiler, triggers: Triggers) Error![]?Slice { + const found = try c.arena.alloc(?Slice, c.object_count); + @memset(found, null); + for (triggers.subjects, c.triggers.items, 0..) |subject, call, index| { + const object = subject orelse continue; + const slot = &found[object]; + if (slot.*) |*slice| { + if (@as(usize, slice.first) + slice.count != index) return c.fail(call, "an object's triggers follow one another", .{}); + if (slice.count == std.math.maxInt(u8)) return c.fail(call, "an object has 255 triggers at most", .{}); + slice.count += 1; + } else { + slot.* = .{ .first = try c.narrow(u16, call, @intCast(index), "trigger"), .count = 1 }; + } + } + return found; + } + + const Slice = struct { first: u16, count: u8 }; + + /// The script: the blocks the triggers run, in the order the triggers first run them, then the + /// parts' in order. Sets where each trigger's and each part's code starts. + fn buildScript(c: *Compiler, parts: Parts, triggers: Triggers) Error![]u8 { + var bytes: std.ArrayList(u8) = .empty; + // The blocks the triggers run, by their functions, with the trigger whose condition gives + // the event's values: the first that can fire, as `statements.Context.of` takes it. + var blocks: std.AutoArrayHashMapUnmanaged(Index, usize) = .empty; + for (triggers.functions, 0..) |function, index| { + const node = function orelse continue; + const slot = try blocks.getOrPut(c.arena, node); + if (!slot.found_existing or (triggers.subjects[slot.value_ptr.*] == null and triggers.subjects[index] != null)) slot.value_ptr.* = index; + } + const starts = try c.arena.alloc(u16, blocks.count()); + for (blocks.keys(), blocks.values(), starts) |node, first, *start| { + const condition = triggers.records[first].condition.descriptor(); + const context: statements.Context = .{ .event = if (condition) |found| found.values else &.{} }; + start.* = try c.routine(&bytes, node, .event, context); + } + for (triggers.records, triggers.functions) |*record, function| { + if (function) |node| record.link = starts[blocks.getIndex(node).?]; + } + for (parts.records, parts.functions) |*record, function| { + const node = function orelse continue; + record.offset = try c.routine(&bytes, node, .arguments, .{}); + record.length = @intCast(bytes.items.len / 2 - record.offset); + } + return bytes.items; + } + + /// Writes the routine of the function `node` after `bytes`, and gives where it starts, in + /// halfwords. + fn routine(c: *Compiler, bytes: *std.ArrayList(u8), node: Index, parameters: code.Parameters, context: statements.Context) Error!u16 { + const body = try code.function(c, node, parameters); + const made = statements.bytes(c.arena, body, context) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.BranchTooFar => c.fail(node, "an `if` or a random choice here holds more code than a branch reaches", .{}), + error.InlineTooLong => c.fail(node, "a text here is longer than 254 bytes", .{}), + error.TooManyConstants => c.fail(node, "this code pushes more numbers than a routine holds", .{}), + error.BlockTooLong => c.fail(node, "this code is longer than a routine holds", .{}), + error.WrongOperands => c.fail(node, "a random choice here has more than 255 arms", .{}), + error.BackwardBranch, error.UnplacedLabel => unreachable, + }; + const start = bytes.items.len / 2; + if (start + made.len / 2 >= dte.Part.no_block) return c.fail(node, "the script is longer than a mission's 65534 halfwords", .{}); + try bytes.appendSlice(c.arena, made); + return @intCast(start); + } + + /// The object table: each ship's, flight group's and squad's entry, by its object ID, with its + /// slice of the triggers, and an entry of no record for each that `object_index` names past + /// them. Ship 0's entry and the curve points' are marked as the original's tools mark them. + fn buildObjects(c: *Compiler, ships: []const dte.Ship, triggers: []const ?Slice) Error![]dte.Object { + const objects = try c.arena.alloc(dte.Object, c.object_count); + for (objects, triggers) |*object, slice| object.* = .{ + .kind = .ship, + .count = if (slice) |found| found.count else 0, + .first = if (slice) |found| found.first else dte.Object.no_triggers, + .tool_flags = .{}, + }; + inline for (comptime std.enums.values(ObjectKind)) |kind| { + for (c.object_ids.get(kind)) |id| objects[id].kind = @field(dte.Object.Kind, @tagName(kind)); + } + for (ships, 0..) |ship, index| { + const flags = &objects[ship.object_id].tool_flags; + flags.player = index == 0; + flags.curve_point = ship.kind == dte.Ship.curve_point_kind; + } + return objects; + } +}; + +/// A table's fields, by their names. +const Fields = struct { + keys: []const []const u8 = &.{}, + values: []const Index = &.{}, + + fn get(given: Fields, key: []const u8) ?Index { + for (given.keys, given.values) |name, value| if (std.mem.eql(u8, name, key)) return value; + return null; + } +}; + +/// Names as a message lists them: `a`, `b` and `c`. +const Listed = struct { + names: []const []const u8, + + pub fn format(listed: Listed, writer: *std.Io.Writer) std.Io.Writer.Error!void { + for (listed.names, 0..) |name, index| { + const separator = if (index == 0) "" else if (index + 1 == listed.names.len) " and " else ", "; + try writer.print("{s}`{s}`", .{ separator, name }); + } + } +}; + +fn contains(list: []const []const u8, name: []const u8) bool { + for (list) |item| if (std.mem.eql(u8, item, name)) return true; + return false; +} + +/// The string pool: each name once, with a NUL after it, by the byte it starts at. +const Pool = struct { + bytes: std.ArrayList(u8) = .empty, + offsets: std.StringHashMapUnmanaged(u16) = .empty, + + fn add(pool: *Pool, c: *Compiler, node: Index) Error!u16 { + const text = try c.string(node); + if (std.mem.findScalar(u8, text, 0) != null) return c.fail(node, "a name can't hold a NUL", .{}); + if (pool.offsets.get(text)) |offset| return offset; + const offset = std.math.cast(u16, pool.bytes.items.len) orelse return c.fail(node, "the names fill the string pool, which counts 65535 bytes", .{}); + try pool.bytes.appendSlice(c.arena, text); + try pool.bytes.append(c.arena, 0); + try pool.offsets.put(c.arena, text, offset); + return offset; + } +}; diff --git a/src/formats/dte/source/compile/code.zig b/src/formats/dte/source/compile/code.zig new file mode 100644 index 00000000..20c63910 --- /dev/null +++ b/src/formats/dte/source/compile/code.zig @@ -0,0 +1,377 @@ +//! The code of parts and triggers read from mission source into statements (`statements`), the +//! inverse of `script.write` ([The script](../../../../../docs/formats/mission-source.md#the-script)). + +const std = @import("std"); + +const compile = @import("../compile.zig"); +const Compiler = compile.Compiler; +const Error = compile.Error; +const statements = @import("../../statements.zig"); +const Statement = statements.Statement; +const Expression = statements.Expression; +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 syntax = @import("../syntax.zig"); +const Index = syntax.Index; +const vocabulary = @import("../vocabulary.zig"); + +/// What a function's parameters stand for, by their place. +pub const Parameters = enum { + /// The values of a trigger's event, which its thread starts with. + event, + /// A part's arguments. + arguments, +}; + +/// The statements of the function `node`, whose parameters are `parameters`. +pub fn function(c: *Compiler, node: Index, parameters: Parameters) Error![]const Statement { + const found = c.tree.get(node).function; + const reader: Reader = .{ .c = c, .parameters = found.parameters, .kind = parameters }; + return reader.body(found.body); +} + +/// Whether `name` is a command's, which no record can take. +pub fn isCommand(name: []const u8) bool { + return command_numbers.has(name); +} + +/// The commands of the catalogue, by their names. +const command_numbers: std.StaticStringMap(u8) = .initComptime(pairs: { + @setEvalBranchQuota(100_000); + var list: [commands.table.len]struct { []const u8, u8 } = undefined; + for (&list, commands.table, 0..) |*pair, command, index| pair.* = .{ command.name, index }; + break :pairs list; +}); + +const Reader = struct { + c: *Compiler, + /// The function's parameters, their `name` nodes. + parameters: []const Index, + kind: Parameters, + + fn fail(r: Reader, node: Index, comptime message: []const u8, args: anytype) Error { + return r.c.fail(node, message, args); + } + + fn get(r: Reader, node: Index) syntax.Syntax { + return r.c.tree.get(node); + } + + fn body(r: Reader, block: Index) Error![]const Statement { + const lines = r.get(block).block; + var list: std.ArrayList(Statement) = .empty; + var at: usize = 0; + while (at < lines.len) : (at += 1) { + const line = lines[at]; + if (r.get(line) == .local) { + // A random choice: its roll, then the `if` over its arms. + const next = if (at + 1 < lines.len) lines[at + 1] else return r.fail(line, "a random choice's roll is followed by the `if` over its arms", .{}); + try list.append(r.c.arena, .{ .random = try r.random(line, next) }); + at += 1; + continue; + } + try list.append(r.c.arena, try r.statement(line)); + } + return list.items; + } + + fn statement(r: Reader, line: Index) Error!Statement { + switch (r.get(line)) { + .call_statement => |call| return r.callStatement(call), + .assign => |binding| { + if (binding.targets.len != 1 or binding.values.len != 1) return r.fail(line, "an assignment sets one target to one value", .{}); + return .{ .assign = .{ .target = try r.target(binding.targets[0]), .update = .assign, .value = try r.expression(binding.values[0]) } }; + }, + .compound_assign => |assign| { + const update: statements.Update = switch (assign.operator) { + .add => .add_assign, + .sub => .sub_assign, + .mul => .mul_assign, + .div => .div_assign, + else => return r.fail(line, "the script has no `{s}=`", .{assign.operator.symbol()}), + }; + return .{ .assign = .{ .target = try r.target(assign.target), .update = update, .value = try r.expression(assign.value) } }; + }, + .@"if" => |branch| return .{ .@"if" = try r.ifStatement(branch) }, + .@"return" => |values| { + if (values.len != 1) return r.fail(line, "`return` gives one value", .{}); + return .{ .@"return" = try r.expression(values[0]) }; + }, + .unsupported => |what| return r.fail(line, "{s}, which mission source doesn't use", .{what}), + else => return r.fail(line, "this isn't a statement of the script", .{}), + } + } + + fn ifStatement(r: Reader, branch: syntax.Syntax.If) Error!statements.If { + const otherwise: ?[]const Statement = if (branch.@"else") |node| switch (r.get(node)) { + // `elseif`: an `else` that holds one `if`. + .@"if" => try r.c.arena.dupe(Statement, &.{try r.statement(node)}), + else => try r.body(node), + } else null; + return .{ .condition = try r.expression(branch.condition), .then = try r.body(branch.then), .otherwise = otherwise }; + } + + /// `local roll = random()` at `line`, then at `next`, an `if` that compares the roll with each + /// arm's threshold, `roll < 50`, and runs the default in its `else`. + fn random(r: Reader, line: Index, next: Index) Error!statements.Random { + const binding = r.get(line).local; + const usage = "a local holds a random choice's roll: `local roll = random()`"; + if (binding.targets.len != 1 or binding.values.len != 1) return r.fail(line, usage, .{}); + const value = binding.values[0]; + if (r.c.wordOf(value) != .random or r.get(value).call.arguments.len != 0) return r.fail(line, usage, .{}); + const roll = r.get(binding.targets[0]).name; + var arms: std.ArrayList(statements.Random.Arm) = .empty; + var branch_node = next; + while (true) { + const branch = switch (r.get(branch_node)) { + .@"if" => |found| found, + else => return r.fail(branch_node, "a random choice's roll is followed by the `if` over its arms", .{}), + }; + try arms.append(r.c.arena, .{ .threshold = try r.threshold(branch.condition, roll), .extra = 0, .body = try r.body(branch.then) }); + const otherwise = branch.@"else" orelse return .{ .arms = arms.items, .default = &.{} }; + if (r.get(otherwise) != .@"if") return .{ .arms = arms.items, .default = try r.body(otherwise) }; + branch_node = otherwise; + } + } + + /// The threshold of an arm of a random choice, from its condition: `roll < 50`. + fn threshold(r: Reader, node: Index, roll: syntax.Syntax.Local) Error!u8 { + const usage = "an arm of a random choice compares the roll with its threshold, such as `{s} < 50`"; + const comparison = switch (r.get(node)) { + .binary => |pair| pair, + else => return r.fail(node, usage, .{roll.name}), + }; + if (comparison.operator != .less) return r.fail(node, usage, .{roll.name}); + switch (r.get(comparison.left)) { + .local_name => |local| if (local.number != roll.number) return r.fail(node, usage, .{roll.name}), + else => return r.fail(node, usage, .{roll.name}), + } + return r.c.whole(u8, comparison.right); + } + + fn callStatement(r: Reader, node: Index) Error!Statement { + const call = r.get(node).call; + switch (r.get(call.function)) { + .global_name => |name| { + if (command_numbers.get(name)) |index| return .{ .command = .{ .index = index, .arguments = try r.commandArguments(index, call.arguments) } }; + if (std.meta.stringToEnum(Word, name)) |word| switch (word) { + .command => { + if (call.arguments.len == 0) return r.fail(node, "`command(...)` takes the command's number, then its arguments", .{}); + const index = try r.c.whole(u8, call.arguments[0]); + return .{ .command = .{ .index = index, .arguments = try r.commandArguments(index, call.arguments[1..]) } }; + }, + .spawn => { + if (call.arguments.len == 0) return r.fail(node, "`spawn(...)` takes the part, then its arguments", .{}); + return .{ .spawn = .{ .part = try r.part(call.arguments[0]), .arguments = try r.expressions(call.arguments[1..]) } }; + }, + .add_assign_f, .sub_assign_f, .mul_assign_f, .div_assign_f => { + if (call.arguments.len != 2) return r.fail(node, "`{t}(...)` takes its target and its value", .{word}); + return .{ .assign = .{ + .target = try r.target(call.arguments[0]), + .update = std.meta.stringToEnum(statements.Update, @tagName(word)).?, + .value = try r.expression(call.arguments[1]), + } }; + }, + else => return r.fail(node, "`{t}(...)` isn't a statement", .{word}), + }; + const declared = try r.c.lookup(call.function, name); + if (declared.kind != .part) return r.fail(call.function, "`{s}` is a {s}, which can't be called", .{ name, declared.kind.noun() }); + }, + .call => {}, + else => return r.fail(call.function, "a command or a part is called here", .{}), + } + return .{ .call = .{ .part = try r.part(call.function), .arguments = try r.expressions(call.arguments) } }; + } + + /// The arguments of command `index`: a name in quotes where the parameter takes a named value, + /// such as an order, and a part by its name. + fn commandArguments(r: Reader, index: u8, nodes: []const Index) Error![]const Expression { + const params = if (commands.find(index)) |command| command.params else &.{}; + const list = try r.c.arena.alloc(Expression, nodes.len); + for (list, nodes, 0..) |*argument, node, place| { + const kind = if (place < params.len) vocabulary.of(params[place]) else null; + argument.* = if (kind) |value_kind| switch (value_kind) { + .part => .{ .number = try r.part(node) }, + else => if (r.get(node) == .string) .{ .number = try r.c.named(node, value_kind) } else try r.expression(node), + } else try r.expression(node); + } + return list; + } + + fn expressions(r: Reader, nodes: []const Index) Error![]const Expression { + const list = try r.c.arena.alloc(Expression, nodes.len); + for (list, nodes) |*item, node| item.* = try r.expression(node); + return list; + } + + /// A part, by its name, or by `part_index(N)` for one past the mission's parts. + fn part(r: Reader, node: Index) Error!u8 { + return r.c.referenceAs(u8, node, .part); + } + + /// What an assignment stores into: a global, a part's argument, or one of the game's + /// variables. + fn target(r: Reader, node: Index) Error!statements.Target { + switch (r.get(node)) { + .global_name => |name| { + if (names.argument(name)) |number| return .{ .argument = number }; + const declared = try r.c.lookup(node, name); + if (declared.kind != .global) return r.fail(node, "`{s}` is a {s}, which can't be assigned", .{ name, declared.kind.noun() }); + return .{ .global = try r.c.narrow(u8, node, declared.index, "global") }; + }, + .local_name => |local| { + const place = try r.parameter(node, local); + if (r.kind == .event) return r.fail(node, "`{s}` is a value of the event, which can't be assigned", .{local.name}); + return .{ .argument = place }; + }, + .field, .index => return .{ .variable = try r.variable(node) }, + else => return r.fail(node, "a value is assigned to a global, a part's argument or one of the game's variables", .{}), + } + } + + /// The place among the function's parameters of the one that `local` names. + fn parameter(r: Reader, node: Index, local: syntax.Syntax.Local) Error!u8 { + for (r.parameters, 0..) |parameter_node, place| { + if (r.get(parameter_node).name.number == local.number) return @intCast(place); + } + return r.fail(node, "`{s}` is a random choice's roll, which only the `if` over its arms compares", .{local.name}); + } + + /// One of the game's variables: `game.mission_over`, or `game[37]` for one without a name. + fn variable(r: Reader, node: Index) Error!u8 { + const object = switch (r.get(node)) { + inline .field, .index => |found| found.object, + else => unreachable, + }; + if (r.get(object) != .global_name or !std.mem.eql(u8, r.get(object).global_name, @tagName(Word.game))) + return r.fail(node, "only the game's variables are read this way: `game.mission_over` or `game[37]`", .{}); + switch (r.get(node)) { + .field => |field| return vocabulary.variableNumber(field.name) orelse return r.fail(node, "the game has no variable `{s}`", .{field.name}), + .index => |indexed| return r.c.whole(u8, indexed.key), + else => unreachable, + } + } + + fn expression(r: Reader, node: Index) Error!Expression { + switch (r.get(node)) { + .number => return .{ .number = try r.c.word(node) }, + .unary => |unary| { + if (unary.operator != .minus) return r.fail(node, "the script has no `{s}`", .{unary.operator.symbol()}); + return .{ .number = try r.c.word(node) }; + }, + .string => |text| return .{ .text = text }, + .nil => return .null, + .global_name => |name| return r.named(node, name), + .local_name => |local| { + const place = try r.parameter(node, local); + return switch (r.kind) { + .event => .{ .local = place }, + .arguments => .{ .argument = place }, + }; + }, + .field, .index => return .{ .variable = try r.variable(node) }, + .call => return r.callValue(node), + .binary => |pair| { + const operator: statements.Operator = switch (pair.operator) { + .add => .add, + .sub => .sub, + .mul => .mul, + .div => .div, + .equal => .equal, + .not_equal => .not_equal, + .less => .less, + .less_equal => .less_equal, + .greater => .greater, + .greater_equal => .greater_equal, + .@"and" => .logical_and, + .@"or" => .logical_or, + .floor_div, .mod, .pow, .concat => return r.fail(node, "the script has no `{s}`", .{pair.operator.symbol()}), + }; + return r.binary(operator, pair.left, pair.right); + }, + .boolean => return r.fail(node, "the script has no `true` or `false`: it compares numbers, as in `== 1`", .{}), + .unsupported => |what| return r.fail(node, "{s}, which mission source doesn't use", .{what}), + else => return r.fail(node, "this isn't a value of the script", .{}), + } + } + + fn binary(r: Reader, operator: statements.Operator, left: Index, right: Index) Error!Expression { + const made = try r.c.arena.create(statements.Binary); + made.* = .{ .operator = operator, .left = try r.expression(left), .right = try r.expression(right) }; + return .{ .binary = made }; + } + + /// The record a name declares, as the script pushes it, or a part's argument such as `arg0`. + fn named(r: Reader, node: Index, name: []const u8) Error!Expression { + if (names.argument(name)) |number| return .{ .argument = number }; + const declared = try r.c.lookup(node, name); + const index = declared.index; + const noun = declared.kind.noun(); + return switch (declared.kind) { + .ship => .{ .ship = try r.c.narrow(u16, node, index, noun) }, + .flight_group => .{ .flight_group = try r.c.narrow(u8, node, index, noun) }, + .squad => .{ .squad = try r.c.narrow(u8, node, index, noun) }, + .curve => .{ .curve = try r.c.narrow(u8, node, index, noun) }, + .global => .{ .global = try r.c.narrow(u8, node, index, noun) }, + .part => r.fail(node, "`{s}` is a part, which runs as `{s}()`", .{ name, name }), + .formation, .handler => r.fail(node, "`{s}` is a {s}, which the script doesn't name", .{ name, declared.kind.noun() }), + }; + } + + /// A value the script gets by a call: a ship's component, a value of the thread or of an + /// object's last event, the last command's result, a record by its index, or an operator + /// written as a call. + fn callValue(r: Reader, node: Index) Error!Expression { + const word = r.c.wordOf(node) orelse return r.fail(node, "this call isn't a value of the script", .{}); + const arguments = r.get(node).call.arguments; + switch (word) { + .result => { + if (arguments.len != 0) return r.fail(node, "`result()` takes nothing", .{}); + return .result; + }, + .component => { + if (arguments.len != 2) return r.fail(node, "`component(...)` takes the ship and the component's index", .{}); + return .{ .component = .{ .ship = try r.c.referenceAs(u8, arguments[0], .ship), .component = try r.c.whole(u8, arguments[1]) } }; + }, + .thread_value => return .{ .local = try r.c.whole(u8, try r.only(node, arguments, "the value's place")) }, + .last_event => return .{ .event_value = try r.lastEvent(node, arguments) }, + .ship_index => return .{ .ship = try r.c.whole(u16, try r.only(node, arguments, "the ship's index")) }, + .flight_group_index => return .{ .flight_group = try r.c.whole(u8, try r.only(node, arguments, "the flight group's index")) }, + .squad_index => return .{ .squad = try r.c.whole(u8, try r.only(node, arguments, "the squad's index")) }, + .curve_index => return .{ .curve = try r.c.whole(u8, try r.only(node, arguments, "the curve's index")) }, + .global_index => return .{ .global = try r.c.whole(u8, try r.only(node, arguments, "the global's index")) }, + .greater_f, .greater_equal_f, .less_f, .less_equal_f, .add_f, .sub_f, .mul_f, .div_f, .in_flight_group, .not_in_flight_group, .in_squad, .not_in_squad => { + if (arguments.len != 2) return r.fail(node, "`{t}(...)` takes two values", .{word}); + return r.binary(std.meta.stringToEnum(statements.Operator, @tagName(word)).?, arguments[0], arguments[1]); + }, + else => return r.fail(node, "`{t}(...)` isn't a value of the script", .{word}), + } + } + + /// The one argument of the call `node`, which is `what`. + fn only(r: Reader, node: Index, arguments: []const Index, comptime what: []const u8) Error!Index { + if (arguments.len != 1) return r.fail(node, "this takes " ++ what, .{}); + return arguments[0]; + } + + /// `last_event(object, condition, value)`: the object, the condition by its name, and the + /// value of its event by its label in the condition catalogue, or each by its number. + fn lastEvent(r: Reader, node: Index, arguments: []const Index) Error!statements.EventValue { + if (arguments.len != 3) return r.fail(node, "`last_event(...)` takes the object, the condition and the value", .{}); + const condition = try r.c.narrow(u8, arguments[1], try r.c.named(arguments[1], .condition), "condition"); + const value: u8 = switch (r.get(arguments[2])) { + .string => |label| value: { + const values = if (conditions.find(condition)) |found| found.values else &.{}; + for (values, 0..) |entry, place| { + if (std.mem.eql(u8, entry.label, label)) break :value @intCast(place); + } + return r.fail(arguments[2], "the condition's event has no value \"{s}\"", .{label}); + }, + else => try r.c.whole(u8, arguments[2]), + }; + const object = try r.c.objectOf(arguments[0]); + return .{ .condition = condition, .value = value, .object = try r.c.narrow(u8, arguments[0], object, "object") }; + } +}; diff --git a/src/formats/dte/source/names.zig b/src/formats/dte/source/names.zig index dbe9e73d..d5175319 100644 --- a/src/formats/dte/source/names.zig +++ b/src/formats/dte/source/names.zig @@ -77,10 +77,20 @@ pub fn isReserved(name: []const u8) bool { 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; - } else true; + return argumentDigits(name) != null; +} + +/// The argument of the running part that a name such as `arg2` stands for, by its number. +pub fn argument(name: []const u8) ?u8 { + return std.fmt.parseInt(u8, argumentDigits(name) orelse return null, 10) catch null; +} + +/// The digits after `arg` in a name such as `arg2`, where it is one. +fn argumentDigits(name: []const u8) ?[]const u8 { + const digits = std.mem.cutPrefix(u8, name, "arg") orelse return null; + if (digits.len == 0) return null; + for (digits) |byte| if (!std.ascii.isDigit(byte)) return null; + return digits; } /// Makes the names of a file's records, each unique among those it has made. @@ -178,6 +188,16 @@ test Namer { try std.testing.expectEqualStrings("game_global", try namer.name("game", "global", 2)); } +test argument { + try std.testing.expectEqual(0, argument("arg0").?); + try std.testing.expectEqual(17, argument("arg17").?); + try std.testing.expectEqual(null, argument("arg")); + try std.testing.expectEqual(null, argument("argument")); + // Past the arguments a part can take, but still a name of the source's own. + try std.testing.expectEqual(null, argument("arg999")); + try std.testing.expect(isReserved("arg999")); +} + test isName { try std.testing.expect(isName("convoy_hit")); try std.testing.expect(isName("_private")); diff --git a/src/formats/dte/source/syntax.zig b/src/formats/dte/source/syntax.zig index fc97d774..07365f46 100644 --- a/src/formats/dte/source/syntax.zig +++ b/src/formats/dte/source/syntax.zig @@ -47,7 +47,20 @@ pub const Kind = enum(u8) { unsupported = 40, }; -pub const UnaryOperator = enum(u8) { not, minus, length }; +pub const UnaryOperator = enum(u8) { + not, + minus, + length, + + /// The operator as Luau writes it. + pub fn symbol(operator: UnaryOperator) []const u8 { + return switch (operator) { + .not => "not", + .minus => "-", + .length => "#", + }; + } +}; pub const BinaryOperator = enum(u8) { add, @@ -66,6 +79,28 @@ pub const BinaryOperator = enum(u8) { greater_equal, @"and", @"or", + + /// The operator as Luau writes it. + pub fn symbol(operator: BinaryOperator) []const u8 { + return switch (operator) { + .add => "+", + .sub => "-", + .mul => "*", + .div => "/", + .floor_div => "//", + .mod => "%", + .pow => "^", + .concat => "..", + .not_equal => "~=", + .equal => "==", + .less => "<", + .less_equal => "<=", + .greater => ">", + .greater_equal => ">=", + .@"and" => "and", + .@"or" => "or", + }; + } }; /// The forms of a table's item: `value`, `key = value` and `[key] = value`. @@ -96,6 +131,7 @@ pub const Node = extern struct { /// The node's text, `text_length` bytes from `text_start` in `Tree.text`. text_start: u32, text_length: u32, + /// A number's value, and for a name and a local's use, the local's number (`Syntax.Local`). number: f64, }; @@ -118,7 +154,7 @@ pub const Syntax = union(Kind) { boolean: bool, number: f64, string: []const u8, - local_name: []const u8, + local_name: Local, global_name: []const u8, field: Field, index: Indexed, @@ -128,9 +164,17 @@ pub const Syntax = union(Kind) { item: Item, unary: Unary, binary: Binary, - name: []const u8, + name: Local, unsupported: []const u8, + /// A local, by its name and its number: each local the file declares, a parameter or a + /// `local`, has a number of its own, which tells apart two of the same name where one hides + /// the other. + pub const Local = struct { + name: []const u8, + number: u32, + }; + pub const If = struct { condition: Index, then: Index, @@ -220,7 +264,7 @@ pub const Tree = struct { .boolean => .{ .boolean = node.op != 0 }, .number => .{ .number = node.number }, .string => .{ .string = text }, - .local_name => .{ .local_name = text }, + .local_name => .{ .local_name = .{ .name = text, .number = @intFromFloat(node.number) } }, .global_name => .{ .global_name = text }, .field => .{ .field = .{ .object = parts[0], .name = text } }, .index => .{ .index = .{ .object = parts[0], .key = parts[1] } }, @@ -237,7 +281,7 @@ pub const Tree = struct { }, .unary => .{ .unary = .{ .operator = @fromBackingInt(node.op), .operand = parts[0] } }, .binary => .{ .binary = .{ .operator = @fromBackingInt(node.op), .left = parts[0], .right = parts[1] } }, - .name => .{ .name = text }, + .name => .{ .name = .{ .name = text, .number = @intFromFloat(node.number) } }, .unsupported => .{ .unsupported = text }, }; } @@ -264,7 +308,8 @@ pub const Tree = struct { switch (tree.get(index)) { .boolean => |value| try writer.print(" {}", .{value}), .number => |value| try writer.print(" {d}", .{value}), - .string, .local_name, .global_name, .name, .unsupported => |text| try writer.print(" \"{f}\"", .{std.zig.fmtString(text)}), + .string, .global_name, .unsupported => |text| try writer.print(" \"{f}\"", .{std.zig.fmtString(text)}), + .local_name, .name => |local| try writer.print(" \"{f}\"", .{std.zig.fmtString(local.name)}), .field => |field| try writer.print(" \"{f}\"", .{std.zig.fmtString(field.name)}), .compound_assign => |assign| try writer.print(" {t}", .{assign.operator}), .unary => |unary| try writer.print(" {t}", .{unary.operator}), diff --git a/src/formats/dte/source/vocabulary.zig b/src/formats/dte/source/vocabulary.zig index f556ecc4..c6d942dd 100644 --- a/src/formats/dte/source/vocabulary.zig +++ b/src/formats/dte/source/vocabulary.zig @@ -23,6 +23,15 @@ pub const Kind = enum { head, /// A part, which mission source names by the part's own name. part, + + /// What a value of the kind is called in a message. + pub fn noun(kind: Kind) []const u8 { + return switch (kind) { + .ship_type => "ship type", + .head => "head movement", + else => @tagName(kind), + }; + } }; /// The name of value `number` of `kind`, where the game names it. @@ -37,6 +46,23 @@ pub fn name(kind: Kind, number: u32) ?[]const u8 { }; } +/// The value of `kind` that the game names `text`, the inverse of `name`. +pub fn value(kind: Kind, text: []const u8) ?u32 { + return switch (kind) { + .ship_type => numberOf(gameobj.GameType, text), + .pilot => numberOf(pilots.GamePilot, text), + .order => numberOf(orders.Order, text), + .condition => numberOf(dte.Condition, text), + .head => numberOf(pilots.Head, text), + .part => null, + }; +} + +/// The number of the tag of `Enum` named `text`, where it has one that a value can take. +fn numberOf(comptime Enum: type, text: []const u8) ?u32 { + return std.math.cast(u32, @backingInt(std.meta.stringToEnum(Enum, text) orelse return 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 { @@ -44,6 +70,12 @@ pub fn variable(number: u8) ?[]const u8 { return if (found[0] == '_') null else found; } +/// The number of the game's variable named `text`, the inverse of `variable`. +pub fn variableNumber(text: []const u8) ?u8 { + if (text.len == 0 or text[0] == '_') return null; + return @backingInt(std.meta.stringToEnum(vm.GameVariable, text) orelse return null); +} + /// 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. @@ -75,11 +107,31 @@ test name { try std.testing.expectEqual(null, name(.order, 70_000)); } +test value { + // Each name reads back as the value it names. + for ([_]struct { Kind, u32 }{ + .{ .ship_type, @backingInt(gameobj.GameType.grendel) }, + .{ .order, @intCast(@backingInt(orders.Order.find_new_target)) }, + .{ .condition, @backingInt(dte.Condition.shot_at) }, + .{ .head, @backingInt(pilots.Head.laughing) }, + .{ .pilot, @backingInt(pilots.GamePilot.bandit_tigers_leader) }, + }) |pair| try std.testing.expectEqual(pair[1], value(pair[0], name(pair[0], pair[1]).?).?); + try std.testing.expectEqual(null, value(.ship_type, "rubber_duck")); + try std.testing.expectEqual(null, value(.part, "start")); +} + 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 variableNumber { + try std.testing.expectEqual(vm.Variables.number("backup_available"), variableNumber("backup_available").?); + // A placeholder's number is written as one, never by its name. + try std.testing.expectEqual(null, variableNumber("_unknown_6")); + try std.testing.expectEqual(null, variableNumber("no_such_variable")); +} + test of { const executor = @import("../../../engine/game/executor.zig"); const set_ai = commands.find(executor.commandIndex("SetAI")).?; diff --git a/src/formats/dte/source/write.zig b/src/formats/dte/source/write.zig index 1a1f0c2b..cf454640 100644 --- a/src/formats/dte/source/write.zig +++ b/src/formats/dte/source/write.zig @@ -8,6 +8,7 @@ const Writer = std.Io.Writer; const dte = @import("../../dte.zig"); const decompile = @import("../decompile.zig"); +const bind = @import("../../../engine/game/mission/bind.zig"); const conditions = @import("../../../engine/vm/conditions.zig"); const names = @import("names.zig"); const script = @import("script.zig"); @@ -32,7 +33,6 @@ const Source = struct { 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, @@ -44,6 +44,8 @@ const Source = struct { routines: []const dte.Routine, record_names: script.Names, formation_names: []const []const u8, + /// Each formation's points, by their indices, as the game finds them (`dte.Mission.pointsOf`). + formation_points: []const []const u16, /// 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. @@ -57,7 +59,6 @@ const Source = struct { .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(), @@ -68,6 +69,7 @@ const Source = struct { .routines = try mission.routines(arena), .record_names = .{}, .formation_names = &.{}, + .formation_points = &.{}, .events = undefined, .namer = .init(arena), }; @@ -86,6 +88,9 @@ const Source = struct { .objects = try source.objectNames(), }; source.formation_names = try source.nameAll(dte.Formation, source.formations, .formation); + const points = try arena.alloc([]const u16, source.formations.len); + for (points, 0..) |*list, index| list.* = try mission.pointsOf(arena, index); + source.formation_points = points; try source.nameHandlers(); return source; } @@ -104,20 +109,19 @@ const Source = struct { return list; } - /// The name of each object's record, by its object ID: a ship's, a flight group's or a squad's. + /// The name of each object's record, by its object ID: the ship's, the flight group's or the + /// squad's the game takes it for (`mission.bind.resolveObjects`). 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); + const records = try bind.resolveObjects(source.arena, source.mission); + const list = try source.arena.alloc(?[]const u8, records.len); + for (list, records) |*name, record| name.* = if (record) |found| switch (found) { + .ship => |index| source.record_names.ships[index], + .flight_group => |index| source.record_names.flight_groups[index], + .squad => |index| source.record_names.squads[index], + } else null; 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 { @@ -168,18 +172,21 @@ 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"`, the start of a declaration, and the characters it takes. + fn open(source: *Source, w: *Writer, name: []const u8, constructor: Word, display: u16) Writer.Error!usize { + var line: Writer.Allocating = .init(source.arena); + try line.writer.print("{s} = {t}(", .{ name, constructor }); + try script.writeString(&line.writer, source.mission.name(display)); + try w.writeAll(line.written()); + return line.written().len; } /// `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); + const column = 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 fields.write(w, column + ", ".len, ")".len); } try w.writeAll(")\n"); } @@ -187,7 +194,7 @@ const Source = struct { 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 source.open(w, name, .global, global.name); + _ = try source.open(w, name, .global, global.name); try w.print(", {d})\n", .{global.value}); } } @@ -240,28 +247,23 @@ const Source = struct { try heading(w, "Squads", source.squads.len); for (source.squads, source.record_names.squads, 0..) |squad, name, index| { var members: std.ArrayList([]const u8) = .empty; - for (source.members) |member| { - if (member.squad != index) continue; + for (try source.mission.membersOf(index)) |member| { const object = source.record_names.object(member.object_id); 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 source.open(w, name, .squad, squad.name); - try writeList(w, members.items); + try writeList(w, members.items, try source.open(w, name, .squad, squad.name)); } } 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| { + for (source.formations, source.formation_names, source.formation_points) |formation, name, list| { var points: std.ArrayList([]const u8) = .empty; - for (source.points) |point| { - if (point.formation == index) try points.append(source.arena, try std.fmt.allocPrint(source.arena, "{f}", .{vector(point.offset)})); - } - try source.open(w, name, .formation, formation.name); - try writeList(w, points.items); + for (list) |point| try points.append(source.arena, try std.fmt.allocPrint(source.arena, "{f}", .{vector(source.points[point].offset)})); + try writeList(w, points.items, try source.open(w, name, .formation, formation.name)); } } @@ -279,7 +281,7 @@ const Source = struct { try fields.add("leaving", "{f}", .{vector(curve.leaving)}); try fields.add("arriving", "{f}", .{vector(curve.arriving)}); try w.print("{s} = {t}(", .{ name, Word.curve }); - try fields.write(w, name.len + " = curve(".len); + try fields.write(w, name.len + " = curve(".len, ")".len); try w.writeAll(")\n"); } } @@ -290,7 +292,7 @@ const Source = struct { const routine = source.routineAt(start).?; 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 source.function(w, routine, .{ .event = source.eventsOf(condition) }, 0); try w.writeAll(")\n"); } } @@ -303,9 +305,8 @@ 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.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))}); + const subject: script.Indexed = if (owner) |object| source.record_names.object(object) else .{ .name = "nil" }; + const head = try std.fmt.allocPrint(source.arena, "{t}({f}, {f}", .{ Word.on, subject, named(.condition, @backingInt(trigger.condition)) }); var fields: Fields = .init(source.arena); switch (trigger.repeat) { .once => {}, @@ -319,24 +320,54 @@ const Source = struct { if (trigger.component()) |component| try fields.add("component", "{d}", .{component}); if (trigger.deferred != 0) try fields.add("deferred", "true", .{}); try source.filters(&fields, trigger); + const code = source.triggerCode(trigger, owner); + const event = source.eventsOf(trigger.condition); + try w.writeAll(head); if (fields.list.items.len > 0) { + // What follows the fields on their line: the code's first line, or the bracket. + const after = switch (code) { + .none => ")".len, + .handler => |name| ", ".len + name.len + ")".len, + .function => ", function()".len + parametersLength(event), + }; try w.writeAll(", "); - try fields.write(w, "on(".len); + try fields.write(w, head.len + ", ".len, after); + } + switch (code) { + .none => {}, + .handler => |name| try w.print(", {s}", .{name}), + .function => |routine| { + // A function that doesn't fit after the subject and the condition starts a line + // of its own. + const own_line = fields.list.items.len == 0 and head.len + ", function()".len + parametersLength(event) > line_length; + try w.writeAll(if (own_line) ",\n " else ", "); + try source.function(w, routine, .{ .event = event }, @intFromBool(own_line)); + }, } - 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 code a trigger runs: the block it shares with other triggers, by its handler's name, + /// or its own, where it can fire and has one. + const Code = union(enum) { + none, + handler: []const u8, + function: dte.Routine, + }; + + fn triggerCode(source: *Source, trigger: dte.Trigger, owner: ?u16) Code { + const start = trigger.block() orelse return .none; + if (source.handlers.get(start)) |handler| return .{ .handler = handler }; + if (owner == null) return .none; + return .{ .function = source.routineAt(start) orelse return .none }; + } + + /// The characters a function's parameters `names` take, with the commas between them. + fn parametersLength(parameter_names: []const []const u8) usize { + var length: usize = 0; + for (parameter_names, 0..) |name, index| length += name.len + @as(usize, if (index > 0) ", ".len else 0); + return length; } /// The values a trigger's event must have, by their names, for the values its condition @@ -361,13 +392,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 source.open(w, name, .part, 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, .{ .arguments = part.arguments }); + try source.function(w, found, .{ .arguments = part.arguments }, 0); } else if (part.flags.start) { try w.writeAll(", nil"); } @@ -382,8 +413,9 @@ const Source = struct { arguments: u8, }; - /// A function of `routine`'s statements, with `parameters`. - fn function(source: *Source, w: *Writer, routine: dte.Routine, parameters: Parameters) Error!void { + /// A function of `routine`'s statements, with `parameters`, whose `end` is indented `depth` + /// levels. + fn function(source: *Source, w: *Writer, routine: dte.Routine, parameters: Parameters, depth: usize) Error!void { try w.writeAll("function("); switch (parameters) { .event => |event| for (event, 0..) |name, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", name }), @@ -395,23 +427,37 @@ const Source = struct { .event => |names_of_values| names_of_values, .arguments => &.{}, }; - try script.write(w, body, &source.record_names, .{ .event = event }, 1); + try script.write(w, body, &source.record_names, .{ .event = event }, depth + 1); + try w.splatByteAll(' ', 4 * depth); try w.writeAll("end"); } /// Point `point` of `formation_points` as its formation's name indexed by its place among the - /// formation's points, from 1. + /// formation's points, from 1, or by its index where it is none of its formation's. fn pointName(source: Source, point: u16) Allocator.Error![]const u8 { - 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 }); + if (point < source.points.len) { + const formation = source.points[point].formation; + if (formation < source.formations.len) { + if (std.mem.findScalar(u16, source.formation_points[formation], point)) |place| { + return std.fmt.allocPrint(source.arena, "{s}[{d}]", .{ source.formation_names[formation], place + 1 }); + } + } + } + return std.fmt.allocPrint(source.arena, "{t}({d})", .{ Word.formation_point_index, point }); } }; -/// `, { 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 { +/// `, { a, b })` and a new line: a declaration's list and its end, after `column` characters of +/// its line, or with an item to a line where it doesn't fit there. `, {})` for an empty one. +fn writeList(w: *Writer, items: []const []const u8, column: usize) Writer.Error!void { if (items.len == 0) return w.writeAll(", {})\n"); + var length = column + ", { })".len; + for (items, 0..) |item, index| length += item.len + @as(usize, if (index > 0) ", ".len else 0); + if (length > line_length) { + try w.writeAll(", {\n"); + for (items) |item| try w.print(" {s},\n", .{item}); + 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"); @@ -475,11 +521,11 @@ const Fields = struct { 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 { + /// Writes the fields as a table, starting `column` characters into its line, with `after` + /// characters after it on the line. + fn write(fields: Fields, w: *Writer, column: usize, after: usize) Writer.Error!void { if (fields.list.items.len == 0) return w.writeAll("{}"); - var length = column + "{ })".len; + var length = column + "{ }".len + after; for (fields.list.items, 0..) |field, index| { length += field[0].len + " = ".len + field[1].len + @as(usize, if (index > 0) ", ".len else 0); } diff --git a/src/formats/dte/statements.zig b/src/formats/dte/statements.zig index 2923b996..55ce0868 100644 --- a/src/formats/dte/statements.zig +++ b/src/formats/dte/statements.zig @@ -393,6 +393,66 @@ fn push(routine: *assemble.Routine, expression: Expression, use: Use, context: C } } +/// Whether `a` and `b` are the same code: equal statements, but for the last bytes of a random +/// choice's arms, which the engine never reads. `objects.same(x, y)` says whether the objects that +/// `push_event_value` reads, `x` in `a` and `y` in `b`, are the same, which `Numbers` takes for +/// equal numbers. +pub fn same(a: []const Statement, b: []const Statement, objects: anytype) bool { + if (a.len != b.len) return false; + for (a, b) |left, right| if (!sameStatement(left, right, objects)) return false; + return true; +} + +/// Objects compared by their numbers, for `same`. +pub const Numbers = struct { + pub fn same(_: Numbers, a: u8, b: u8) bool { + return a == b; + } +}; + +fn sameStatement(a: Statement, b: Statement, objects: anytype) bool { + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return switch (a) { + .command => |command| command.index == b.command.index and sameValues(command.arguments, b.command.arguments, objects), + .call, .spawn => |call| { + const other = if (b == .call) b.call else b.spawn; + return call.part == other.part and sameValues(call.arguments, other.arguments, objects); + }, + .assign => |assign| std.meta.eql(assign.target, b.assign.target) and assign.update == b.assign.update and sameValue(assign.value, b.assign.value, objects), + .@"if" => |branch| { + const other = b.@"if"; + if (!sameValue(branch.condition, other.condition, objects) or !same(branch.then, other.then, objects)) return false; + const otherwise = branch.otherwise orelse return other.otherwise == null; + return other.otherwise != null and same(otherwise, other.otherwise.?, objects); + }, + .random => |random| { + const other = b.random; + if (random.arms.len != other.arms.len or !same(random.default, other.default, objects)) return false; + for (random.arms, other.arms) |arm, other_arm| { + if (arm.threshold != other_arm.threshold or !same(arm.body, other_arm.body, objects)) return false; + } + return true; + }, + .@"return" => |value| sameValue(value, b.@"return", objects), + }; +} + +fn sameValues(a: []const Expression, b: []const Expression, objects: anytype) bool { + if (a.len != b.len) return false; + for (a, b) |left, right| if (!sameValue(left, right, objects)) return false; + return true; +} + +fn sameValue(a: Expression, b: Expression, objects: anytype) bool { + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; + return switch (a) { + .text => |text| std.mem.eql(u8, text, b.text), + .event_value => |value| value.condition == b.event_value.condition and value.value == b.event_value.value and objects.same(value.object, b.event_value.object), + .binary => |pair| pair.operator == b.binary.operator and sameValue(pair.left, b.binary.left, objects) and sameValue(pair.right, b.binary.right, objects), + else => std.meta.eql(a, b), + }; +} + /// Expressions for the tests, by name: an order, as `SetAI` takes it, and a value of an event, by /// its label in the condition catalogue. pub const testing = struct { @@ -458,6 +518,37 @@ test lower { try std.testing.expectEqualSlices(u8, &.{ 1, 0, 0, 0, 0, 0, 0, 0 }, table); } +test same { + const left: Binary = .{ .operator = .equal, .left = .{ .global = 0 }, .right = .{ .text = "a" } }; + const right: Binary = .{ .operator = .equal, .left = .{ .global = 0 }, .right = .{ .text = "a" } }; + const arm: Random.Arm = .{ .threshold = 50, .extra = 0, .body = &.{} }; + var other_arm = arm; + other_arm.extra = 7; + const a = [_]Statement{ + .{ .@"if" = .{ .condition = .{ .binary = &left }, .then = &.{}, .otherwise = &.{} } }, + .{ .random = .{ .arms = &.{arm}, .default = &.{} } }, + }; + const b = [_]Statement{ + .{ .@"if" = .{ .condition = .{ .binary = &right }, .then = &.{}, .otherwise = &.{} } }, + .{ .random = .{ .arms = &.{other_arm}, .default = &.{} } }, + }; + // Equal values behind other pointers, and arms that differ in their unread byte alone. + try std.testing.expect(same(&a, &b, Numbers{})); + // An empty `else` is not none. + const c = [_]Statement{.{ .@"if" = .{ .condition = .{ .binary = &left }, .then = &.{}, .otherwise = null } }}; + try std.testing.expect(!same(a[0..1], &c, Numbers{})); + // The objects an event's value is read from, as the caller compares them. + const first = [_]Statement{.{ .@"return" = .{ .event_value = .{ .condition = 0, .value = 2, .object = 47 } } }}; + const second = [_]Statement{.{ .@"return" = .{ .event_value = .{ .condition = 0, .value = 2, .object = 0 } } }}; + try std.testing.expect(!same(&first, &second, Numbers{})); + const Renumbered = struct { + pub fn same(_: @This(), x: u8, y: u8) bool { + return x == 47 and y == 0; + } + }; + try std.testing.expect(same(&first, &second, Renumbered{})); +} + test "a number compared with a percentage is a byte" { const gpa = std.testing.allocator; const shot_at = conditions.find(@backingInt(dte.Condition.shot_at)).?; diff --git a/src/openreliant/mission0.zig b/src/openreliant/mission0.zig index 29d90d76..0a7aaf7b 100644 --- a/src/openreliant/mission0.zig +++ b/src/openreliant/mission0.zig @@ -188,25 +188,14 @@ pub fn write(gpa: Allocator) ![]u8 { const groups = std.enums.values(Group); const group_records = try arena.alloc(dte.FlightGroup, groups.len); - var listed: u32 = 0; for (group_records, groups) |*record, group| { - var count: u8 = 0; - for (placed) |ship| { - if (ship.group == group) count += 1; - } - record.* = .{ - .object_id = @intCast(placed.len + @backingInt(group)), - ._unknown_02 = 0, - .name = try addString(arena, &strings, group.label()), - ._unknown_06 = 0, - .wing = group.wing(), - .ship_count = count, - ._unknown_0a = 0, - .first_ship = listed, - .formation = dte.FlightGroup.no_formation, - }; - listed += count; + record.* = .blank; + record.object_id = @intCast(placed.len + @backingInt(group)); + record.name = try addString(arena, &strings, group.label()); + record.wing = group.wing(); } + // Each group's count of ships and the first one's place, as binding the mission works them out. + _ = try game.mission.bind.listGroupShips(arena, group_records, records); // The object table: the ships' IDs, then the flight groups', none with triggers. The player's // ship, ship 0, is marked as the original's tools mark it in the game's missions. @@ -214,12 +203,12 @@ pub fn write(gpa: Allocator) ![]u8 { for (objects, 0..) |*object, id| object.* = .{ .kind = if (id < placed.len) .ship else .flight_group, .count = 0, - .first = no_triggers, + .first = dte.Object.no_triggers, .tool_flags = .{ .player = id == 0 }, }; const code = try script(arena); - var part = std.mem.zeroes(dte.Part); + var part: dte.Part = .blank; part.name = part_name; part.offset = 0; part.flags.start = true; @@ -241,19 +230,10 @@ pub fn write(gpa: Allocator) ![]u8 { return dte.write.write(gpa, §ions, .{ .name = name }); } -/// The object table's `first` for an object with no triggers, as the game's missions give it. -const no_triggers = 0xFFFF; - -/// The bytes after a ship's pitch (`_unknown_3c`, `tier`, `_unknown_3e`, the marker's curve and its -/// place on it), as most of the ships of the game's missions have them: tier 255, which asks for -/// the campaign's, and no curve. -const ship_tail = [_]u8{ 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00 }; - -/// Ship `ship`'s record, as the game's missions have their ships': object ID `id`, named at -/// `name_at`, launching through its tube of the first Reliant, or from none, in no formation, every -/// component intact, standing where it is placed. +/// Ship `ship`'s record: object ID `id`, named at `name_at`, launching through its tube of the +/// first Reliant, or from none, standing where it is placed (`dte.Ship.blank` for the rest). fn shipRecord(ship: Placed, id: u32, name_at: u16) dte.Ship { - var record = std.mem.zeroes(dte.Ship); + var record: dte.Ship = .blank; record.object_id = id; record.name = name_at; record.runtime_position = ship.at; @@ -261,19 +241,14 @@ fn shipRecord(ship: Placed, id: u32, name_at: u16) dte.Ship { record.flight_group = @backingInt(ship.group); record.pilot = ship.pilot; record.kind = @intCast(ship.kind.number()); - record.launch_from = if (ship.gate != null) @intCast(Type.of(.reliant).number()) else std.math.maxInt(u16); - record._unknown_2a = 0xFF; - record.launch_gate = ship.gate orelse dte.Ship.no_launch; + if (ship.gate) |gate| { + record.launch_from = @intCast(Type.of(.reliant).number()); + record.launch_gate = gate; + } record.runtime_yaw = ship.yaw; record.yaw = ship.yaw; - record.intact_components = dte.Ship.all_intact; - record.formation_point = dte.Ship.no_formation_point; - record.formation_point_tag = .none; - record._unknown_37 = 0xFF; record.runtime_pitch = ship.pitch; record.pitch = ship.pitch; - const tail = std.mem.asBytes(&record)[@offsetOf(dte.Ship, "_unknown_3c")..][0..ship_tail.len]; - tail.* = ship_tail; record.runtime_roll = ship.roll; record.roll = ship.roll; return record; diff --git a/src/source_parser.zig b/src/source_parser.zig index 6c41657d..e5d76fdd 100644 --- a/src/source_parser.zig +++ b/src/source_parser.zig @@ -1,14 +1,17 @@ //! Reads mission source with Luau's own parser //! ([Mission source](../docs/formats/mission-source.md)): `source_parser/luau.cpp` parses the //! text and writes the syntax tree into flat arrays, and `parse` copies them into the library's -//! tree (`dte.source.syntax`). It is a module of its own because it links Luau's parser, which the -//! library the tools share doesn't. +//! tree (`dte.source.syntax`). `build` compiles the text into a mission file. It is a module of its +//! own because it links Luau's parser, which the library the tools share doesn't, and so it holds +//! the tests that compile source. const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; -const syntax = @import("openreliant").dte.source.syntax; +const dte = @import("openreliant").dte; +const syntax = dte.source.syntax; +const compile = dte.source.compile; const luau = @import("luau_syntax"); /// Parses `text` as Luau, into a tree in `arena` with the errors Luau's parser found. @@ -32,6 +35,12 @@ pub fn parse(arena: Allocator, text: []const u8) Allocator.Error!syntax.Tree { }; } +/// Compiles the mission source `text` into a mission file in `arena` (`dte.source.compile`). Where +/// the source has an error, the parser's or the compiler's, `failure` says what and where. +pub fn build(arena: Allocator, text: []const u8, failure: *compile.Failure) compile.Error![]u8 { + return compile.compile(arena, try parse(arena, text), failure); +} + // The bridge's nodes have the library's layout, and the header gives every kind of node, operator // and form of a table's item the library's number. comptime { @@ -147,7 +156,28 @@ test "constructs mission source doesn't use" { ); } -test "errors and their places" { +test "a local that hides a parameter is told apart from it" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const tree = try parse(arena_state.allocator(), + \\f = function(roll) + \\ local roll = roll + \\ return roll + \\end + ); + try std.testing.expectEqual(0, tree.errors.len); + const function = tree.get(tree.get(tree.get(tree.root).block[0]).assign.values[0]).function; + const parameter = tree.get(function.parameters[0]).name; + const lines = tree.get(function.body).block; + const local = tree.get(lines[0]).local; + const hiding = tree.get(local.targets[0]).name; + // The local's value is the parameter, and what it returns is the local. + try std.testing.expect(parameter.number != hiding.number); + try std.testing.expectEqual(parameter.number, tree.get(local.values[0]).local_name.number); + try std.testing.expectEqual(hiding.number, tree.get(tree.get(lines[1]).@"return"[0]).local_name.number); +} + +test "the parser's errors and their places" { var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -163,3 +193,202 @@ test "errors and their places" { try std.testing.expectEqual(0, empty.errors.len); try std.testing.expectEqual(0, empty.get(empty.root).block.len); } + +/// A mission with every kind of record and every form of the script, as `sltool dte decompile` +/// writes it. +const patrol = + \\mission({ name = "Patrol" }) + \\ + \\-- Globals + \\ + \\convoy_hits = global("(GV)convoy hits", 0) + \\lost = global("(GV)lost", 4294967295) + \\ + \\-- Flight groups + \\ + \\alpha = flight_group("(FG)Alpha", { wing = "player" }) + \\sabres = flight_group("(FG)Sabres", { formation = vee }) + \\ + \\-- Ships + \\ + \\player = ship("Player", { + \\ type = "grendel", + \\ group = alpha, + \\ position = { 100, -200, 300.5 }, + \\ yaw = 90, + \\}) + \\wingman = ship("Wingman", { + \\ type = "wolverine", + \\ group = alpha, + \\ pilot = "bandit_tigers_leader", + \\ launch = { from = "reliant", gate = 2 }, + \\ tier = 3, + \\}) + \\sabre_1 = ship("Sabre 1", { type = "sabre", group = sabres, yaw = 180, formation_point = vee[1] }) + \\sabre_2 = ship("Sabre 2", { + \\ type = "sabre", + \\ group = sabres, + \\ yaw = 180, + \\ roll = -45, + \\ formation_point = vee[2], + \\}) + \\home = nav_point("Home", { position = { 0, 0, 50000 } }) + \\halfway = point("Halfway", { marker = { curve = curve_0, at = 0.5 } }) + \\start = curve_point("Start") + \\end_ship = curve_point("End", { position = { 1000, 0, 0 } }) + \\ + \\-- Squads + \\ + \\targets = squad("(AI)targets", { sabre_1, component(sabre_2, 2), sabres }) + \\everyone = squad("(AI)everyone", { targets, player }) + \\ + \\-- Formations + \\ + \\vee = formation("vee", { { 0, 0, 0 }, { -400, 0, 300 } }) + \\ + \\-- Curves + \\ + \\curve_0 = curve({ + \\ start = start, + \\ finish = end_ship, + \\ from = { 0, 0, 0 }, + \\ to = { 1000, 0, 0 }, + \\ leaving_handle = start, + \\ arriving_handle = end_ship, + \\ leaving = { 10, 0, 0 }, + \\ arriving = { 0, 0, -10 }, + \\}) + \\ + \\-- Code more than one trigger runs + \\ + \\sabre_1_shot_at = handler(function(attacker, shield_damage, hull_damage, victim, weapon_fired) + \\ convoy_hits += 1 + \\ if hull_damage >= 50 then + \\ SetAI(targets, "find_new_target", 1, nil) + \\ end + \\ return 1 + \\end) + \\ + \\-- Triggers + \\ + \\on(sabre_1, "shot_at", { repeats = "always", attacker = any_ship }, sabre_1_shot_at) + \\on(sabre_2, "shot_at", { + \\ repeats = { counted = 3, left = 1 }, + \\ component = 2, + \\ deferred = true, + \\}, sabre_1_shot_at) + \\on(sabres, "destroyed", function(killer, victim) + \\ if last_event(targets, "shot_at", "Hull Damage") > 20 and result() ~= 0 then + \\ go_home(5) + \\ elseif game.mission_over == 0 then + \\ spawn(go_home, killer) + \\ else + \\ end + \\ local roll = random() + \\ if roll < 50 then + \\ lost = 1 + \\ elseif roll < 100 then + \\ game.ion_cannons_hold_lock = thread_value(3) + \\ else + \\ add_assign_f(lost, 1065353216) + \\ end + \\ return 1 + \\end) + \\on(nil, "destroyed") + \\ + \\-- Parts + \\ + \\go_home = part("(F)go home", function(arg0) + \\ PlayMusic("new_launch.wav", 0) + \\ CreateTimer(3, go_home, 30, arg0 + 1) + \\ command(250, "a mod's command") + \\ if in_flight_group(player, alpha) or greater_f(arg0, 0) then + \\ arg0 = component(sabre_2, 1) + \\ end + \\ return 1 + \\end) + \\launch = part("(F)launch", function() + \\ StartLaunch(alpha) + \\ return 1 + \\end, { start = true }) + \\not_written_yet = part("(F)not written yet") + \\ +; + +test "a mission built from its source decompiles to that source" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var failure: compile.Failure = undefined; + const built = build(arena, patrol, &failure) catch |err| { + std.debug.print("{f}\n", .{failure}); + return err; + }; + var out: std.Io.Writer.Allocating = .init(arena); + try dte.source.write.write(&out.writer, arena, try .parse(built)); + try std.testing.expectEqualStrings(patrol, out.written()); +} + +test "the objects that last_event names are numbered first" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + var failure: compile.Failure = undefined; + const mission: dte.Mission = try .parse(try build(arena_state.allocator(), + \\a = ship("A", { type = "grendel" }) + \\b = ship("B", { type = "grendel" }) + \\g = flight_group("G") + \\s = squad("S", { a }) + \\on(a, "destroyed", function() + \\ return last_event(s, "destroyed", "Killer") + \\end) + , &failure)); + // The script names the object `last_event` reads by a byte, so it comes first; then the ships, + // the flight groups and the squads. + try std.testing.expectEqual(0, (try mission.squads())[0].object_id); + try std.testing.expectEqual(1, (try mission.ships())[0].object_id); + try std.testing.expectEqual(2, (try mission.ships())[1].object_id); + try std.testing.expectEqual(3, (try mission.flightGroups())[0].object_id); + const objects = try mission.objects(); + try std.testing.expectEqual(.squad, objects[0].kind); + try std.testing.expectEqual(.flight_group, objects[3].kind); + // `a`'s entry holds its trigger, and marks it as the original's tools mark ship 0. + try std.testing.expectEqual(1, objects[1].count); + try std.testing.expect(objects[1].tool_flags.player); + try std.testing.expectEqual(dte.Object.no_triggers, objects[2].first); +} + +test "the compiler's errors and their places" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + for ([_]struct { []const u8, []const u8 }{ + .{ "on(nobody, \"destroyed\")", "1:4: `nobody` is not declared" }, + .{ "a = global(\"a\", 0)\na = global(\"b\", 0)", "2:1: `a` is declared already, at 1:5" }, + .{ "ship = global(\"x\", 0)", "1:1: `ship` is a name mission source keeps for itself" }, + .{ "Wait = global(\"x\", 0)", "1:1: `Wait` is a name mission source keeps for itself" }, + .{ "x = 1", "1:5: `x` is declared by a constructor, such as `ship(...)`" }, + .{ "p = ship(\"P\")", "1:5: a ship needs its `type`" }, + .{ "p = ship(\"P\", { type = \"rubber_duck\" })", "1:24: the game has no ship type named \"rubber_duck\"" }, + .{ "p = ship(\"P\", { type = \"grendel\", colour = 1 })", "1:35: `colour` isn't a field here, which takes `type`, `group`, `pilot`, `position`, `yaw`, `pitch`, `roll`, `launch`, `formation_point` and `tier`" }, + .{ "p = ship(\"P\", { type = \"grendel\", yaw = 40000 })", "1:41: a whole number from -32768 to 32767 goes here" }, + .{ "g = global(\"g\", 0)\np = ship(\"P\", { type = \"grendel\", group = g })", "2:43: `g` is a global, not a flight group" }, + .{ "a = ship(\"A\", { type = \"grendel\" })\nb = ship(\"B\", { type = \"grendel\" })\non(a, \"destroyed\")\non(b, \"destroyed\")\non(a, \"destroyed\")", "5:1: an object's triggers follow one another" }, + .{ "on(nil, \"destroyed\", { attacker = any_ship })", "1:24: `attacker` isn't a field here, which takes `repeats`, `component`, `deferred`, `killer` and `victim`" }, + .{ "p = part(\"p\", function()\n while true do end\nend)", "2:5: a loop, which mission source doesn't use" }, + .{ "p = part(\"p\", function()\n local roll = random()\n return 1\nend)", "3:5: a random choice's roll is followed by the `if` over its arms" }, + .{ "on(nil, \"destroyed\", function(killer)\n killer = 1\nend)", "2:5: `killer` is a value of the event, which can't be assigned" }, + .{ "p = part(\"p\", function()\n return not result()\nend)", "2:12: the script has no `not`" }, + .{ "p = part(\"p\", function()\n Wait(\"long\" .. \"er\")\nend)", "2:10: the script has no `..`" }, + .{ "p = part(\"p\", function()\n SetAI(nil, \"dance\", 1, nil)\nend)", "2:16: the game has no order named \"dance\"" }, + }) |case| { + var failure: compile.Failure = undefined; + try std.testing.expectError(error.Invalid, build(arena, case[0], &failure)); + var out: std.Io.Writer.Allocating = .init(arena); + try out.writer.print("{f}", .{failure}); + try std.testing.expectEqualStrings(case[1], out.written()); + } + // The parser's errors come first, with their places. + var failure: compile.Failure = undefined; + try std.testing.expectError(error.Invalid, build(arena, "x = ship(\"A\",\ny = 2", &failure)); + try std.testing.expectEqual(1, failure.at.line); +} diff --git a/src/source_parser/luau.cpp b/src/source_parser/luau.cpp index c529a92a..c0f1dc29 100644 --- a/src/source_parser/luau.cpp +++ b/src/source_parser/luau.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include // The header numbers the operators and the forms of a table's item as Luau does. @@ -45,6 +46,8 @@ struct Builder { std::vector nodes; std::vector children; std::string text; + // The number of each local the file declares, in the order Luau's parser meets them. + std::unordered_map locals; // Adds a node of `kind` at `location` with the children `kids`, and gives its index. uint32_t node(uint8_t kind, const Luau::Location &location, const std::vector &kids, @@ -76,9 +79,14 @@ struct Builder { return unsupported(location, "more than " + std::to_string(most_names) + " " + std::string(what)); } + // The number of `local`, which tells it apart from another local of the same name. + uint32_t localNumber(const Luau::AstLocal *local) { + return locals.emplace(local, static_cast(locals.size())).first->second; + } + // Adds the name that a local or a parameter declares. uint32_t name(const Luau::AstLocal *local) { - return node(OR_NAME, local->location, {}, 0, 0, local->name.value); + return node(OR_NAME, local->location, {}, 0, localNumber(local), local->name.value); } uint32_t string(const Luau::Location &location, const Luau::AstArray &value) { @@ -157,7 +165,8 @@ struct Builder { if (found->expressions.size > 0) return unsupported(at, "a string with values put into it"); return string(at, found->strings.data[0]); } - if (auto found = expr->as()) return node(OR_LOCAL_NAME, at, {}, 0, 0, found->local->name.value); + if (auto found = expr->as()) + return node(OR_LOCAL_NAME, at, {}, 0, localNumber(found->local), found->local->name.value); if (auto found = expr->as()) return node(OR_GLOBAL_NAME, at, {}, 0, 0, found->name.value); if (auto found = expr->as()) return node(OR_FIELD, at, {expression(found->expr)}, 0, 0, found->index.value); diff --git a/src/source_parser/luau.h b/src/source_parser/luau.h index c14c58a8..e24c6425 100644 --- a/src/source_parser/luau.h +++ b/src/source_parser/luau.h @@ -70,7 +70,8 @@ enum { /* A node. Its kind decides what `op` holds (`syntax.Node.op`). `first` and `count` give its * children's indices in `OrTree.children`, and `text_start` and `text_length` its text in - * `OrTree.text`. */ + * `OrTree.text`. `number` holds a number's value, and for a name and a local's use the local's + * number, which each local the file declares has of its own. */ typedef struct { uint8_t kind; uint8_t op; diff --git a/src/tools/sltool/dte.zig b/src/tools/sltool/dte.zig index 838e3ea1..64fc7e06 100644 --- a/src/tools/sltool/dte.zig +++ b/src/tools/sltool/dte.zig @@ -26,6 +26,8 @@ pub const Command = union(enum) { check: struct { mission: []const u8 }, /// Writes the mission as Luau source. decompile: struct { mission: []const u8 }, + /// Compiles mission source into a mission file. + build: struct { source: []const u8, mission: []const u8 }, pub const usage = \\ dte info summarise a mission @@ -38,6 +40,7 @@ pub const Command = union(enum) { \\ dte check rewrite the mission and its script, and check that \\ nothing changes \\ dte decompile write the mission as Luau source + \\ dte build compile mission source into a mission file \\ ; @@ -49,6 +52,7 @@ pub const Command = union(enum) { } pub fn run(command: Command, ctx: Context) !void { + if (command == .build) return build(ctx, command.build.source, command.build.mission); const path = switch (command) { inline else => |operands| operands.mission, }; @@ -69,10 +73,25 @@ pub const Command = union(enum) { .script => try script(ctx, mission, models, .starlancer), .check => try check(ctx, mission), .decompile => try decompile(ctx, mission, path), + .build => unreachable, } } }; +/// Compiles the mission source at `source` into the mission file `mission` +/// ([Mission source](../../../docs/formats/mission-source.md)). +fn build(ctx: Context, source: []const u8, mission: []const u8) !void { + var failure: dte.source.compile.Failure = undefined; + const bytes = source_parser.build(ctx.arena, try ctx.readInput(source), &failure) catch |err| switch (err) { + error.Invalid => { + try ctx.stdout.print("{s}:{f}\n", .{ source, failure }); + return err; + }, + else => return err, + }; + try Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = mission, .data = bytes }); +} + /// 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 { @@ -371,14 +390,18 @@ test Command { try std.testing.expectError(error.Usage, Command.parse(&.{"script"})); try std.testing.expectError(error.Usage, Command.parse(&.{ "script", "M01.DTE", "extra" })); try std.testing.expectError(error.Usage, Command.parse(&.{ "disassemble", "M01.DTE" })); + const build_command = (try Command.parse(&.{ "build", "patrol.mission.luau", "patrol.dte" })).build; + try std.testing.expectEqualStrings("patrol.mission.luau", build_command.source); + try std.testing.expectEqualStrings("patrol.dte", build_command.mission); } /// Rewrites `mission` four ways and fails if anything changes: with its sections' whole rooms, /// byte for byte, if the file is laid out like the template; from its records alone, record for /// record; each routine of its script, reassembled from its disassembly, byte for byte except the /// block's padding; and each routine read as statements and lowered again, byte for byte except -/// the bytes no instruction reaches (`sameReached`). Then it writes the mission as source, and -/// fails if Luau's parser finds an error in it or a construct mission source doesn't use. +/// the bytes no instruction reaches (`sameReached`). Then it writes the mission as source, fails if +/// Luau's parser finds an error in it or a construct mission source doesn't use, and builds it +/// again, which must give the same mission (`dte.same`), one the engine binds. fn check(ctx: Context, mission: dte.Mission) !void { const gpa = ctx.arena; const out = ctx.stdout; @@ -449,6 +472,19 @@ fn check(ctx: Context, mission: dte.Mission) !void { else => {}, }; try out.writeAll("source: written, and Luau's parser reads it without an error\n"); + + var failure: dte.source.compile.Failure = undefined; + const built = dte.source.compile.compile(gpa, tree, &failure) catch |err| switch (err) { + error.Invalid => return fail(out, try std.fmt.allocPrint(gpa, "source, at {f}", .{failure})), + else => return err, + }; + if (try dte.same.difference(gpa, mission, try .parse(built))) |difference| { + return fail(out, try std.fmt.allocPrint(gpa, "built again from its source: {s}", .{difference})); + } + // And the engine binds it, as starting the mission does. + var bound: openreliant.engine.game.mission.Mission = try .bind(gpa, built); + bound.deinit(); + try out.writeAll("round trip: built again from its source, it is the same mission, and it binds\n"); } /// Whether the routine `again` is `original` where it counts: the same length, the same From 6c67dc41895e96643bf5fbaa14873eb2aa541b71 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 05:31:46 +0200 Subject: [PATCH 2/4] refactor: one walk of a squad's members and of a formation's points The format, the engine and the decompiler each walked a squad's members and a formation's points. The walks now live once in dte.zig (SquadMember.run, FormationPoint.Walk), which the binder and the formation orders use too. The formation walk is OpenReliant's fix of the original's, so its description says so. Mission 0 and the compiler share one string pool builder (dte.write.Strings), the executor looks a command up by name at run time next to its comptime lookup, and the decompiler writes fields and lists through one table writer. The compiler's messages take their limits from the constants they describe, such as the assembler's longest text, which is 253 bytes, not 254. The test mission now covers the layouts of a line that doesn't fit. --- src/engine/game/ai/formations.zig | 44 ++++--------- src/engine/game/executor.zig | 18 ++++++ src/engine/game/mission/bind.zig | 8 +-- src/formats/dte.zig | 84 ++++++++++++++++++++----- src/formats/dte/assemble.zig | 12 ++++ src/formats/dte/source/compile.zig | 46 +++++++------- src/formats/dte/source/compile/code.zig | 16 +---- src/formats/dte/source/write.zig | 44 ++++++------- src/formats/dte/write.zig | 42 +++++++++++++ src/openreliant/mission0.zig | 18 ++---- src/source_parser.zig | 17 ++++- 11 files changed, 218 insertions(+), 131 deletions(-) diff --git a/src/engine/game/ai/formations.zig b/src/engine/game/ai/formations.zig index 3c0b3dc6..e500db13 100644 --- a/src/engine/game/ai/formations.zig +++ b/src/engine/game/ai/formations.zig @@ -723,33 +723,13 @@ fn pointOffset(bound: *const bind.Mission, point: u16) ?Vector { return if (point < all.len) all[point].offset else null; } -/// The points of the formation of point `point`: those of the mission's from the formation's first -/// that name it, its points following one another from there. -const FormationPoints = struct { - all: []align(1) const dte.FormationPoint, - formation: u16, - at: usize, - - /// Those of point `point`'s formation, where the mission has the point and the formation. - fn of(bound: *const bind.Mission, point: u16) ?FormationPoints { - const all = bound.file.formationPoints() catch return null; - const formations = bound.file.formations() catch return null; - if (point >= all.len) return null; - const formation = all[point].formation; - if (formation >= formations.len) return null; - return .{ .all = all, .formation = formation, .at = formations[formation].first_point }; - } - - /// The next point, by its index. - fn next(points: *FormationPoints) ?u16 { - while (points.at < points.all.len) { - const at = points.at; - points.at += 1; - if (points.all[at].formation == points.formation) return @intCast(at); - } - return null; - } -}; +/// The points of the formation of point `point` (`dte.FormationPoint.Walk`), where the mission has +/// the point and the formation. +fn pointsOfFormationOf(bound: *const bind.Mission, point: u16) ?dte.FormationPoint.Walk { + const all = bound.file.formationPoints() catch return null; + if (point >= all.len) return null; + return bound.file.formationWalk(all[point].formation) catch null; +} /// `formation_leader` (`0x00404230`): the ship that leads the formation of point `point` for a ship /// of flight group `group`: the first of the mission's ships of that group, or of none for @@ -760,11 +740,11 @@ const FormationPoints = struct { /// a later formation's point nearer its origin takes the lead, which no ship of the group stands /// at; OpenReliant looks at the formation's own points. fn leaderOf(bound: *const bind.Mission, group: u8, point: u16) ?u16 { - var points = FormationPoints.of(bound, point) orelse return null; + var points = pointsOfFormationOf(bound, point) orelse return null; var lead: ?u16 = null; var nearest: f32 = 0; while (points.next()) |at| { - const from_origin = math.length(points.all[at].offset); + const from_origin = math.length(points.points[at].offset); if (lead == null or from_origin < nearest) { lead = at; nearest = from_origin; @@ -784,12 +764,12 @@ fn leaderOf(bound: *const bind.Mission, group: u8, point: u16) ?u16 { /// **Fix:** the game takes in the points from the formation's first to the mission's last, as /// `leaderOf` looks at them; OpenReliant takes in the formation's own. fn formationSize(bound: *const bind.Mission, point: u16) f32 { - var points = FormationPoints.of(bound, point) orelse return 0; + var points = pointsOfFormationOf(bound, point) orelse return 0; const first = points.next() orelse return 0; - var least: Vector = points.all[first].offset; + var least: Vector = points.points[first].offset; var most = least; while (points.next()) |at| { - const offset: Vector = points.all[at].offset; + const offset: Vector = points.points[at].offset; least = @min(least, offset); most = @max(most, offset); } diff --git a/src/engine/game/executor.zig b/src/engine/game/executor.zig index 195d53d9..954f96c6 100644 --- a/src/engine/game/executor.zig +++ b/src/engine/game/executor.zig @@ -101,6 +101,19 @@ pub fn commandIndex(comptime name: []const u8) u8 { } else @compileError("the Executor has no command " ++ name); } +/// The catalogue's number of the command `name`, where it has one, for a name read at run time, +/// such as one in mission source. +pub fn commandNamed(name: []const u8) ?u8 { + return command_numbers.get(name); +} + +const command_numbers: std.StaticStringMap(u8) = .initComptime(pairs: { + @setEvalBranchQuota(100_000); + var list: [commands.table.len]struct { []const u8, u8 } = undefined; + for (&list, commands.table, 0..) |*pair, command, index| pair.* = .{ command.name, index }; + break :pairs list; +}); + /// A command of the catalogue, by its number, under the name scripts know it by: its name in snake /// case, so that `SetAI` is `set_ai` (`snakeCase`). pub const MissionCommand = @Enum(u8, .exhaustive, &command_names, &std.simd.iota(u8, command_names.len)); @@ -1548,6 +1561,11 @@ test commandIndex { try std.testing.expectEqualStrings("Wait", commands.table[commandIndex("Wait")].name); } +test commandNamed { + try std.testing.expectEqual(commandIndex("SetAI"), commandNamed("SetAI").?); + try std.testing.expectEqual(null, commandNamed("set_ai")); +} + test MissionCommand { try std.testing.expectEqual(commandIndex("SetAI"), @backingInt(MissionCommand.set_ai)); try std.testing.expectEqualStrings("set_environment_fx_nebula", @tagName(@as(MissionCommand, @fromBackingInt(commandIndex("SetEnvironmentFXNebula"))))); diff --git a/src/engine/game/mission/bind.zig b/src/engine/game/mission/bind.zig index 11daebaf..4b4a5617 100644 --- a/src/engine/game/mission/bind.zig +++ b/src/engine/game/mission/bind.zig @@ -394,7 +394,7 @@ pub const Mission = struct { /// members, or where the file's members cannot be read. pub fn squadMembersFrom(mission: *const Mission, squad: u16, first: usize) SquadMembers { const members = mission.file.records(dte.SquadMember, .squad_members) catch &.{}; - return .{ .mission = mission, .squad = squad, .members = members[@min(first, members.len)..] }; + return .{ .mission = mission, .squad = squad, .members = dte.SquadMember.run(members, squad, first) }; } /// A squad's members in turn, from its first while they are its own, each as the record it @@ -403,7 +403,7 @@ pub const Mission = struct { pub const SquadMembers = struct { mission: *const Mission, squad: u16, - /// The squads' members from the next one on. + /// The squad's members still to walk. members: []align(1) const dte.SquadMember, pub const Member = union(enum) { @@ -419,10 +419,6 @@ pub const Mission = struct { while (each.members.len > 0) { const member = each.members[0]; each.members = each.members[1..]; - if (member.squad != each.squad) { - each.members = &.{}; - return null; - } const record = each.mission.recordOf(member.object_id) orelse continue; return switch (record) { .ship => |index| .{ .ship = .{ .index = index, .component = member.part() } }, diff --git a/src/formats/dte.zig b/src/formats/dte.zig index e4d807da..7753c786 100644 --- a/src/formats/dte.zig +++ b/src/formats/dte.zig @@ -1042,6 +1042,15 @@ pub const SquadMember = extern struct { ._unknown_09 = .{ 0x87, 0x1E, 0x0D }, }; + /// The members of squad `squad` among `members`, from member `first` on while they are its + /// own, as the game walks them (`squad_walk`, `0x00401D80`): none from past the members. + pub fn run(members: []align(1) const SquadMember, squad: usize, first: usize) []align(1) const SquadMember { + const start = @min(first, members.len); + var end = start; + while (end < members.len and members[end].squad == squad) end += 1; + return members[start..end]; + } + /// The component it names, or null for the whole object (`Trigger.whole_object`). pub fn part(member: SquadMember) ?u8 { return if (member.component == Trigger.whole_object) null else member.component; @@ -1091,6 +1100,23 @@ pub const FormationPoint = extern struct { /// it. pub const blank: FormationPoint = .{ .formation = 0, .formation_tag = .formation, ._unknown_03 = 0xFF, .offset = @splat(0) }; + /// A formation's points, by their indices: from the formation's first point on, those that + /// name it, as OpenReliant's formation orders take them (`ai.formations`). + pub const Walk = struct { + points: []align(1) const FormationPoint, + formation: usize, + at: usize, + + pub fn next(walk: *Walk) ?u16 { + while (walk.at < walk.points.len) { + const at = walk.at; + walk.at += 1; + if (walk.points[at].formation == walk.formation) return @intCast(at); + } + return null; + } + }; + comptime { assert(@offsetOf(FormationPoint, "offset") == 0x04); assert(@sizeOf(FormationPoint) == 0x10); @@ -1869,29 +1895,28 @@ pub const Mission = struct { return mission.records(FormationPoint, .formation_points); } - /// The members of squad `squad`, as the game walks them (`squad_walk`, `0x00401D80`): from the - /// squad's first on, while they are its own. None for a squad the mission lacks. + /// The members of squad `squad` from its first (`SquadMember.run`). None for a squad the + /// mission lacks. pub fn membersOf(mission: Mission, squad: usize) Error![]align(1) const SquadMember { - const all = try mission.squadMembers(); const list = try mission.squads(); if (squad >= list.len) return &.{}; - const first = @min(list[squad].first_member, all.len); - var end = first; - while (end < all.len and all[end].squad == squad) end += 1; - return all[first..end]; + return SquadMember.run(try mission.squadMembers(), squad, list[squad].first_member); } - /// The points of formation `formation`, by their indices, as the game finds them - /// (`formation_leader`, `0x00404230`): from the formation's first point on, those that are its - /// own. None for a formation the mission lacks. - pub fn pointsOf(mission: Mission, allocator: Allocator, formation: usize) (Error || Allocator.Error)![]u16 { - const all = try mission.formationPoints(); + /// The points of formation `formation` (`FormationPoint.Walk`), where the mission has the + /// formation. + pub fn formationWalk(mission: Mission, formation: usize) Error!?FormationPoint.Walk { const list = try mission.formations(); - if (formation >= list.len) return &.{}; + if (formation >= list.len) return null; + return .{ .points = try mission.formationPoints(), .formation = formation, .at = list[formation].first_point }; + } + + /// The points of formation `formation`, by their indices (`FormationPoint.Walk`). None for a + /// formation the mission lacks. + pub fn pointsOf(mission: Mission, allocator: Allocator, formation: usize) (Error || Allocator.Error)![]u16 { + var walk = try mission.formationWalk(formation) orelse return &.{}; var found: std.ArrayList(u16) = .empty; - for (all[@min(list[formation].first_point, all.len)..], list[formation].first_point..) |point, index| { - if (point.formation == formation) try found.append(allocator, @intCast(index)); - } + while (walk.next()) |point| try found.append(allocator, point); return found.toOwnedSlice(allocator); } @@ -2370,6 +2395,33 @@ test "Object.ToolFlags" { try std.testing.expectEqual(Object.ToolFlags{ .curve_point = true }, @as(Object.ToolFlags, @bitCast(@as(u32, 8)))); } +test "SquadMember.run" { + const whole = Trigger.whole_object; + const members = [_]SquadMember{ + testing.squadMember(1, 0, whole), + testing.squadMember(2, 0, whole), + testing.squadMember(3, 1, whole), + testing.squadMember(4, 0, whole), + }; + // From the first, while they are the squad's own: squad 0's last member, past squad 1's, is + // none of the walk's. + try std.testing.expectEqual(2, SquadMember.run(&members, 0, 0).len); + try std.testing.expectEqual(1, SquadMember.run(&members, 1, 2).len); + try std.testing.expectEqual(0, SquadMember.run(&members, 0, 2).len); + try std.testing.expectEqual(0, SquadMember.run(&members, 0, Squad.no_member).len); +} + +test "FormationPoint.Walk" { + var points: [4]FormationPoint = @splat(.blank); + points[0].formation = 1; + points[2].formation = 1; + // From the formation's first point on, those that name it, past another's between them. + var walk: FormationPoint.Walk = .{ .points = &points, .formation = 1, .at = 0 }; + try std.testing.expectEqual(0, walk.next().?); + try std.testing.expectEqual(2, walk.next().?); + try std.testing.expectEqual(null, walk.next()); +} + test "FlightGroup.Wing" { var buffer: [16]u8 = undefined; try std.testing.expectEqualStrings("player", try std.mem.print(&buffer, "{f}", .{FlightGroup.Wing.player})); diff --git a/src/formats/dte/assemble.zig b/src/formats/dte/assemble.zig index af000701..c85d4d29 100644 --- a/src/formats/dte/assemble.zig +++ b/src/formats/dte/assemble.zig @@ -126,6 +126,10 @@ pub const Routine = struct { try routine.pushText(.push_string, text); } + /// The longest text `pushText` carries: the length byte before it counts itself, the text and + /// the NUL after it. + pub const longest_text = std.math.maxInt(u8) - 2; + /// `text`, NUL-terminated, as the inline data of `opcode`: `push_string`, or `push_string_alt`, /// which runs the same handler. pub fn pushText(routine: *Routine, opcode: Opcode, text: []const u8) Error!void { @@ -264,6 +268,14 @@ test Routine { try std.testing.expectError(error.BackwardBranch, back.finish()); } +test "Routine.longest_text" { + var routine: Routine = .init(std.testing.allocator); + defer routine.deinit(); + const text: [Routine.longest_text + 1]u8 = @splat('a'); + try routine.pushText(.push_string, text[0..Routine.longest_text]); + try std.testing.expectError(error.InlineTooLong, routine.pushText(.push_string, &text)); +} + test emit { const gpa = std.testing.allocator; // A routine with every kind of instruction, written again from its disassembly. diff --git a/src/formats/dte/source/compile.zig b/src/formats/dte/source/compile.zig index 8b327b94..0fd74fd6 100644 --- a/src/formats/dte/source/compile.zig +++ b/src/formats/dte/source/compile.zig @@ -9,9 +9,11 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const dte = @import("../../dte.zig"); +const assemble = @import("../assemble.zig"); const statements = @import("../statements.zig"); const bind = @import("../../../engine/game/mission/bind.zig"); const commands = @import("../../../engine/game/executor/commands.zig"); +const executor = @import("../../../engine/game/executor.zig"); const conditions = @import("../../../engine/vm/conditions.zig"); const names = @import("names.zig"); const script = @import("script.zig"); @@ -235,7 +237,7 @@ pub const Compiler = struct { .handler => .handler, else => |other| return c.fail(value, "`{t}(...)` declares no record", .{other}), }; - if (names.isReserved(name) or code.isCommand(name)) return c.fail(target, "`{s}` is a name mission source keeps for itself", .{name}); + if (names.isReserved(name) or executor.commandNamed(name) != null) return c.fail(target, "`{s}` is a name mission source keeps for itself", .{name}); const records = c.declarations.getPtr(kind); const slot = try c.names.getOrPut(c.arena, name); if (slot.found_existing) return c.fail(target, "`{s}` is declared already, at {f}", .{ name, c.tree.at(slot.value_ptr.call) }); @@ -285,7 +287,7 @@ pub const Compiler = struct { pub fn word(c: *Compiler, node: Index) Error!u32 { const value = try c.number(node); if (@trunc(value) != value or value < std.math.minInt(i32) or value > std.math.maxInt(u32)) { - return c.fail(node, "a whole number from -2147483648 to 4294967295 goes here", .{}); + return c.fail(node, "a whole number from {d} to {d} goes here", .{ std.math.minInt(i32), std.math.maxInt(u32) }); } return if (value < 0) @bitCast(@as(i32, @intFromFloat(value))) else @intFromFloat(value); } @@ -416,7 +418,7 @@ pub const Compiler = struct { const objects = try c.buildObjects(ships, try c.slices(triggers)); var sections: dte.write.Sections = @splat(.{}); - try c.section(§ions, .strings, pool.bytes.items.len, pool.bytes.items, "names"); + try c.section(§ions, .strings, pool.strings.bytes.items.len, pool.strings.bytes.items, "bytes of names"); try c.section(§ions, .globals, globals.len, std.mem.sliceAsBytes(globals), "globals"); try c.section(§ions, .ships, ships.len, std.mem.sliceAsBytes(ships), "ships"); try c.section(§ions, .flight_groups, groups.len, std.mem.sliceAsBytes(groups), "flight groups"); @@ -561,12 +563,12 @@ pub const Compiler = struct { }; if (details.get("group")) |node| { const group = try c.reference(node, .flight_group); - if (group >= dte.Ship.no_flight_group) return c.fail(node, "a ship's flight group is one of the first 255", .{}); + if (group >= dte.Ship.no_flight_group) return c.fail(node, "a ship's flight group is one of the first {d}", .{dte.Ship.no_flight_group}); record.flight_group = @intCast(group); } if (details.get("pilot")) |node| { const pilot = try c.named(node, .pilot); - if (pilot >= dte.Ship.no_pilot) return c.fail(node, "a pilot is one of the first 255 records of pilotstats.bin", .{}); + if (pilot >= dte.Ship.no_pilot) return c.fail(node, "a pilot is one of the first {d} records of pilotstats.bin", .{dte.Ship.no_pilot}); record.pilot = @intCast(pilot); } if (details.get("position")) |node| { @@ -585,7 +587,7 @@ pub const Compiler = struct { const gate = launch.get("gate") orelse return c.fail(node, "a launch gives the `gate` the ship launches through", .{}); record.launch_from = try c.narrow(u16, from, try c.named(from, .ship_type), "ship type"); record.launch_gate = try c.whole(u8, gate); - if (record.launch_gate == dte.Ship.no_launch) return c.fail(gate, "a gate is from 0 to 254", .{}); + if (record.launch_gate == dte.Ship.no_launch) return c.fail(gate, "a gate is from 0 to {d}", .{dte.Ship.no_launch - 1}); } if (details.get("formation_point")) |node| { record.formation_point = try c.formationPoint(node, formations); @@ -617,7 +619,7 @@ pub const Compiler = struct { .call => |call| if (c.wordOf(node) == .formation_point_index) { if (call.arguments.len != 1) return c.fail(node, "`formation_point_index(...)` takes the point's index", .{}); const point = try c.whole(u16, call.arguments[0]); - if (point == dte.Ship.no_formation_point) return c.fail(node, "a formation point's index is from 0 to 65534", .{}); + if (point == dte.Ship.no_formation_point) return c.fail(node, "a formation point's index is from 0 to {d}", .{dte.Ship.no_formation_point - 1}); return point; }, else => {}, @@ -653,7 +655,7 @@ pub const Compiler = struct { if (parts.len != 2) return c.fail(item, "`component(...)` takes the object and the component's index", .{}); object = parts[0]; member.component = try c.whole(u8, parts[1]); - if (member.component == dte.Trigger.whole_object) return c.fail(parts[1], "a component's index is from 0 to 254", .{}); + if (member.component == dte.Trigger.whole_object) return c.fail(parts[1], "a component's index is from 0 to {d}", .{dte.Trigger.whole_object - 1}); } member.object_id = try c.narrow(u16, item, try c.objectOf(object), "object"); try members.append(c.arena, member); @@ -757,7 +759,7 @@ pub const Compiler = struct { }; if (details.get("component")) |component| { record.qualifier = try c.whole(u8, component); - if (record.qualifier == dte.Trigger.whole_object) return c.fail(component, "a component's index is from 0 to 254", .{}); + if (record.qualifier == dte.Trigger.whole_object) return c.fail(component, "a component's index is from 0 to {d}", .{dte.Trigger.whole_object - 1}); } if (details.get("deferred")) |deferred| record.deferred = @intFromBool(try c.boolean(deferred)); for (event, 0..) |name, slot| { @@ -809,7 +811,7 @@ pub const Compiler = struct { const slot = &found[object]; if (slot.*) |*slice| { if (@as(usize, slice.first) + slice.count != index) return c.fail(call, "an object's triggers follow one another", .{}); - if (slice.count == std.math.maxInt(u8)) return c.fail(call, "an object has 255 triggers at most", .{}); + if (slice.count == std.math.maxInt(u8)) return c.fail(call, "an object has {d} triggers at most", .{std.math.maxInt(u8)}); slice.count += 1; } else { slot.* = .{ .first = try c.narrow(u16, call, @intCast(index), "trigger"), .count = 1 }; @@ -856,14 +858,14 @@ pub const Compiler = struct { const made = statements.bytes(c.arena, body, context) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, error.BranchTooFar => c.fail(node, "an `if` or a random choice here holds more code than a branch reaches", .{}), - error.InlineTooLong => c.fail(node, "a text here is longer than 254 bytes", .{}), + error.InlineTooLong => c.fail(node, "a text here is longer than {d} bytes", .{assemble.Routine.longest_text}), error.TooManyConstants => c.fail(node, "this code pushes more numbers than a routine holds", .{}), error.BlockTooLong => c.fail(node, "this code is longer than a routine holds", .{}), - error.WrongOperands => c.fail(node, "a random choice here has more than 255 arms", .{}), + error.WrongOperands => c.fail(node, "a random choice here has more than {d} arms", .{std.math.maxInt(u8)}), error.BackwardBranch, error.UnplacedLabel => unreachable, }; const start = bytes.items.len / 2; - if (start + made.len / 2 >= dte.Part.no_block) return c.fail(node, "the script is longer than a mission's 65534 halfwords", .{}); + if (start + made.len / 2 >= dte.Part.no_block) return c.fail(node, "the script is longer than a mission's {d} halfwords", .{dte.Part.no_block - 1}); try bytes.appendSlice(c.arena, made); return @intCast(start); } @@ -919,19 +921,15 @@ fn contains(list: []const []const u8, name: []const u8) bool { return false; } -/// The string pool: each name once, with a NUL after it, by the byte it starts at. +/// The string pool (`dte.write.Strings`), which the source's display names go in. const Pool = struct { - bytes: std.ArrayList(u8) = .empty, - offsets: std.StringHashMapUnmanaged(u16) = .empty, + strings: dte.write.Strings = .{}, fn add(pool: *Pool, c: *Compiler, node: Index) Error!u16 { - const text = try c.string(node); - if (std.mem.findScalar(u8, text, 0) != null) return c.fail(node, "a name can't hold a NUL", .{}); - if (pool.offsets.get(text)) |offset| return offset; - const offset = std.math.cast(u16, pool.bytes.items.len) orelse return c.fail(node, "the names fill the string pool, which counts 65535 bytes", .{}); - try pool.bytes.appendSlice(c.arena, text); - try pool.bytes.append(c.arena, 0); - try pool.offsets.put(c.arena, text, offset); - return offset; + return pool.strings.add(c.arena, try c.string(node)) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.HoldsNul => c.fail(node, "a name can't hold a NUL", .{}), + error.Full => c.fail(node, "the names fill the string pool, which counts {d} bytes", .{std.math.maxInt(u16)}), + }; } }; diff --git a/src/formats/dte/source/compile/code.zig b/src/formats/dte/source/compile/code.zig index 20c63910..87c94ec4 100644 --- a/src/formats/dte/source/compile/code.zig +++ b/src/formats/dte/source/compile/code.zig @@ -10,6 +10,7 @@ const statements = @import("../../statements.zig"); const Statement = statements.Statement; const Expression = statements.Expression; const commands = @import("../../../../engine/game/executor/commands.zig"); +const executor = @import("../../../../engine/game/executor.zig"); const conditions = @import("../../../../engine/vm/conditions.zig"); const names = @import("../names.zig"); const Word = names.Word; @@ -32,19 +33,6 @@ pub fn function(c: *Compiler, node: Index, parameters: Parameters) Error![]const return reader.body(found.body); } -/// Whether `name` is a command's, which no record can take. -pub fn isCommand(name: []const u8) bool { - return command_numbers.has(name); -} - -/// The commands of the catalogue, by their names. -const command_numbers: std.StaticStringMap(u8) = .initComptime(pairs: { - @setEvalBranchQuota(100_000); - var list: [commands.table.len]struct { []const u8, u8 } = undefined; - for (&list, commands.table, 0..) |*pair, command, index| pair.* = .{ command.name, index }; - break :pairs list; -}); - const Reader = struct { c: *Compiler, /// The function's parameters, their `name` nodes. @@ -155,7 +143,7 @@ const Reader = struct { const call = r.get(node).call; switch (r.get(call.function)) { .global_name => |name| { - if (command_numbers.get(name)) |index| return .{ .command = .{ .index = index, .arguments = try r.commandArguments(index, call.arguments) } }; + if (executor.commandNamed(name)) |index| return .{ .command = .{ .index = index, .arguments = try r.commandArguments(index, call.arguments) } }; if (std.meta.stringToEnum(Word, name)) |word| switch (word) { .command => { if (call.arguments.len == 0) return r.fail(node, "`command(...)` takes the command's number, then its arguments", .{}); diff --git a/src/formats/dte/source/write.zig b/src/formats/dte/source/write.zig index cf454640..2387958a 100644 --- a/src/formats/dte/source/write.zig +++ b/src/formats/dte/source/write.zig @@ -44,7 +44,7 @@ const Source = struct { routines: []const dte.Routine, record_names: script.Names, formation_names: []const []const u8, - /// Each formation's points, by their indices, as the game finds them (`dte.Mission.pointsOf`). + /// Each formation's points, by their indices (`dte.Mission.pointsOf`). formation_points: []const []const u16, /// The names of each condition's event values, by condition. events: [conditions.table.len][]const []const u8, @@ -450,17 +450,25 @@ const Source = struct { /// `, { a, b })` and a new line: a declaration's list and its end, after `column` characters of /// its line, or with an item to a line where it doesn't fit there. `, {})` for an empty one. fn writeList(w: *Writer, items: []const []const u8, column: usize) Writer.Error!void { - if (items.len == 0) return w.writeAll(", {})\n"); - var length = column + ", { })".len; + try w.writeAll(", "); + try writeTable(w, items, column + ", ".len, ")".len); + try w.writeAll(")\n"); +} + +/// `items` as a table, `{ a, b }`, starting `column` characters into its line, with `after` +/// characters after it on the line: on that line where it fits, or with an item to a line. +fn writeTable(w: *Writer, items: []const []const u8, column: usize, after: usize) Writer.Error!void { + if (items.len == 0) return w.writeAll("{}"); + var length = column + "{ }".len + after; for (items, 0..) |item, index| length += item.len + @as(usize, if (index > 0) ", ".len else 0); if (length > line_length) { - try w.writeAll(", {\n"); + try w.writeAll("{\n"); for (items) |item| try w.print(" {s},\n", .{item}); - return w.writeAll("})\n"); + return w.writeAll("}"); } - try w.writeAll(", { "); + try w.writeAll("{ "); for (items, 0..) |item, index| try w.print("{s}{s}", .{ if (index > 0) ", " else "", item }); - try w.writeAll(" })\n"); + try w.writeAll(" }"); } /// A value the game names, by its name as a string, or its number where it has none. @@ -511,32 +519,20 @@ fn vector(value: [3]f32) Vector { /// they don't. const Fields = struct { arena: Allocator, - list: std.ArrayList([2][]const u8) = .empty, + /// Each field as it is written: `name = value`. + list: std.ArrayList([]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) }); + try fields.list.append(fields.arena, try std.fmt.allocPrint(fields.arena, "{s} = " ++ format, .{key} ++ args)); } - /// Writes the fields as a table, starting `column` characters into its line, with `after` - /// characters after it on the line. + /// Writes the fields as a table (`writeTable`). fn write(fields: Fields, w: *Writer, column: usize, after: usize) Writer.Error!void { - if (fields.list.items.len == 0) return w.writeAll("{}"); - var length = column + "{ }".len + after; - 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("}"); + try writeTable(w, fields.list.items, column, after); } }; diff --git a/src/formats/dte/write.zig b/src/formats/dte/write.zig index 987389bc..140d3f82 100644 --- a/src/formats/dte/write.zig +++ b/src/formats/dte/write.zig @@ -60,6 +60,36 @@ pub const template = struct { } }; +/// A string pool as a mission file holds one (`Section.strings`): each text once, with a NUL after +/// it, named by the byte it starts at. +pub const Strings = struct { + bytes: std.ArrayList(u8) = .empty, + offsets: std.StringHashMapUnmanaged(u16) = .empty, + + pub const AddError = Allocator.Error || error{ + /// The text holds a NUL, which would end it early. + HoldsNul, + /// The pool is past what a name's offset counts. + Full, + }; + + /// Where `text` starts in the pool, added where it isn't there yet. + pub fn add(strings: *Strings, gpa: Allocator, text: []const u8) AddError!u16 { + if (std.mem.findScalar(u8, text, 0) != null) return error.HoldsNul; + if (strings.offsets.get(text)) |offset| return offset; + const offset = std.math.cast(u16, strings.bytes.items.len) orelse return error.Full; + try strings.bytes.appendSlice(gpa, text); + try strings.bytes.append(gpa, 0); + try strings.offsets.put(gpa, text, offset); + return offset; + } + + pub fn deinit(strings: *Strings, gpa: Allocator) void { + strings.bytes.deinit(gpa); + strings.offsets.deinit(gpa); + } +}; + /// What a section holds: its directory count and its bytes. pub const Contents = struct { count: u16 = 0, @@ -199,6 +229,18 @@ test write { try std.testing.expectError(error.SectionTooLarge, write(gpa, §ions, .{})); } +test Strings { + const gpa = std.testing.allocator; + var strings: Strings = .{}; + defer strings.deinit(gpa); + try std.testing.expectEqual(0, try strings.add(gpa, "Player")); + try std.testing.expectEqual(7, try strings.add(gpa, "(FG)Alpha")); + // A text already in the pool is named where it is. + try std.testing.expectEqual(0, try strings.add(gpa, "Player")); + try std.testing.expectEqualStrings("Player\x00(FG)Alpha\x00", strings.bytes.items); + try std.testing.expectError(error.HoldsNul, strings.add(gpa, "a\x00b")); +} + test rooms { const gpa = std.testing.allocator; const empty: Sections = @splat(.{}); diff --git a/src/openreliant/mission0.zig b/src/openreliant/mission0.zig index 0a7aaf7b..39dd4951 100644 --- a/src/openreliant/mission0.zig +++ b/src/openreliant/mission0.zig @@ -180,18 +180,18 @@ pub fn write(gpa: Allocator) ![]u8 { const arena = arena_state.allocator(); const placed = ships ++ rocks(); - var strings: std.ArrayList(u8) = .empty; - const part_name = try addString(arena, &strings, "(F)Start"); + var strings: dte.write.Strings = .{}; + const part_name = try strings.add(arena, "(F)Start"); const records = try arena.alloc(dte.Ship, placed.len); - for (records, placed, 0..) |*record, ship, index| record.* = shipRecord(ship, @intCast(index), try addString(arena, &strings, ship.name)); + for (records, placed, 0..) |*record, ship, index| record.* = shipRecord(ship, @intCast(index), try strings.add(arena, ship.name)); const groups = std.enums.values(Group); const group_records = try arena.alloc(dte.FlightGroup, groups.len); for (group_records, groups) |*record, group| { record.* = .blank; record.object_id = @intCast(placed.len + @backingInt(group)); - record.name = try addString(arena, &strings, group.label()); + record.name = try strings.add(arena, group.label()); record.wing = group.wing(); } // Each group's count of ships and the first one's place, as binding the mission works them out. @@ -216,7 +216,7 @@ pub fn write(gpa: Allocator) ![]u8 { var sections: dte.write.Sections = @splat(.{}); const section = dte.write.set; - section(§ions, .strings, strings.items.len, strings.items); + section(§ions, .strings, strings.bytes.items.len, strings.bytes.items); section(§ions, .ships, records.len, std.mem.sliceAsBytes(records)); section(§ions, .flight_groups, group_records.len, std.mem.sliceAsBytes(group_records)); section(§ions, .script, code.len / @sizeOf(u16), code); @@ -254,14 +254,6 @@ fn shipRecord(ship: Placed, id: u32, name_at: u16) dte.Ship { return record; } -/// Adds `text` to the string pool, NUL-terminated, and gives the byte offset it starts at. -fn addString(gpa: Allocator, strings: *std.ArrayList(u8), text: []const u8) !u16 { - const at: u16 = @intCast(strings.items.len); - try strings.appendSlice(gpa, text); - try strings.append(gpa, 0); - return at; -} - /// The order the start part makes the flight groups in: the Reliant's first, which the wing /// launches from as it is made. const made = [_]Group{ .reliant, .alpha, .badanov, .sabres, .rocks }; diff --git a/src/source_parser.zig b/src/source_parser.zig index e5d76fdd..fe797856 100644 --- a/src/source_parser.zig +++ b/src/source_parser.zig @@ -194,8 +194,8 @@ test "the parser's errors and their places" { try std.testing.expectEqual(0, empty.get(empty.root).block.len); } -/// A mission with every kind of record and every form of the script, as `sltool dte decompile` -/// writes it. +/// A mission with every kind of record, every form of the script and each way a line that doesn't +/// fit is broken, as `sltool dte decompile` writes it. const patrol = \\mission({ name = "Patrol" }) \\ @@ -236,11 +236,20 @@ const patrol = \\halfway = point("Halfway", { marker = { curve = curve_0, at = 0.5 } }) \\start = curve_point("Start") \\end_ship = curve_point("End", { position = { 1000, 0, 0 } }) + \\escort_carrier_of_the_convoy = ship("Escort carrier of the convoy", { type = "reliant" }) \\ \\-- Squads \\ \\targets = squad("(AI)targets", { sabre_1, component(sabre_2, 2), sabres }) \\everyone = squad("(AI)everyone", { targets, player }) + \\convoy_and_escorts = squad("(AI)convoy and escorts", { + \\ player, + \\ wingman, + \\ sabre_1, + \\ sabre_2, + \\ escort_carrier_of_the_convoy, + \\ targets, + \\}) \\ \\-- Formations \\ @@ -295,6 +304,10 @@ const patrol = \\ return 1 \\end) \\on(nil, "destroyed") + \\on(escort_carrier_of_the_convoy, "shot_at", + \\ function(attacker, shield_damage, hull_damage, victim, weapon_fired) + \\ return 1 + \\ end) \\ \\-- Parts \\ From 1540aa3268b1d15c51ba37aa7a855f33a9f8f8a5 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 05:31:46 +0200 Subject: [PATCH 3/4] feat: name every pilot, the deathmatch markers, two game variables and the environment effects Decompiling the shipped missions left numbers where names belong: 66 markers, most pilots, two game variables and the environment effects. - Every pilot of pilotstats.bin has a name, spelled out from its record, and the pilots past it with faces of their own, after their face films. Pilots 15 and 16 are Ivan and Nicolai Petrov, as the radio's aces are numbered by their pilots; the radio now names its aces by these names. - Types 991 to 994 are the deathmatch maps' markers. The game lists the players' spawn points, type 993, as a multiplayer game starts (multiplayer_list_spawns, now in the Ghidra names). - Variable 6 is Nicolai Petrov's alive flag, which the Dreamcast's mission 12 clears as he dies, and 35 the Red Dragon's, which brings him into mission 26. - SetEnvironmentFX takes its effect by name in mission source. Scripts see the new pilot, ship type and variable names, and make definitions wrote them into the API's docs. --- docs/engine/movies.md | 2 +- docs/engine/script-vm.md | 4 +- docs/formats/mission-source.md | 9 +- docs/guide/openreliant.d.luau | 6 +- docs/guide/reference.md | 6 +- docs/guide/scripting.md | 2 +- ghidra/names/LANCER.EXE.tsv | 1 + src/engine/game/gameflow.zig | 8 +- src/engine/game/gameflow/save.zig | 8 +- src/engine/game/gameobj.zig | 9 ++ src/engine/game/pilots.zig | 168 +++++++++++++++++++++++++- src/engine/game/radio/menu.zig | 52 ++++---- src/engine/game/xtrabits/landing.zig | 4 +- src/engine/vm.zig | 25 ++-- src/formats/dte/source/vocabulary.zig | 21 +++- src/scripting/records/missions.zig | 2 +- 16 files changed, 252 insertions(+), 75 deletions(-) diff --git a/docs/engine/movies.md b/docs/engine/movies.md index d7fe019f..3aba6b13 100644 --- a/docs/engine/movies.md +++ b/docs/engine/movies.md @@ -124,7 +124,7 @@ and plays: | 7 | `new_chapter1_thread1.bik` | `rameses_alive` (18) is other than 1 | | 11 | `new_chapter2_thread1.bik` | `czar_alive` (29) is other than 1 | | 11 | `new_chapter2_thread2.bik` | `krasnaya_alive` (17) is other than 1 | -| 11 | `new_chapter2_thread3.bik` | Variable 6 is other than 1; it then sets `chapter2_thread3_shown` (34) | +| 11 | `new_chapter2_thread3.bik` | `nicolai_petrov_alive` (6) is other than 1; it then sets `chapter2_thread3_shown` (34) | Each attempt at a mission clears variable 34. The function has reports for mission 16 too, `new_chapter3_thread1.bik`, `new_chapter3_thread2.bik` and `new_chapter2_thread3.bik`, which are diff --git a/docs/engine/script-vm.md b/docs/engine/script-vm.md index 2b3e04cd..ec28676a 100644 --- a/docs/engine/script-vm.md +++ b/docs/engine/script-vm.md @@ -118,7 +118,7 @@ own: what the engine does with each is in the last column. | Number | Name | In the shipped campaign | What the engine does with it | |---|---|---|---| | 5 | `mcgann_alive` | Whether McGann lives. Mission 15 clears it as his Phoenix is destroyed; mission 9 reads it | The news report after mission 16 plays once it's clear, but mission 16 ends no chapter, so it never plays | -| 6 | | Mission 27 clears it as it starts. `mission191.dte`, which the campaign never flies, reads it as its Petrov wing's leader alive. **Unknown:** what mission 27 clears it for | The third news report after mission 11 (`new_chapter2_thread3.bik`) plays once it's clear, which it never is then | +| 6 | `nicolai_petrov_alive` | Whether Nicolai Petrov lives. The Dreamcast's mission 12, which the PC game lacks, clears it as its Petrov's Basilisk is destroyed, `mission191.dte`, which the campaign never flies, reads it as its Petrov wing's leader alive, and mission 27 clears it as it starts. **Unverified:** that this Petrov is Nicolai: the missions don't name him, but the game knows two Petrovs, and Ivan's flag is 7 | The third news report after mission 11 (`new_chapter2_thread3.bik`) plays once it's clear, which it never is then | | 7 | `ivan_petrov_alive` | Whether Ivan Petrov lives. Mission 28 clears it as his Basilisk is destroyed | The story's end plays his news report once it's clear | | 8 | `kulov_alive` | Whether Kulov lives. Mission 28 clears it as the Boridin breakaway is destroyed | The story's end plays his news report once it's clear | | 11 | `al_rahan_alive` | Whether Al-Rahan lives, 0 in a new campaign. Mission 7 clears it as he dies, and mission 24 sets it as it starts, before it reads it | | @@ -133,7 +133,7 @@ own: what the engine does with each is in the last column. | 30 | `ghost_alive` | Whether Ghost lives. Mission 1 clears it as Ghost dies; mission 4 has Petrov say a line by it | | | 32 | `reliant_alive` | Whether the Reliant flies. Missions 7, 8 and 18 clear it as she's lost | While it's set, mission 8 ends in a landing on the Reliant rather than the Yamato, and a total failure on the Reliant ends the pilot's career in the transfer off the Reliant | | 34 | `chapter2_thread3_shown` | Never set in the shipped campaign, as the report that sets it never plays | Set as the third news report after mission 11 plays; cleared before each attempt at a mission | -| 35 | | 1 in a new campaign and never cleared; mission 26 reads it. Not kept with the saved game. **Unknown:** what it was meant to hold | | +| 35 | `red_dragon_alive` | Whether the Red Dragon, the Golden Warriors' leader, lives. 1 in a new campaign and not kept with the saved game; mission 26 brings him in while it is set. **Unverified:** that it says whether he lives, since no script clears it, not even mission 26's as he dies | | | 36 | `yamato_alive` | Whether the Yamato lives. Mission 25's second part and mission 27 clear it as she's destroyed | Once it's clear, those missions end without the landing, and a total failure in them ends the pilot's career in the shuttle at Fort Bear | No script names variables 2, 16, 20, 21, 24, 25, 26 and 31: a new campaign sets 16, 20, 21 and 31 to diff --git a/docs/formats/mission-source.md b/docs/formats/mission-source.md index f39f043d..5b3926f7 100644 --- a/docs/formats/mission-source.md +++ b/docs/formats/mission-source.md @@ -231,7 +231,7 @@ Luau's syntax: | 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 | +| 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, `"coalition_fighter"` for a pilot, `"talking"` for a head movement, `"ice_field"` for an environment effect, 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)` | @@ -325,9 +325,10 @@ parameters change nothing, and a string in backquotes without values is text lik `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. A squad's members and a -formation's points are written as the game finds them, from the squad's first member and the -formation's first point. +record's from then on, and renaming the display name doesn't change it. A squad's members are +written as the game walks them, from the squad's first member while they are its own, and a +formation's points as OpenReliant finds them, from the formation's first point on, those that name +it (`dte.FormationPoint.Walk`). Built again, a decompiled mission is the same mission ([`same.zig`](../../src/formats/dte/same.zig)): the same records and code, decoded, with names diff --git a/docs/guide/openreliant.d.luau b/docs/guide/openreliant.d.luau index 53d8d39e..120feb17 100644 --- a/docs/guide/openreliant.d.luau +++ b/docs/guide/openreliant.d.luau @@ -6,9 +6,9 @@ -- Names of values. A value without a name is a number. -type ShipType = "predator" | "naginata" | "grendel" | "crusader" | "coyote" | "mirage" | "tempest" | "patriot" | "wolverine" | "reaper" | "shroud" | "phoenix" | "reliant" | "yamato" | "alsace" | "kestrel" | "falco" | "victorious" | "endeavour" | "mitchell" | "bremen" | "arc_ship" | "ulysses" | "sai" | "nanny" | "galahad" | "hades" | "mule" | "lonestar" | "limpet_car" | "prowler" | "ripper" | "lueneburg" | "mammoth" | "intruder" | "sentry" | "goodwill" | "stork" | "sky_hawk" | "haidar" | "karak" | "salin" | "azan" | "sabre" | "lagg" | "kamov" | "saracen" | "salamander" | "scimitar" | "basilisk" | "kossac" | "yao" | "ramases" | "kozlov" | "morzov" | "badanov" | "pukov" | "xia" | "riza" | "cyclops" | "kurgan" | "sharov" | "gurevich" | "berijev" | "kirov" | "loki" | "kalan_shuttle" | "saladin" | "darkreign" | "stalag" | "antanov" | "kronstadt" | "boridin" | "troop_car" | "torpedo" | "ulysses_back" | "ulysses_fin" | "escape_pod" | "debris" | "debris_1" | "debris_2" | "debris_3" | "debris_4" | "debris_5" | "debris_6" | "debris_7" | "debris_8" | "debris_9" | "crewman" | "crewman_1" | "crewman_2" | "crewman_3" | "russian_torpedo" | "mammoth_back" | "baxter" | "neptune_hi" | "triton_hi" | "uranus_hi" | "saturn_hi" | "titan_hi" | "jupiter_hi" | "europa_hi" | "ganymede_hi" | "io_hi" | "calisto_hi" | "venus_hi" | "earth" | "kronstadt_wreck" | "planet_placeholder_4" | "proto_gate" | "advanced_gate" | "proximity_mine" | "black_box" | "satellite" | "mammoth_wreck_front" | "mammoth_wreck_back" | "proto_gate_panel" | "badanov_wreck_back" | "badanov_wreck_front" | "kurgan_wreck" | "krasnaya" | "asteroid_1" | "asteroid_2" | "asteroid_3" | "asteroid_4" | "asteroid_5" | "asteroid_6" | "asteroid_7" | "research_station" | "latov" | "gurevich_wreck_back" | "training_hoop" | "czar_docked" | "turret_asteroid_1" | "turret_asteroid_2" | "turret_asteroid_3" | "turret_asteroid_4" | "turret_asteroid_5" | "turret_asteroid_6" | "turret_asteroid_7" | "powerup_random" | "dm_beacon_gate" | "dm_beacon" | "pukov_wreck_back" | "other_escape_pod" | "cargo_pod" | "other_cargo_pod" | "grazer_gun_sat" | "archer_missile_sat" | "kafelnikof" | "reliant_wreck_back" | "stalag_doors" | "stalag_duct_cover" | "yakob_shuttle" | "krasny" | "varyag" | "other_ramases" | "fort_carter" | "koenig" | "washington" | "other_mitchell" | "ufelsky" | "kresta" | "krasnaya_left_arm" | "krasnaya_right_arm" | "rogue_base" | "kronstadt_arm" | "boridin_gun_dome" | "boridin_breakaway" | "darkreign_wreck_hat" | "yamato_wreck_back" | "maintenance_droid" | "maintenance_astronaut" | "rogue_base_wreck_top" | "tank" | "training_target" | "zakov" | "shell" | "rock_chunk" | "rock_chunk_tiny_1" | "rock_chunk_tiny_2" | "rock_chunk_tiny_3" | "rock_chunk_tiny_4" | "latov_wreck_1" | "latov_wreck_2" | "czar" | "zakov_wreck_back" | "sharov_wreck_back" | "limpet_pod" | "stalag_wreck_1" | "stalag_wreck_2" | "saladin_wreck" | "gegarin" | "krelov" | "kiev" | "kiev_wreck_back" | "baxter_wreck_top" | "victorious_wreck_front" | "mitchell_wreck_back" | "fort_bear" | "fort_sherman" | "neptune_lo" | "triton_lo" | "uranus_lo" | "saturn_lo" | "titan_lo" | "jupiter_lo" | "europa_lo" | "ganymede_lo" | "io_lo" | "calisto_lo" | "venus_lo" | "yamato_hangar" | "yamato_landing_bay" | "reliant_hangar" | "comms_relay" | "saladin_link" | "varyag_wreck_front" | "churchill" | "bokov" | "bulatov" | "shinnik" | "kovtun" | "late_escape_pod" | "other_late_escape_pod" | "fuel_pod" | "yevstafiy" | "mammoth_gulliver" | "mammoth_santa_maria" | "mammoth_rosario" | "mammoth_larsons_pride" | "mammoth_brittania" | "mammoth_sierra_madre" | "mammoth_mayan_gold" | "mammoth_dawn_chorus" | "mammoth_calysto" | "mammoth_sundown" | "mammoth_krenna" | "mammoth_seabound" | "mammoth_crimson_sky" | "asteroid_with_hole_1" | "asteroid_with_hole_2" | "asteroid_with_hole_3" | "asteroid_with_hole_4" | "t_predator" | "t_naginata" | "t_grendel" | "t_crusader" | "t_coyote" | "t_mirage" | "t_tempest" | "t_patriot" | "t_wolverine" | "t_reaper" | "t_shroud" | "t_phoenix" | "sun_marker" | "nebula_marker" | "marker" | "stand_in" | number | string +type ShipType = "predator" | "naginata" | "grendel" | "crusader" | "coyote" | "mirage" | "tempest" | "patriot" | "wolverine" | "reaper" | "shroud" | "phoenix" | "reliant" | "yamato" | "alsace" | "kestrel" | "falco" | "victorious" | "endeavour" | "mitchell" | "bremen" | "arc_ship" | "ulysses" | "sai" | "nanny" | "galahad" | "hades" | "mule" | "lonestar" | "limpet_car" | "prowler" | "ripper" | "lueneburg" | "mammoth" | "intruder" | "sentry" | "goodwill" | "stork" | "sky_hawk" | "haidar" | "karak" | "salin" | "azan" | "sabre" | "lagg" | "kamov" | "saracen" | "salamander" | "scimitar" | "basilisk" | "kossac" | "yao" | "ramases" | "kozlov" | "morzov" | "badanov" | "pukov" | "xia" | "riza" | "cyclops" | "kurgan" | "sharov" | "gurevich" | "berijev" | "kirov" | "loki" | "kalan_shuttle" | "saladin" | "darkreign" | "stalag" | "antanov" | "kronstadt" | "boridin" | "troop_car" | "torpedo" | "ulysses_back" | "ulysses_fin" | "escape_pod" | "debris" | "debris_1" | "debris_2" | "debris_3" | "debris_4" | "debris_5" | "debris_6" | "debris_7" | "debris_8" | "debris_9" | "crewman" | "crewman_1" | "crewman_2" | "crewman_3" | "russian_torpedo" | "mammoth_back" | "baxter" | "neptune_hi" | "triton_hi" | "uranus_hi" | "saturn_hi" | "titan_hi" | "jupiter_hi" | "europa_hi" | "ganymede_hi" | "io_hi" | "calisto_hi" | "venus_hi" | "earth" | "kronstadt_wreck" | "planet_placeholder_4" | "proto_gate" | "advanced_gate" | "proximity_mine" | "black_box" | "satellite" | "mammoth_wreck_front" | "mammoth_wreck_back" | "proto_gate_panel" | "badanov_wreck_back" | "badanov_wreck_front" | "kurgan_wreck" | "krasnaya" | "asteroid_1" | "asteroid_2" | "asteroid_3" | "asteroid_4" | "asteroid_5" | "asteroid_6" | "asteroid_7" | "research_station" | "latov" | "gurevich_wreck_back" | "training_hoop" | "czar_docked" | "turret_asteroid_1" | "turret_asteroid_2" | "turret_asteroid_3" | "turret_asteroid_4" | "turret_asteroid_5" | "turret_asteroid_6" | "turret_asteroid_7" | "powerup_random" | "dm_beacon_gate" | "dm_beacon" | "pukov_wreck_back" | "other_escape_pod" | "cargo_pod" | "other_cargo_pod" | "grazer_gun_sat" | "archer_missile_sat" | "kafelnikof" | "reliant_wreck_back" | "stalag_doors" | "stalag_duct_cover" | "yakob_shuttle" | "krasny" | "varyag" | "other_ramases" | "fort_carter" | "koenig" | "washington" | "other_mitchell" | "ufelsky" | "kresta" | "krasnaya_left_arm" | "krasnaya_right_arm" | "rogue_base" | "kronstadt_arm" | "boridin_gun_dome" | "boridin_breakaway" | "darkreign_wreck_hat" | "yamato_wreck_back" | "maintenance_droid" | "maintenance_astronaut" | "rogue_base_wreck_top" | "tank" | "training_target" | "zakov" | "shell" | "rock_chunk" | "rock_chunk_tiny_1" | "rock_chunk_tiny_2" | "rock_chunk_tiny_3" | "rock_chunk_tiny_4" | "latov_wreck_1" | "latov_wreck_2" | "czar" | "zakov_wreck_back" | "sharov_wreck_back" | "limpet_pod" | "stalag_wreck_1" | "stalag_wreck_2" | "saladin_wreck" | "gegarin" | "krelov" | "kiev" | "kiev_wreck_back" | "baxter_wreck_top" | "victorious_wreck_front" | "mitchell_wreck_back" | "fort_bear" | "fort_sherman" | "neptune_lo" | "triton_lo" | "uranus_lo" | "saturn_lo" | "titan_lo" | "jupiter_lo" | "europa_lo" | "ganymede_lo" | "io_lo" | "calisto_lo" | "venus_lo" | "yamato_hangar" | "yamato_landing_bay" | "reliant_hangar" | "comms_relay" | "saladin_link" | "varyag_wreck_front" | "churchill" | "bokov" | "bulatov" | "shinnik" | "kovtun" | "late_escape_pod" | "other_late_escape_pod" | "fuel_pod" | "yevstafiy" | "mammoth_gulliver" | "mammoth_santa_maria" | "mammoth_rosario" | "mammoth_larsons_pride" | "mammoth_brittania" | "mammoth_sierra_madre" | "mammoth_mayan_gold" | "mammoth_dawn_chorus" | "mammoth_calysto" | "mammoth_sundown" | "mammoth_krenna" | "mammoth_seabound" | "mammoth_crimson_sky" | "asteroid_with_hole_1" | "asteroid_with_hole_2" | "asteroid_with_hole_3" | "asteroid_with_hole_4" | "t_predator" | "t_naginata" | "t_grendel" | "t_crusader" | "t_coyote" | "t_mirage" | "t_tempest" | "t_patriot" | "t_wolverine" | "t_reaper" | "t_shroud" | "t_phoenix" | "sun_marker" | "nebula_marker" | "dm_sphere_centre" | "beacon_marker" | "spawn_player" | "spawn_powerup" | "marker" | "stand_in" | number | string type ShipClass = "fighter" | "capital" | "support" | "other" | "torpedo" | "debris" | "mine" | "planet" | number -type PilotNumber = "bandit_tigers_leader" | "diceman_tigers_leader" | "moose_tigers" | "moose_volunteers" | "bandit_volunteers_leader" | "viper" | "ronin_leader" | "ronin" | "frenchy" | "silky" | "mayday" | "trigger" | "hawkeye" | "worm" | "ego" | "diceman" | "bandit" | number | string +type PilotNumber = "bandit_tigers_leader" | "diceman_tigers_leader" | "moose_tigers" | "tigers" | "moose_volunteers" | "volunteers" | "bandit_volunteers_leader" | "viper" | "fifty_first" | "black_guard" | "black_sun_leader" | "black_sun" | "blades" | "black_eagle_leader" | "black_eagle" | "ivan_petrov" | "nicolai_petrov" | "black_aces" | "bremen_bridge_officer" | "buccaneer" | "cat_foster" | "cobra" | "couger" | "coalition_comms_officer" | "coalition_fighter" | "coalition_scientist" | "endeavour_bridge_officer" | "enriquez_reliant_bridge" | "enriquez_yamato_bridge" | "enriquez_cockpit" | "foster_dead" | "fort_bear_comms" | "fort_baxter_comms" | "fort_carter_comms" | "fort_sherman_comms" | "gamma_dead" | "gamma_leader" | "golden_warrior" | "hellcat_leader" | "hellcat" | "hornet" | "jackel_leader" | "jackel" | "jaguar" | "kestrel_command" | "koenig_bridge_officer" | "kestrel_bridge_officer" | "kurgen" | "lonestar" | "mauler" | "mcgann" | "mitchell_bridge_officer" | "nanny_leader" | "pirate_leader" | "pirate" | "prowler" | "pukov_captain" | "puma_leader" | "puma" | "raven" | "reliant_bridge_officer" | "ripper" | "ronin_leader" | "ronin" | "russian_bomber" | "sabre_leader" | "sabre" | "saracen" | "scorpion_leader" | "scorpion" | "saladin_captain" | "stahl_bridge" | "stahl_marines" | "steiner_ejected" | "stinger" | "stork" | "ulysses_captain" | "ulysses_captain_panicking" | "steiner_vampires_leader" | "vampire" | "varyag_captain" | "victorious_bridge_officer" | "virtual_flight_instructor" | "washington_bridge_officer" | "yamato_bridge_officer" | "frenchy" | "silky" | "mayday" | "trigger" | "vixen" | "cutter" | "claymore" | "skippy" | "juice" | "face" | "hawkeye" | "arrow" | "nero" | "rhino" | "stalker" | "flash" | "stretch" | "talon" | "butcher" | "snapper" | "banshee" | "wolf" | "reaper" | "worm" | "wildcat" | "siron" | "ferret" | "poker" | "blade" | "scarlet" | "rico" | "slick" | "irish" | "spider" | "ego" | "diceman" | "captain_mukai" | "cat_foster_prowler" | "coalition_torpedo_leader" | "general_reese_pod" | "kulov_borodin" | "kulov_pod" | "mammoth_1" | "mammoth_2" | "mammoth_3" | "mammoth_4" | "saracen_leader" | "stahl_ripper" | "golden_warrior_leader" | "hornet_leader" | "mammoth_5" | "mammoth_6" | "mammoth_7" | "bandit" | "marauder_leader" | "marauder" | "marine_leader" | "marine" | "jaguar_leader" | "buccaneer_leader" | number | string type Side = "friendly" | "hostile" | "neutral" | number type Maneuver = "defend_dodge1" | "defend_dodge2" | "defend_dodge3" | "out_of_action_sphere" | "defend_runaway" | "attack_pursue" | "attack_massive_object" | "attack_medium_fighter" | "loop_the_loop" | "run_to_ship" | number type Invulnerability = "none" | "player_can_hit" | "full" | "eject_before_exploding" | number @@ -42,7 +42,7 @@ type Axis = "x" | "y" | "z" type OptionKind = "toggle" | "choice" | "number" | "slider" | "text" | "heading" type Medal = "silver" | "black_eagle" | "valour" | "legion" | "navy_cross" | "medal_of_honour" type YamatoVisit = "never" | "always" | "when_reliant_lost" -type GameVariable = "jump_ready" | "warp_ready" | "backup_available" | "player_missiles_left" | "mcgann_alive" | "ivan_petrov_alive" | "kulov_alive" | "mission_over" | "landing_cleared" | "al_rahan_alive" | "sharif_alive" | "steiner_alive" | "mission_success" | "players" | "krasnaya_alive" | "rameses_alive" | "kozah_alive" | "fixed_gate_alive" | "warp_gate_alive" | "last_success" | "objectives_met" | "czar_alive" | "ghost_alive" | "reliant_alive" | "countdown" | "chapter2_thread3_shown" | "yamato_alive" | "ion_cannons_hold_lock" | number +type GameVariable = "jump_ready" | "warp_ready" | "backup_available" | "player_missiles_left" | "mcgann_alive" | "nicolai_petrov_alive" | "ivan_petrov_alive" | "kulov_alive" | "mission_over" | "landing_cleared" | "al_rahan_alive" | "sharif_alive" | "steiner_alive" | "mission_success" | "players" | "krasnaya_alive" | "rameses_alive" | "kozah_alive" | "fixed_gate_alive" | "warp_gate_alive" | "last_success" | "objectives_met" | "czar_alive" | "ghost_alive" | "reliant_alive" | "countdown" | "chapter2_thread3_shown" | "red_dragon_alive" | "yamato_alive" | "ion_cannons_hold_lock" | number type Ending = "playing" | "destroyed" | "rescued" | "captured" | "left" | "total_failure" | "friendly_fire" | "ejecting" | number type Rating = "total_failure" | "failure" | "partial_failure" | "partial_success" | "success" | "success_bonus" | number type DamageKind = "bullet" | "missile" | "collision" | "crash" | "screamer" | number diff --git a/docs/guide/reference.md b/docs/guide/reference.md index bade4129..920e2099 100644 --- a/docs/guide/reference.md +++ b/docs/guide/reference.md @@ -1806,7 +1806,7 @@ number. A script can set a field to either. ### ShipType -`predator`, `naginata`, `grendel`, `crusader`, `coyote`, `mirage`, `tempest`, `patriot`, `wolverine`, `reaper`, `shroud`, `phoenix`, `reliant`, `yamato`, `alsace`, `kestrel`, `falco`, `victorious`, `endeavour`, `mitchell`, `bremen`, `arc_ship`, `ulysses`, `sai`, `nanny`, `galahad`, `hades`, `mule`, `lonestar`, `limpet_car`, `prowler`, `ripper`, `lueneburg`, `mammoth`, `intruder`, `sentry`, `goodwill`, `stork`, `sky_hawk`, `haidar`, `karak`, `salin`, `azan`, `sabre`, `lagg`, `kamov`, `saracen`, `salamander`, `scimitar`, `basilisk`, `kossac`, `yao`, `ramases`, `kozlov`, `morzov`, `badanov`, `pukov`, `xia`, `riza`, `cyclops`, `kurgan`, `sharov`, `gurevich`, `berijev`, `kirov`, `loki`, `kalan_shuttle`, `saladin`, `darkreign`, `stalag`, `antanov`, `kronstadt`, `boridin`, `troop_car`, `torpedo`, `ulysses_back`, `ulysses_fin`, `escape_pod`, `debris`, `debris_1`, `debris_2`, `debris_3`, `debris_4`, `debris_5`, `debris_6`, `debris_7`, `debris_8`, `debris_9`, `crewman`, `crewman_1`, `crewman_2`, `crewman_3`, `russian_torpedo`, `mammoth_back`, `baxter`, `neptune_hi`, `triton_hi`, `uranus_hi`, `saturn_hi`, `titan_hi`, `jupiter_hi`, `europa_hi`, `ganymede_hi`, `io_hi`, `calisto_hi`, `venus_hi`, `earth`, `kronstadt_wreck`, `planet_placeholder_4`, `proto_gate`, `advanced_gate`, `proximity_mine`, `black_box`, `satellite`, `mammoth_wreck_front`, `mammoth_wreck_back`, `proto_gate_panel`, `badanov_wreck_back`, `badanov_wreck_front`, `kurgan_wreck`, `krasnaya`, `asteroid_1`, `asteroid_2`, `asteroid_3`, `asteroid_4`, `asteroid_5`, `asteroid_6`, `asteroid_7`, `research_station`, `latov`, `gurevich_wreck_back`, `training_hoop`, `czar_docked`, `turret_asteroid_1`, `turret_asteroid_2`, `turret_asteroid_3`, `turret_asteroid_4`, `turret_asteroid_5`, `turret_asteroid_6`, `turret_asteroid_7`, `powerup_random`, `dm_beacon_gate`, `dm_beacon`, `pukov_wreck_back`, `other_escape_pod`, `cargo_pod`, `other_cargo_pod`, `grazer_gun_sat`, `archer_missile_sat`, `kafelnikof`, `reliant_wreck_back`, `stalag_doors`, `stalag_duct_cover`, `yakob_shuttle`, `krasny`, `varyag`, `other_ramases`, `fort_carter`, `koenig`, `washington`, `other_mitchell`, `ufelsky`, `kresta`, `krasnaya_left_arm`, `krasnaya_right_arm`, `rogue_base`, `kronstadt_arm`, `boridin_gun_dome`, `boridin_breakaway`, `darkreign_wreck_hat`, `yamato_wreck_back`, `maintenance_droid`, `maintenance_astronaut`, `rogue_base_wreck_top`, `tank`, `training_target`, `zakov`, `shell`, `rock_chunk`, `rock_chunk_tiny_1`, `rock_chunk_tiny_2`, `rock_chunk_tiny_3`, `rock_chunk_tiny_4`, `latov_wreck_1`, `latov_wreck_2`, `czar`, `zakov_wreck_back`, `sharov_wreck_back`, `limpet_pod`, `stalag_wreck_1`, `stalag_wreck_2`, `saladin_wreck`, `gegarin`, `krelov`, `kiev`, `kiev_wreck_back`, `baxter_wreck_top`, `victorious_wreck_front`, `mitchell_wreck_back`, `fort_bear`, `fort_sherman`, `neptune_lo`, `triton_lo`, `uranus_lo`, `saturn_lo`, `titan_lo`, `jupiter_lo`, `europa_lo`, `ganymede_lo`, `io_lo`, `calisto_lo`, `venus_lo`, `yamato_hangar`, `yamato_landing_bay`, `reliant_hangar`, `comms_relay`, `saladin_link`, `varyag_wreck_front`, `churchill`, `bokov`, `bulatov`, `shinnik`, `kovtun`, `late_escape_pod`, `other_late_escape_pod`, `fuel_pod`, `yevstafiy`, `mammoth_gulliver`, `mammoth_santa_maria`, `mammoth_rosario`, `mammoth_larsons_pride`, `mammoth_brittania`, `mammoth_sierra_madre`, `mammoth_mayan_gold`, `mammoth_dawn_chorus`, `mammoth_calysto`, `mammoth_sundown`, `mammoth_krenna`, `mammoth_seabound`, `mammoth_crimson_sky`, `asteroid_with_hole_1`, `asteroid_with_hole_2`, `asteroid_with_hole_3`, `asteroid_with_hole_4`, `t_predator`, `t_naginata`, `t_grendel`, `t_crusader`, `t_coyote`, `t_mirage`, `t_tempest`, `t_patriot`, `t_wolverine`, `t_reaper`, `t_shroud`, `t_phoenix`, `sun_marker`, `nebula_marker`, `marker`, `stand_in`, the qualified name of one a mod adds, or a number. +`predator`, `naginata`, `grendel`, `crusader`, `coyote`, `mirage`, `tempest`, `patriot`, `wolverine`, `reaper`, `shroud`, `phoenix`, `reliant`, `yamato`, `alsace`, `kestrel`, `falco`, `victorious`, `endeavour`, `mitchell`, `bremen`, `arc_ship`, `ulysses`, `sai`, `nanny`, `galahad`, `hades`, `mule`, `lonestar`, `limpet_car`, `prowler`, `ripper`, `lueneburg`, `mammoth`, `intruder`, `sentry`, `goodwill`, `stork`, `sky_hawk`, `haidar`, `karak`, `salin`, `azan`, `sabre`, `lagg`, `kamov`, `saracen`, `salamander`, `scimitar`, `basilisk`, `kossac`, `yao`, `ramases`, `kozlov`, `morzov`, `badanov`, `pukov`, `xia`, `riza`, `cyclops`, `kurgan`, `sharov`, `gurevich`, `berijev`, `kirov`, `loki`, `kalan_shuttle`, `saladin`, `darkreign`, `stalag`, `antanov`, `kronstadt`, `boridin`, `troop_car`, `torpedo`, `ulysses_back`, `ulysses_fin`, `escape_pod`, `debris`, `debris_1`, `debris_2`, `debris_3`, `debris_4`, `debris_5`, `debris_6`, `debris_7`, `debris_8`, `debris_9`, `crewman`, `crewman_1`, `crewman_2`, `crewman_3`, `russian_torpedo`, `mammoth_back`, `baxter`, `neptune_hi`, `triton_hi`, `uranus_hi`, `saturn_hi`, `titan_hi`, `jupiter_hi`, `europa_hi`, `ganymede_hi`, `io_hi`, `calisto_hi`, `venus_hi`, `earth`, `kronstadt_wreck`, `planet_placeholder_4`, `proto_gate`, `advanced_gate`, `proximity_mine`, `black_box`, `satellite`, `mammoth_wreck_front`, `mammoth_wreck_back`, `proto_gate_panel`, `badanov_wreck_back`, `badanov_wreck_front`, `kurgan_wreck`, `krasnaya`, `asteroid_1`, `asteroid_2`, `asteroid_3`, `asteroid_4`, `asteroid_5`, `asteroid_6`, `asteroid_7`, `research_station`, `latov`, `gurevich_wreck_back`, `training_hoop`, `czar_docked`, `turret_asteroid_1`, `turret_asteroid_2`, `turret_asteroid_3`, `turret_asteroid_4`, `turret_asteroid_5`, `turret_asteroid_6`, `turret_asteroid_7`, `powerup_random`, `dm_beacon_gate`, `dm_beacon`, `pukov_wreck_back`, `other_escape_pod`, `cargo_pod`, `other_cargo_pod`, `grazer_gun_sat`, `archer_missile_sat`, `kafelnikof`, `reliant_wreck_back`, `stalag_doors`, `stalag_duct_cover`, `yakob_shuttle`, `krasny`, `varyag`, `other_ramases`, `fort_carter`, `koenig`, `washington`, `other_mitchell`, `ufelsky`, `kresta`, `krasnaya_left_arm`, `krasnaya_right_arm`, `rogue_base`, `kronstadt_arm`, `boridin_gun_dome`, `boridin_breakaway`, `darkreign_wreck_hat`, `yamato_wreck_back`, `maintenance_droid`, `maintenance_astronaut`, `rogue_base_wreck_top`, `tank`, `training_target`, `zakov`, `shell`, `rock_chunk`, `rock_chunk_tiny_1`, `rock_chunk_tiny_2`, `rock_chunk_tiny_3`, `rock_chunk_tiny_4`, `latov_wreck_1`, `latov_wreck_2`, `czar`, `zakov_wreck_back`, `sharov_wreck_back`, `limpet_pod`, `stalag_wreck_1`, `stalag_wreck_2`, `saladin_wreck`, `gegarin`, `krelov`, `kiev`, `kiev_wreck_back`, `baxter_wreck_top`, `victorious_wreck_front`, `mitchell_wreck_back`, `fort_bear`, `fort_sherman`, `neptune_lo`, `triton_lo`, `uranus_lo`, `saturn_lo`, `titan_lo`, `jupiter_lo`, `europa_lo`, `ganymede_lo`, `io_lo`, `calisto_lo`, `venus_lo`, `yamato_hangar`, `yamato_landing_bay`, `reliant_hangar`, `comms_relay`, `saladin_link`, `varyag_wreck_front`, `churchill`, `bokov`, `bulatov`, `shinnik`, `kovtun`, `late_escape_pod`, `other_late_escape_pod`, `fuel_pod`, `yevstafiy`, `mammoth_gulliver`, `mammoth_santa_maria`, `mammoth_rosario`, `mammoth_larsons_pride`, `mammoth_brittania`, `mammoth_sierra_madre`, `mammoth_mayan_gold`, `mammoth_dawn_chorus`, `mammoth_calysto`, `mammoth_sundown`, `mammoth_krenna`, `mammoth_seabound`, `mammoth_crimson_sky`, `asteroid_with_hole_1`, `asteroid_with_hole_2`, `asteroid_with_hole_3`, `asteroid_with_hole_4`, `t_predator`, `t_naginata`, `t_grendel`, `t_crusader`, `t_coyote`, `t_mirage`, `t_tempest`, `t_patriot`, `t_wolverine`, `t_reaper`, `t_shroud`, `t_phoenix`, `sun_marker`, `nebula_marker`, `dm_sphere_centre`, `beacon_marker`, `spawn_player`, `spawn_powerup`, `marker`, `stand_in`, the qualified name of one a mod adds, or a number. ### ShipClass @@ -1814,7 +1814,7 @@ number. A script can set a field to either. ### PilotNumber -`bandit_tigers_leader`, `diceman_tigers_leader`, `moose_tigers`, `moose_volunteers`, `bandit_volunteers_leader`, `viper`, `ronin_leader`, `ronin`, `frenchy`, `silky`, `mayday`, `trigger`, `hawkeye`, `worm`, `ego`, `diceman`, `bandit`, the qualified name of one a mod adds, or a number. +`bandit_tigers_leader`, `diceman_tigers_leader`, `moose_tigers`, `tigers`, `moose_volunteers`, `volunteers`, `bandit_volunteers_leader`, `viper`, `fifty_first`, `black_guard`, `black_sun_leader`, `black_sun`, `blades`, `black_eagle_leader`, `black_eagle`, `ivan_petrov`, `nicolai_petrov`, `black_aces`, `bremen_bridge_officer`, `buccaneer`, `cat_foster`, `cobra`, `couger`, `coalition_comms_officer`, `coalition_fighter`, `coalition_scientist`, `endeavour_bridge_officer`, `enriquez_reliant_bridge`, `enriquez_yamato_bridge`, `enriquez_cockpit`, `foster_dead`, `fort_bear_comms`, `fort_baxter_comms`, `fort_carter_comms`, `fort_sherman_comms`, `gamma_dead`, `gamma_leader`, `golden_warrior`, `hellcat_leader`, `hellcat`, `hornet`, `jackel_leader`, `jackel`, `jaguar`, `kestrel_command`, `koenig_bridge_officer`, `kestrel_bridge_officer`, `kurgen`, `lonestar`, `mauler`, `mcgann`, `mitchell_bridge_officer`, `nanny_leader`, `pirate_leader`, `pirate`, `prowler`, `pukov_captain`, `puma_leader`, `puma`, `raven`, `reliant_bridge_officer`, `ripper`, `ronin_leader`, `ronin`, `russian_bomber`, `sabre_leader`, `sabre`, `saracen`, `scorpion_leader`, `scorpion`, `saladin_captain`, `stahl_bridge`, `stahl_marines`, `steiner_ejected`, `stinger`, `stork`, `ulysses_captain`, `ulysses_captain_panicking`, `steiner_vampires_leader`, `vampire`, `varyag_captain`, `victorious_bridge_officer`, `virtual_flight_instructor`, `washington_bridge_officer`, `yamato_bridge_officer`, `frenchy`, `silky`, `mayday`, `trigger`, `vixen`, `cutter`, `claymore`, `skippy`, `juice`, `face`, `hawkeye`, `arrow`, `nero`, `rhino`, `stalker`, `flash`, `stretch`, `talon`, `butcher`, `snapper`, `banshee`, `wolf`, `reaper`, `worm`, `wildcat`, `siron`, `ferret`, `poker`, `blade`, `scarlet`, `rico`, `slick`, `irish`, `spider`, `ego`, `diceman`, `captain_mukai`, `cat_foster_prowler`, `coalition_torpedo_leader`, `general_reese_pod`, `kulov_borodin`, `kulov_pod`, `mammoth_1`, `mammoth_2`, `mammoth_3`, `mammoth_4`, `saracen_leader`, `stahl_ripper`, `golden_warrior_leader`, `hornet_leader`, `mammoth_5`, `mammoth_6`, `mammoth_7`, `bandit`, `marauder_leader`, `marauder`, `marine_leader`, `marine`, `jaguar_leader`, `buccaneer_leader`, the qualified name of one a mod adds, or a number. ### Side @@ -1950,7 +1950,7 @@ number. A script can set a field to either. ### GameVariable -`jump_ready`, `warp_ready`, `backup_available`, `player_missiles_left`, `mcgann_alive`, `ivan_petrov_alive`, `kulov_alive`, `mission_over`, `landing_cleared`, `al_rahan_alive`, `sharif_alive`, `steiner_alive`, `mission_success`, `players`, `krasnaya_alive`, `rameses_alive`, `kozah_alive`, `fixed_gate_alive`, `warp_gate_alive`, `last_success`, `objectives_met`, `czar_alive`, `ghost_alive`, `reliant_alive`, `countdown`, `chapter2_thread3_shown`, `yamato_alive`, `ion_cannons_hold_lock`, or a number. +`jump_ready`, `warp_ready`, `backup_available`, `player_missiles_left`, `mcgann_alive`, `nicolai_petrov_alive`, `ivan_petrov_alive`, `kulov_alive`, `mission_over`, `landing_cleared`, `al_rahan_alive`, `sharif_alive`, `steiner_alive`, `mission_success`, `players`, `krasnaya_alive`, `rameses_alive`, `kozah_alive`, `fixed_gate_alive`, `warp_gate_alive`, `last_success`, `objectives_met`, `czar_alive`, `ghost_alive`, `reliant_alive`, `countdown`, `chapter2_thread3_shown`, `red_dragon_alive`, `yamato_alive`, `ion_cannons_hold_lock`, or a number. ### Ending diff --git a/docs/guide/scripting.md b/docs/guide/scripting.md index a14c6ff9..315e998a 100644 --- a/docs/guide/scripting.md +++ b/docs/guide/scripting.md @@ -1109,7 +1109,7 @@ scripts can only read them. | `ship_types` | What the executable holds for each ship type, then for the types the mods add | 0 | As `ships` | | `guns` | Gun stats, `gunstats.bin`, then the guns the mods add | 1 | `laser_cannon`, `pulse_cannon` and the rest, and the qualified names of the mods' guns | | `missiles` | Missile stats, `missilestats.bin`, then the missiles the mods add | 0 | `screamer`, `raptor` and the rest, and the qualified names of the mods' missiles | -| `pilots` | Pilot stats, `pilotstats.bin`, then the pilots the mods add | 0 | `frenchy`, `viper`, `ronin_leader` and the other pilots the game's code singles out, and the qualified names of the mods' pilots | +| `pilots` | Pilot stats, `pilotstats.bin`, then the pilots the mods add | 0 | `frenchy`, `viper`, `ronin_leader` and the game's other pilots, and the qualified names of the mods' pilots | | `faces` | The pilots' faces, which the game keeps in its executable, then those of the pilots the mods add | 0 | As `pilots` | | `text` | The game's text, `language.dll`, by string id | 1 | | | `itac_text` | The ITAC's text, `itaclang.dll`, by string id | 1 | | diff --git a/ghidra/names/LANCER.EXE.tsv b/ghidra/names/LANCER.EXE.tsv index 235eaeab..711430a9 100644 --- a/ghidra/names/LANCER.EXE.tsv +++ b/ghidra/names/LANCER.EXE.tsv @@ -638,6 +638,7 @@ address kind name type comment 00408500 function explode_kill_credit ObjectRoutine Where the player destroyed a hostile fighter, or type 0x2D, 0x3C or 0x3E, adds the kill (kills_add) and a radio line on it (radio_kill_remark); a ship with +0x74C clear gets a line of its own (radio_ship_lost) 004aeb50 function arena_frame void (void) In a multiplayer mission, once a frame: the first time, sizes the arena's sphere round the objects from slot 8 but mines, beacons and a few ranges of types; after, a player's ship that strays past it meets its wall (arena_wall_hit) once until it is back inside, and the local player's is shaken and pushed back in, or put back just inside; the wall's flashes play on and the players' ships are kept apart 004af0e0 function arena_shot_check void __fastcall (int *shot) Once the arena is sized, a shot past its sphere meets its wall (arena_wall_hit) +004af5c0 function multiplayer_list_spawns void (void) As a multiplayer game starts: lists the objects of type spawn_player (0x3E1), the players' spawn points, by their slots (0x005D7CA8, the count at 0x005DAD2C), and unless 0x0050C2F1 is set, holds the guns of every object from slot 8 on; then 0x004B1760, and 0x004B09A0 where the network session is other than 1 004b02a0 function arena_wall_hit void __fastcall (int slot, int *shot) Where a shot, which it frees, or a player's ship meets the multiplayer arena's wall: 10 sparks of kind 3 back in from the wall where a flash of it at most 100 ticks old stands within twice its radius, else a new flash (the next of 32, a 4000 mesh facing the arena's middle, for 250 ticks) with the sparks and sound 0xD 005d7c9c data arena_centre Vec3 The middle of the multiplayer arena's sphere 005d76ec data arena_radius float The radius of the multiplayer arena's sphere: the farthest reach of the objects it is sized round diff --git a/src/engine/game/gameflow.zig b/src/engine/game/gameflow.zig index 8689d305..5d469418 100644 --- a/src/engine/game/gameflow.zig +++ b/src/engine/game/gameflow.zig @@ -40,10 +40,10 @@ const cleared_variables = 32; /// flags, such as `ghost_alive`, and `mission_success`, which each attempt clears again. The rest /// of the campaign's start at 0. const campaign_flags = vm.Variables.numbers(.{ - "mission_success", "_unused_16", "krasnaya_alive", "rameses_alive", "kozah_alive", - "_unused_20", "_unused_21", "mcgann_alive", "fixed_gate_alive", "warp_gate_alive", - "czar_alive", "ghost_alive", "_unused_31", "reliant_alive", "_unknown_35", - "steiner_alive", "kulov_alive", "ivan_petrov_alive", "_unknown_6", "yamato_alive", + "mission_success", "_unused_16", "krasnaya_alive", "rameses_alive", "kozah_alive", + "_unused_20", "_unused_21", "mcgann_alive", "fixed_gate_alive", "warp_gate_alive", + "czar_alive", "ghost_alive", "_unused_31", "reliant_alive", "red_dragon_alive", + "steiner_alive", "kulov_alive", "ivan_petrov_alive", "nicolai_petrov_alive", "yamato_alive", }); /// `campaign_new` (`0x004751B0`) as a new campaign begins, which `WinMain` also runs as the game diff --git a/src/engine/game/gameflow/save.zig b/src/engine/game/gameflow/save.zig index 9d1592a1..816d20e5 100644 --- a/src/engine/game/gameflow/save.zig +++ b/src/engine/game/gameflow/save.zig @@ -190,10 +190,10 @@ pub const Vars = [30]i32; /// (`game_save`, `0x004757A0`; `game_load`, `0x004754DC`): the campaign's flags and /// `last_success`. pub const kept_variables = vm.Variables.numbers(.{ - "_unused_16", "krasnaya_alive", "rameses_alive", "kozah_alive", "_unused_20", "_unused_21", - "mcgann_alive", "fixed_gate_alive", "warp_gate_alive", "_unknown_6", "ivan_petrov_alive", "kulov_alive", - "al_rahan_alive", "sharif_alive", "steiner_alive", "_unused_24", "_unused_25", "_unused_26", - "last_success", "czar_alive", "ghost_alive", "_unused_31", "reliant_alive", "chapter2_thread3_shown", + "_unused_16", "krasnaya_alive", "rameses_alive", "kozah_alive", "_unused_20", "_unused_21", + "mcgann_alive", "fixed_gate_alive", "warp_gate_alive", "nicolai_petrov_alive", "ivan_petrov_alive", "kulov_alive", + "al_rahan_alive", "sharif_alive", "steiner_alive", "_unused_24", "_unused_25", "_unused_26", + "last_success", "czar_alive", "ghost_alive", "_unused_31", "reliant_alive", "chapter2_thread3_shown", "yamato_alive", }); diff --git a/src/engine/game/gameobj.zig b/src/engine/game/gameobj.zig index 89095a69..603ae4ac 100644 --- a/src/engine/game/gameobj.zig +++ b/src/engine/game/gameobj.zig @@ -634,6 +634,15 @@ pub const GameType = enum(u32) { /// The markers `backdrop_place` reads a mission's sun and nebula from. sun_marker = 0x3DC, nebula_marker = 0x3DD, + /// The deathmatch maps' markers, named after the objects their designers placed: the arena's + /// centre (`dm_sphere_centre`), the objects named `beacon1` to `beacon6`, beside the beacons + /// themselves (`dm_beacon`), the players' spawn points and the power-ups'. The game singles out + /// the players' spawn points alone, which `multiplayer_list_spawns` (`0x004AF5C0`) lists as a + /// multiplayer game starts; it sizes the arena round every object (`arena_frame`). + dm_sphere_centre = 0x3DF, + beacon_marker = 0x3E0, + spawn_player = 0x3E1, + spawn_powerup = 0x3E2, /// What `mission_ship_create` makes of a mission's nav points and markers, and the mission's /// start of the camera's marker. marker = 1000, diff --git a/src/engine/game/pilots.zig b/src/engine/game/pilots.zig index d390f4ad..8e442ee7 100644 --- a/src/engine/game/pilots.zig +++ b/src/engine/game/pilots.zig @@ -178,34 +178,192 @@ test Table { } /// The game's pilots, by their numbers in `pilotstats.bin` and in the faces' table (`faces`). The -/// names are OpenReliant's, for the pilots the game's code singles out: the player's wing and its +/// names are OpenReliant's: for the pilots the game's code singles out, the player's wing and its /// stretches (`new_wing`, `stretches`), the first replacements of its pool (`pool`), Moose, who makes /// the squadron's remarks (`radio.moose`), and the Ronin wing, which flies with the StarLancer -/// trial's player. +/// trial's player; for the rest, their records' names in `pilotstats.bin`, spelled out. A +/// squadron's pilot takes the squadron's name, such as `sabre` for `Sabre Plt`, and its wing +/// leader `_leader` after it, such as `sabre_leader` for `Sabrewl Plt`. pub const GamePilot = enum(u8) { /// Bandit and Diceman, the 45th Tigers' wing leaders (`45TigersWL_*`). bandit_tigers_leader = 0, diceman_tigers_leader = 1, - /// Moose, of the 45th Tigers and of the 45th Volunteers. + /// Moose, of the 45th Tigers and of the 45th Volunteers, and each squadron's pilots. moose_tigers = 2, + tigers = 3, moose_volunteers = 4, + volunteers = 5, /// Bandit and Viper, the 45th Volunteers' wing leaders (`45VolntrWL_*`). bandit_volunteers_leader = 6, viper = 7, + /// The 51st's pilots (`51st Plt`). + fifty_first = 8, + black_guard = 9, + /// Black Sun, the wing leader whom the radio knows as an ace, and his squadron's pilots. + black_sun_leader = 10, + black_sun = 11, + blades = 12, + black_eagle_leader = 13, + black_eagle = 14, + /// Ivan and Nicolai Petrov, of the Black Guard (`Blckgrdip Plt`, `Blckgrdnp Plt`), whom the + /// radio knows as aces. + ivan_petrov = 15, + nicolai_petrov = 16, + /// A Black Aces pilot (`Blkace`, whose film is `BlkAces_Plt_d` as it dies). + black_aces = 17, + bremen_bridge_officer = 18, + /// The Buccaneers' ace (`Bucc`), who says mission 21's lines `ms21_buc_*` and has a death film + /// of his own (`BUCCDETH`), as their wing leader (`buccaneer_leader`) does. + buccaneer = 19, + cat_foster = 20, + /// A Cobra pilot (`Cob`, whose film is `Cobra_Plt_D` as it dies). + cobra = 21, + couger = 22, + /// The Coalition's (`C Comms Off1`, `C Fhtr 01`, `C Scientist`): most of its fighters are + /// flown by its fighter pilot. + coalition_comms_officer = 23, + coalition_fighter = 24, + coalition_scientist = 25, + endeavour_bridge_officer = 26, + /// Enriquez on the Reliant's and the Yamato's bridges, and in a cockpit. + enriquez_reliant_bridge = 27, + enriquez_yamato_bridge = 28, + enriquez_cockpit = 29, + /// `Fostded`, which no shipped mission flies or speaks by. **Unverified:** that it is Foster + /// dead. + foster_dead = 30, + /// The comms officers of Fort Bear, Fort Baxter, Fort Carter and Fort Sherman + /// (`Frtbear Comms`). + fort_bear_comms = 31, + fort_baxter_comms = 32, + fort_carter_comms = 33, + fort_sherman_comms = 34, + /// `Gammadea`, which no shipped mission flies or speaks by. **Unverified:** that it is Gamma + /// dead. + gamma_dead = 35, + gamma_leader = 36, + golden_warrior = 37, + hellcat_leader = 38, + hellcat = 39, + hornet = 40, + jackel_leader = 41, + jackel = 42, + jaguar = 43, + kestrel_command = 44, + koenig_bridge_officer = 45, + kestrel_bridge_officer = 46, + kurgen = 47, + lonestar = 48, + mauler = 49, + /// Colonel McGann (`Mcgannno`), whom the radio knows as an ace. + mcgann = 50, + mitchell_bridge_officer = 51, + nanny_leader = 52, + pirate_leader = 53, + pirate = 54, + prowler = 55, + pukov_captain = 56, + puma_leader = 57, + puma = 58, + raven = 59, + reliant_bridge_officer = 60, + ripper = 61, /// The Ronin wing's leader, Tanaka in the trial, and its pilots (`RoninWL_Plt`, `Ronin_Plt`). ronin_leader = 62, ronin = 63, - /// The new wing's pilots, Hawkeye and Diceman of the stretches, and the pool's first - /// replacements, Ego, Mayday and Trigger. + russian_bomber = 64, + sabre_leader = 65, + sabre = 66, + saracen = 67, + scorpion_leader = 68, + scorpion = 69, + saladin_captain = 70, + /// Stahl, who says mission 9's lines `ms9_stal_*` (`Stahl Brdship`), and his marines. + /// **Unverified:** that `Brdship` is a ship's bridge. + stahl_bridge = 71, + stahl_marines = 72, + steiner_ejected = 73, + stinger = 74, + stork = 75, + ulysses_captain = 76, + ulysses_captain_panicking = 77, + /// Steiner, the Vampires' wing leader (`Vampirewl Stiener`), and the Vampires' pilots. + steiner_vampires_leader = 78, + vampire = 79, + varyag_captain = 80, + victorious_bridge_officer = 81, + /// The simulator's instructor (`Virtflt Ins`). + virtual_flight_instructor = 82, + washington_bridge_officer = 83, + yamato_bridge_officer = 84, + /// The new wing's pilots, Hawkeye and Diceman of the stretches, the pool's first replacements, + /// Ego, Mayday and Trigger, and the 45th's other pilots, by their callsigns. frenchy = 85, silky = 86, mayday = 87, trigger = 88, + vixen = 89, + cutter = 90, + claymore = 91, + skippy = 92, + juice = 93, + face = 94, hawkeye = 95, + arrow = 96, + nero = 97, + rhino = 98, + stalker = 99, + flash = 100, + stretch = 101, + talon = 102, + butcher = 103, + snapper = 104, + banshee = 105, + wolf = 106, + reaper = 107, worm = 108, + wildcat = 109, + siron = 110, + ferret = 111, + poker = 112, + blade = 113, + scarlet = 114, + rico = 115, + slick = 116, + irish = 117, + spider = 118, ego = 119, diceman = 120, + captain_mukai = 121, + cat_foster_prowler = 122, + coalition_torpedo_leader = 123, + /// The pilots past the records of `pilotstats.bin`, which take its defaults, after their faces' + /// films (`faces`): those with faces of their own. Those whose faces repeat another's, such as + /// the 45th's spares and further Mammoth pilots, are known by their numbers alone. + general_reese_pod = 124, + kulov_borodin = 125, + kulov_pod = 126, + mammoth_1 = 127, + mammoth_2 = 128, + mammoth_3 = 129, + mammoth_4 = 130, + /// The Saracens' and the Golden Warriors' leaders, whom the radio knows as aces; the Golden + /// Warriors' is the Red Dragon of mission 26. + saracen_leader = 131, + /// Stahl on the Ripper's ship (`Stahl_RippShip`). + stahl_ripper = 132, + golden_warrior_leader = 133, + hornet_leader = 134, + mammoth_5 = 135, + mammoth_6 = 136, + mammoth_7 = 137, bandit = 172, + marauder_leader = 186, + marauder = 187, + marine_leader = 188, + marine = 189, + jaguar_leader = 190, + buccaneer_leader = 191, _, /// Its number, as the wing keeps it (`Wing`). diff --git a/src/engine/game/radio/menu.zig b/src/engine/game/radio/menu.zig index 61a9787b..dd536ce4 100644 --- a/src/engine/game/radio/menu.zig +++ b/src/engine/game/radio/menu.zig @@ -340,32 +340,22 @@ const taunt_lines = [_][]const u8{ "hud_007.ut", "hud_008.ut", "hud_009.ut", "hu /// The ends of a hostile pilot's answers to a taunt, in its voice (`0x004EF5EC`). const taunt_answers = numbered("_res_", 1, 24); -/// The enemy's aces, by their pilots' numbers, who answer a taunt in lines of their own -/// (`0x00454AFC`). -const Ace = enum(i32) { - black_sun = 10, - ivan_petrov = 15, - nicolai_petrov = 16, - mcgann = 50, - saracen_leader = 131, - golden_warrior_leader = 133, - _, - - /// Black Sun's lines (`0x004EF678`), Ivan Petrov's (`0x004EF694`), Nicolai Petrov's - /// (`0x004EF6CC`), Colonel McGann's (`0x004EF70C`), the Saracens' leader's (`0x004EF64C`) and - /// the Golden Warriors' leader's (`0x004EF6F4`). - fn answers(ace: Ace) ?[]const []const u8 { - return switch (ace) { - .black_sun => &ace_answers.black_sun, - .ivan_petrov => &ace_answers.ivan_petrov, - .nicolai_petrov => &ace_answers.nicolai_petrov, - .mcgann => &ace_answers.mcgann, - .saracen_leader => &ace_answers.saracen_leader, - .golden_warrior_leader => &ace_answers.golden_warrior_leader, - _ => null, - }; - } -}; +/// The lines an enemy ace answers a taunt in, by its pilot (`0x00454AFC`): Black Sun's +/// (`0x004EF678`), Ivan Petrov's (`0x004EF694`), Nicolai Petrov's (`0x004EF6CC`), Colonel +/// McGann's (`0x004EF70C`), the Saracens' leader's (`0x004EF64C`) and the Golden Warriors' +/// leader's (`0x004EF6F4`). None for another pilot. +fn aceAnswers(pilot: i32) ?[]const []const u8 { + const named: pilots.GamePilot = @fromBackingInt(std.math.cast(u8, pilot) orelse return null); + return switch (named) { + .black_sun_leader => &ace_answers.black_sun, + .ivan_petrov => &ace_answers.ivan_petrov, + .nicolai_petrov => &ace_answers.nicolai_petrov, + .mcgann => &ace_answers.mcgann, + .saracen_leader => &ace_answers.saracen_leader, + .golden_warrior_leader => &ace_answers.golden_warrior_leader, + else => null, + }; +} const ace_answers = struct { const black_sun = numbered("hs_res_", 1, 7); @@ -380,8 +370,8 @@ const ace_answers = struct { /// (`radio.playerSays`). Unless the ship is not to be disturbed, is not a fighter by its /// type's class, or its current order has a priority or aims at the player's ship already, it turns /// on the player's ship (Fight), and answers in `radio.report_delay` ticks: one of an ace's -/// own lines (`Ace`), or one of `taunt_answers` in its pilot's voice. Before it turns, the game -/// draws a random number it doesn't use if the pilot's `pilots.Pilot._unknown_20` is 0 or 1. +/// own lines (`aceAnswers`), or one of `taunt_answers` in its pilot's voice. Before it turns, the +/// game draws a random number it doesn't use if the pilot's `pilots.Pilot._unknown_20` is 0 or 1. fn taunt(ctx: aigeneric.Context, which: usize, addressed: i16) void { const world = ctx.world; radio.playerSays(world, taunt_lines[which]); @@ -400,7 +390,7 @@ fn taunt(ctx: aigeneric.Context, which: usize, addressed: i16) void { else => {}, } _ = aigeneric.giveShip(ctx, index, .fight, all.player, null); - const own = (@as(Ace, @fromBackingInt(object.pilot))).answers(); + const own = aceAnswers(object.pilot); const lines: radio.Lines = if (own) |named| .{ .named = named } else .{ .voiced = &taunt_answers }; radio.reportShipIn(world, index, lines, radio.report_delay); } @@ -663,8 +653,8 @@ test "a taunt turns the enemy on the player" { try std.testing.expectEqual(null, heard.radio.reports[0]); // An ace answers in its own lines. - try std.testing.expectEqualStrings("hs_res_007.ut", Ace.black_sun.answers().?[6]); - try std.testing.expectEqual(null, @as(Ace, @fromBackingInt(0)).answers()); + try std.testing.expectEqualStrings("hs_res_007.ut", aceAnswers(@backingInt(pilots.GamePilot.black_sun_leader)).?[6]); + try std.testing.expectEqual(null, aceAnswers(0)); } test "the wingman's status" { diff --git a/src/engine/game/xtrabits/landing.zig b/src/engine/game/xtrabits/landing.zig index a4f81615..75a308d3 100644 --- a/src/engine/game/xtrabits/landing.zig +++ b/src/engine/game/xtrabits/landing.zig @@ -197,12 +197,12 @@ const news = [_]struct { mission: u16, reports: []const Report }{ .{ .mission = 11, .reports = &.{ .{ .movie = "new_chapter2_thread1.bik", .unless = &.{.czar_alive} }, .{ .movie = "new_chapter2_thread2.bik", .unless = &.{.krasnaya_alive} }, - .{ .movie = "new_chapter2_thread3.bik", .unless = &.{._unknown_6}, .sets = .chapter2_thread3_shown }, + .{ .movie = "new_chapter2_thread3.bik", .unless = &.{.nicolai_petrov_alive}, .sets = .chapter2_thread3_shown }, } }, .{ .mission = 16, .reports = &.{ .{ .movie = "new_chapter3_thread1.bik", .unless = &.{.mcgann_alive} }, .{ .movie = "new_chapter3_thread2.bik", .unless = &.{.warp_gate_alive} }, - .{ .movie = "new_chapter2_thread3.bik", .unless = &.{ ._unknown_6, .chapter2_thread3_shown } }, + .{ .movie = "new_chapter2_thread3.bik", .unless = &.{ .nicolai_petrov_alive, .chapter2_thread3_shown } }, } }, }; diff --git a/src/engine/vm.zig b/src/engine/vm.zig index 34629976..7c956dfc 100644 --- a/src/engine/vm.zig +++ b/src/engine/vm.zig @@ -236,11 +236,14 @@ pub const Variables = extern struct { /// destroyed, and mission 9's reads it. The news report after mission 16 reads it too, but /// never plays, as mission 16 ends no chapter. mcgann_alive: u32 = 0, - /// Variable 6: mission 27's script clears it as it starts. The third news report after mission - /// 11 (`new_chapter2_thread3.bik`) plays once it's clear, which it never is then. - /// `mission191.dte`, which the campaign never flies, reads it as its Petrov wing's leader - /// alive. **Unknown:** what mission 27 clears it for. - _unknown_6: u32 = 0, + /// `nicolai_petrov_alive` (6): whether Nicolai Petrov lives. The Dreamcast's mission 12, which + /// the PC game lacks, clears it as its Petrov's Basilisk is destroyed, and `mission191.dte`, + /// which the campaign never flies, reads it as its Petrov wing's leader alive, apart from Ivan + /// Petrov's (`ivan_petrov_alive`). Mission 27's script clears it as it starts. The third news + /// report after mission 11 (`new_chapter2_thread3.bik`) plays once it's clear, which it never + /// is then. **Unverified:** that this Petrov is Nicolai: the missions don't name him, but the + /// game's radio knows two Petrovs, Ivan and Nicolai, and Ivan's flag is 7. + nicolai_petrov_alive: u32 = 0, /// `ivan_petrov_alive` (7): whether Ivan Petrov lives. Mission 28's script clears it as his /// Basilisk is destroyed, and the story's end plays his news report once it's clear. ivan_petrov_alive: u32 = 0, @@ -333,9 +336,11 @@ pub const Variables = extern struct { /// cleared before each attempt at a mission (`gameflow.resetVariables`). Only the news report /// after mission 16 reads it, which never plays. chapter2_thread3_shown: u32 = 0, - /// Variable 35: 1 in a new campaign, not kept with the pilot's game, and nothing clears it. - /// Mission 26's script reads it, always set. **Unknown:** what it was meant to hold. - _unknown_35: u32 = 0, + /// `red_dragon_alive` (35): whether the Red Dragon, the Golden Warriors' leader (pilot 133), + /// lives. 1 in a new campaign and not kept with the pilot's game. Mission 26's script brings + /// him in while it is set, and where he dies, sets a global of its own (`red_dragon_dead`). + /// **Unverified:** that it says whether he lives, since no script clears it. + red_dragon_alive: u32 = 0, /// `yamato_alive` (36): whether the Yamato lives. Mission 25's second part and mission 27 /// clear it as she's destroyed. Once it's clear, those missions end without the landing /// (`xtrabits.landing.lastWithoutLanding`), and a total failure in them ends the career in the @@ -423,7 +428,7 @@ pub const Variables = extern struct { comptime { // Each named variable at the number the scripts give it, and every number a byte names. const numbered = .{ - .{ "_unused_2", 2 }, .{ "mcgann_alive", 5 }, .{ "_unknown_6", 6 }, + .{ "_unused_2", 2 }, .{ "mcgann_alive", 5 }, .{ "nicolai_petrov_alive", 6 }, .{ "ivan_petrov_alive", 7 }, .{ "kulov_alive", 8 }, .{ "al_rahan_alive", 11 }, .{ "sharif_alive", 12 }, .{ "steiner_alive", 13 }, .{ "_unused_16", 16 }, .{ "krasnaya_alive", 17 }, .{ "rameses_alive", 18 }, .{ "kozah_alive", 19 }, @@ -431,7 +436,7 @@ comptime { .{ "warp_gate_alive", 23 }, .{ "_unused_24", 24 }, .{ "_unused_25", 25 }, .{ "_unused_26", 26 }, .{ "czar_alive", 29 }, .{ "ghost_alive", 30 }, .{ "_unused_31", 31 }, .{ "reliant_alive", 32 }, .{ "chapter2_thread3_shown", 34 }, - .{ "_unknown_35", 35 }, .{ "yamato_alive", 36 }, + .{ "red_dragon_alive", 35 }, .{ "yamato_alive", 36 }, }; for (numbered) |named| assert(Variables.number(named[0]) == named[1]); assert(@sizeOf(Variables) == Variables.count * @sizeOf(u32)); diff --git a/src/formats/dte/source/vocabulary.zig b/src/formats/dte/source/vocabulary.zig index c6d942dd..640c9e18 100644 --- a/src/formats/dte/source/vocabulary.zig +++ b/src/formats/dte/source/vocabulary.zig @@ -8,6 +8,7 @@ const std = @import("std"); const dte = @import("../../dte.zig"); const commands = @import("../../../engine/game/executor/commands.zig"); +const environfx = @import("../../../engine/game/environfx.zig"); const gameobj = @import("../../../engine/game/gameobj.zig"); const orders = @import("../../../engine/game/ai/orders.zig"); const pilots = @import("../../../engine/game/pilots.zig"); @@ -23,12 +24,16 @@ pub const Kind = enum { head, /// A part, which mission source names by the part's own name. part, + /// An effect of a mission's space, which `SetEnvironmentFX` turns on and off + /// (`environfx.Effect`). + environment_effect, /// What a value of the kind is called in a message. pub fn noun(kind: Kind) []const u8 { return switch (kind) { .ship_type => "ship type", .head => "head movement", + .environment_effect => "environment effect", else => @tagName(kind), }; } @@ -42,6 +47,7 @@ pub fn name(kind: Kind, number: u32) ?[]const u8 { .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)), + .environment_effect => std.enums.tagName(environfx.Effect, @fromBackingInt(std.math.cast(u5, number) orelse return null)), .part => null, }; } @@ -54,6 +60,7 @@ pub fn value(kind: Kind, text: []const u8) ?u32 { .order => numberOf(orders.Order, text), .condition => numberOf(dte.Condition, text), .head => numberOf(pilots.Head, text), + .environment_effect => numberOf(environfx.Effect, text), .part => null, }; } @@ -64,7 +71,7 @@ fn numberOf(comptime Enum: type, text: []const u8) ?u32 { } /// The name of one of the game's variables (`vm.GameVariable`), where it has one that isn't a -/// placeholder such as `_unknown_6`. +/// placeholder such as `_unused_2`. 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; @@ -85,6 +92,7 @@ pub fn of(param: commands.Param) ?Kind { 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_environment_effect => .environment_effect, refers_to_pilot => .pilot, refers_to_head => .head, else => null, @@ -93,7 +101,9 @@ pub fn of(param: commands.Param) ?Kind { /// 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. +/// What `commands.Extra.refers_to` says a named constant is: an environment effect, a pilot, and a +/// head movement. +const refers_to_environment_effect = 1; const refers_to_pilot = 3; const refers_to_head = 4; @@ -115,6 +125,7 @@ test value { .{ .condition, @backingInt(dte.Condition.shot_at) }, .{ .head, @backingInt(pilots.Head.laughing) }, .{ .pilot, @backingInt(pilots.GamePilot.bandit_tigers_leader) }, + .{ .environment_effect, @backingInt(environfx.Effect.ice_field) }, }) |pair| try std.testing.expectEqual(pair[1], value(pair[0], name(pair[0], pair[1]).?).?); try std.testing.expectEqual(null, value(.ship_type, "rubber_duck")); try std.testing.expectEqual(null, value(.part, "start")); @@ -122,13 +133,13 @@ test value { 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"))); + try std.testing.expectEqual(null, variable(vm.Variables.number("_unused_2"))); } test variableNumber { try std.testing.expectEqual(vm.Variables.number("backup_available"), variableNumber("backup_available").?); // A placeholder's number is written as one, never by its name. - try std.testing.expectEqual(null, variableNumber("_unknown_6")); + try std.testing.expectEqual(null, variableNumber("_unused_2")); try std.testing.expectEqual(null, variableNumber("no_such_variable")); } @@ -139,4 +150,6 @@ test of { 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]).?); + const effect = commands.find(executor.commandIndex("SetEnvironmentFX")).?; + try std.testing.expectEqual(.environment_effect, of(effect.params[0]).?); } diff --git a/src/scripting/records/missions.zig b/src/scripting/records/missions.zig index 70e7918c..389b3c64 100644 --- a/src/scripting/records/missions.zig +++ b/src/scripting/records/missions.zig @@ -542,7 +542,7 @@ test "a campaign mission's landing, chapter news and wing pilots" { \\assert(missions[18].landing_carrier == "yamato" and missions[17].landing_carrier == "reliant") \\assert(missions[7].yamato_visit == "always" and missions[8].yamato_visit == "when_reliant_lost") \\local report = missions[11].chapter_reports[3] - \\assert(report.movie == "new_chapter2_thread3.bik" and report.unless[1] == 6 and report.sets == "chapter2_thread3_shown") + \\assert(report.movie == "new_chapter2_thread3.bik" and report.unless[1] == "nicolai_petrov_alive" and report.sets == "chapter2_thread3_shown") \\assert(missions[7].chapter_reports[1].unless[1] == "rameses_alive") \\assert(missions[6].alpha_5_pilot == "diceman" and missions[25].fort_bear_ending) \\-- A restoration's own. From 0caca189c1f4d9d3619ec4499815af818096c47a Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Tue, 13 Oct 2026 07:01:15 +0200 Subject: [PATCH 4/4] test: every pilot with a face of its own has a name The pilots' names are written by hand, while the faces table is generated from the payload, and nothing tied the two together. The test checks that each pilot whose face no earlier pilot has gets a name, and that every name is a pilot in the table, so a pilot left without a name now fails the tests. --- src/engine/game/pilots.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/engine/game/pilots.zig b/src/engine/game/pilots.zig index 8e442ee7..5325573e 100644 --- a/src/engine/game/pilots.zig +++ b/src/engine/game/pilots.zig @@ -372,6 +372,24 @@ pub const GamePilot = enum(u8) { } }; +test GamePilot { + // Every pilot whose face no pilot before it has gets a name, which scripts and mission source + // show in place of its number. The archive finds a film whatever the case of its name, so + // `Mammoth_Plt_01` and `mammoth_plt_01` are the same face. + const talking = @backingInt(Head.talking); + for (faces, 0..) |face, number| { + const repeated = for (faces[0..number]) |earlier| { + if (std.ascii.eqlIgnoreCase(earlier.films[talking], face.films[talking])) break true; + } else false; + if (!repeated and std.enums.tagName(GamePilot, @fromBackingInt(@intCast(number))) == null) { + std.debug.print("pilot {d} has a face of its own, {s}, and no name\n", .{ number, face.films[talking] }); + return error.TestUnexpectedResult; + } + } + // Every name is a pilot with a face. + for (std.enums.values(GamePilot)) |pilot| try std.testing.expect(@backingInt(pilot) < faces.len); +} + /// A pilot by its number, as scripts know it: none, one of the game's pilots by its name /// (`GamePilot`) or its number, and a pilot a mod adds by its qualified name. pub const Number = enum(u8) {