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/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/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..5b3926f7 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 @@ -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)` | @@ -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,23 @@ 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 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 +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/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/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/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/mission/bind.zig b/src/engine/game/mission/bind.zig index 6cc3af69..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() } }, @@ -549,7 +545,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 +564,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/engine/game/pilots.zig b/src/engine/game/pilots.zig index d390f4ad..5325573e 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`). @@ -214,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) { 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.zig b/src/formats/dte.zig index d95ec24f..7753c786 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,25 @@ 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 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; @@ -920,6 +1075,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 +1096,27 @@ 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) }; + + /// 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); @@ -1715,6 +1895,31 @@ pub const Mission = struct { return mission.records(FormationPoint, .formation_points); } + /// 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 list = try mission.squads(); + if (squad >= list.len) return &.{}; + return SquadMember.run(try mission.squadMembers(), squad, list[squad].first_member); + } + + /// 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 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; + while (walk.next()) |point| try found.append(allocator, point); + 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`). @@ -2190,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/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..0fd74fd6 --- /dev/null +++ b/src/formats/dte/source/compile.zig @@ -0,0 +1,935 @@ +//! 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 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"); +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 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) }); + 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 {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); + } + + 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.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"); + 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 {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 {d} records of pilotstats.bin", .{dte.Ship.no_pilot}); + 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 {d}", .{dte.Ship.no_launch - 1}); + } + 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 {d}", .{dte.Ship.no_formation_point - 1}); + 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 {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); + } + } + 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 {d}", .{dte.Trigger.whole_object - 1}); + } + 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 {d} triggers at most", .{std.math.maxInt(u8)}); + 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 {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 {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 {d} halfwords", .{dte.Part.no_block - 1}); + 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 (`dte.write.Strings`), which the source's display names go in. +const Pool = struct { + strings: dte.write.Strings = .{}, + + fn add(pool: *Pool, c: *Compiler, node: Index) Error!u16 { + 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 new file mode 100644 index 00000000..87c94ec4 --- /dev/null +++ b/src/formats/dte/source/compile/code.zig @@ -0,0 +1,365 @@ +//! 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 executor = @import("../../../../engine/game/executor.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); +} + +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 (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", .{}); + 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..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,6 +24,19 @@ 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), + }; + } }; /// The name of value `number` of `kind`, where the game names it. @@ -33,17 +47,42 @@ 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, }; } +/// 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), + .environment_effect => numberOf(environfx.Effect, 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`. +/// 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; } +/// 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. @@ -53,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, @@ -61,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; @@ -75,9 +117,30 @@ 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) }, + .{ .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")); +} + 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("_unused_2")); + try std.testing.expectEqual(null, variableNumber("no_such_variable")); } test of { @@ -87,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/formats/dte/source/write.zig b/src/formats/dte/source/write.zig index 1a1f0c2b..2387958a 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 (`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,26 +427,48 @@ 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 { - if (items.len == 0) return w.writeAll(", {})\n"); - try w.writeAll(", { "); +/// `, { 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 { + 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"); + for (items) |item| try w.print(" {s},\n", .{item}); + return 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. @@ -465,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, which the - /// declaration's bracket ends. - fn write(fields: Fields, w: *Writer, column: usize) Writer.Error!void { - if (fields.list.items.len == 0) return w.writeAll("{}"); - var length = column + "{ })".len; - for (fields.list.items, 0..) |field, index| { - length += field[0].len + " = ".len + field[1].len + @as(usize, if (index > 0) ", ".len else 0); - } - if (length <= line_length) { - try w.writeAll("{ "); - for (fields.list.items, 0..) |field, index| try w.print("{s}{s} = {s}", .{ if (index > 0) ", " else "", field[0], field[1] }); - return w.writeAll(" }"); - } - try w.writeAll("{\n"); - for (fields.list.items) |field| try w.print(" {s} = {s},\n", .{ field[0], field[1] }); - try w.writeAll("}"); + /// Writes the fields as a table (`writeTable`). + fn write(fields: Fields, w: *Writer, column: usize, after: usize) Writer.Error!void { + try writeTable(w, fields.list.items, column, after); } }; 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/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 29d90d76..39dd4951 100644 --- a/src/openreliant/mission0.zig +++ b/src/openreliant/mission0.zig @@ -180,33 +180,22 @@ 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); - 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 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. + _ = 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; @@ -227,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); @@ -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,32 +241,19 @@ 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; } -/// 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/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. diff --git a/src/source_parser.zig b/src/source_parser.zig index 6c41657d..fe797856 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,215 @@ 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, 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" }) + \\ + \\-- 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 } }) + \\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 + \\ + \\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") + \\on(escort_carrier_of_the_convoy, "shot_at", + \\ function(attacker, shield_damage, hull_damage, victim, weapon_fired) + \\ return 1 + \\ end) + \\ + \\-- 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