Combat maneuvers¶
How a ship fights. The order Fight (105) runs one maneuver after another against its target: "loop the loop", "defend dodge1", "attack pursue" and seven more. Each maneuver is a script in a small language of the developers' own, which the payload compiles line by line as it first runs each line, and a table picks the next maneuver by where the two ships are.
aidefend.zig and aifight.zig define the structures, aidefend/maneuvers.zig holds the maneuvers, their scripts and the handlers of each opcode, which make maneuver-tables transcribes from the executable, and aidefend/script.zig compiles the scripts as the payload does. The build compiles every script, so a script the compiler cannot read fails it. The names below are those make ghidra-annotate gives the Ghidra project; the source file the asserts name is aidefend.cpp.
The maneuvers¶
maneuvers (0x4E1070) holds a 16-byte ManeuverRecord for each maneuver:
| Offset | Size | Field |
|---|---|---|
0x00 |
1 | The inputs it may mirror: bit 0 yaw, bit 1 pitch, bit 2 roll. Each time it starts, a random choice among them is mirrored. |
0x04 |
4 | Its script |
0x08 |
4 | Its name |
0x0C |
2 | The fewest ticks it runs |
0x0E |
2 | The most |
A script is a list of 8-byte ManeuverScriptLine records ending with one without text: the line's
text, and a pointer to its compiled instruction, null until the line first runs. "loop the loop"
reads:
Cloak(on)
SetAfterburner(off)
SetPitch(1)
SetYaw(0)
SetRoll(0)
SetSpeed(0.5)
loop:
Wait(1000)
Goto loop
and "attack pursue":
Cloak(off)
SetAfterburner(off)
loop:
If Goingtocrash
Avoid(300)
Endif
Attack(25)
Goto loop
The language¶
Each line is one command: a word, then for some commands arguments in parentheses, separated by
commas. A word is letters, digits and _ . : -; spaces and tabs around words are skipped. Commands
compare without regard to case. A line whose first word ends in : is a label.
maneuver_compile_line (0x00405010) compiles a line into maneuver_code (0x515DA4) at
maneuver_code_end: a byte of opcode, then what the command needs. It reads arguments with
script_read_arguments (0x00404F80), four at most. Where a command takes a range, one argument
gives both ends. It stops with a fatal error on a command it does not know ("Syntax error %s"),
arguments that do not open with ( or are not separated by commas ("Syntax error"), a Goto to a
label no line holds ("Invalid goto"), and an If or Else with no Endif ("no endif for if").
| Command | Opcode | Compiled |
|---|---|---|
SetYaw(a, b), SetPitch, SetRoll, SetSpeed |
0 to 3 | A range of numbers: 12 bytes |
Wait(a, b) |
4 | A range of ticks: 6 bytes |
Goto label |
5 | The label's line, found by comparing each whole line with the label and a colon: 2 bytes |
label: |
6 | 1 byte |
SetAfterburner(on), (off) |
7 | Whether the argument is on: 2 bytes |
Runaway(a, b) |
8 | A range of ticks |
Attack(a, b) |
9 | A range of ticks |
Attackmassive() |
10 | 6 bytes, no ticks written |
Outofactionsphere(a, b) |
11 | A range of ticks |
Setmirror |
12 | 1 byte |
If Goingtocrash |
13 | The condition, 0 for Goingtocrash, and the line of the matching Else or Endif: 3 bytes. For any other condition the byte is left as it was. |
Else |
14 | The line of the matching Endif: 2 bytes |
Endif |
15 | 1 byte |
Avoid(a, b) |
16 | A range of ticks |
Cloak(on), (off) |
17 | Whether the argument is on: 2 bytes |
AttackMediumFighter(a, b) |
18 | A range of ticks |
NewAttackRun(true), (false) |
19 | Whether there is one argument and it is true: 2 bytes |
RunToShip() |
20 | 6 bytes, no ticks written |
EndScript() |
21 | 6 bytes, no ticks written |
Running a maneuver¶
maneuver_handlers (0x4E0CC0) holds a start and a run routine for each opcode, either of which
may be null. maneuver_run (0x004069B0) runs the Fight order's maneuver for an update, keeping
the line it is on and whether that line waits in the order's state, a FightState:
- While no line waits, it moves to the next line, compiles it if it has not been, and calls its opcode's start routine, which returns true for an instruction that waits.
- It then calls the waiting line's run routine, which returns true while the instruction still waits.
So a script's commands run one after another in the same update until one waits, and a loop
without a waiting command in it would never return. Commands that wait keep a
timer in the state, min + random * (max - min) ticks after frame_start, which
maneuver_start_timer (0x00405B20) sets. What each does:
| Command | What it does |
|---|---|
SetYaw, SetPitch, SetRoll |
Sets the input to a random value in the range, times the pilot's turn limit, negated when the maneuver mirrors that input. |
SetSpeed |
Sets the throttle to a random value in the range. |
Wait |
Waits its ticks. |
Goto, Else |
Go on after the line they hold (maneuver_jump, 0x00405B70). |
If Goingtocrash |
Runs the lines after it when the ship is on course to hit its target, and otherwise goes on after its Else or Endif. Against a target without components, "on course" is what ai_collision_course (0x00401980) finds over 100 updates with a margin of 5000, 3500 or 2000 units by the pilot's skill (0, 1 or 2), and never for other skills; against one with components, being within 50 times the cruise speed plus both radii of the target's part. |
SetAfterburner(on) |
Keeps the afterburner lit through the maneuver, but only for a pilot whose turn limit is exactly 2, and only while a player's ship is within 50000 units. No pilot preset's limit is above 1, so as the game ships it never lights. off puts it out. |
Cloak(on) |
Cloaks the ship after 500 ticks, if its model can cloak. off uncloaks it at once. |
Setmirror |
Picks at random which of the three inputs the Set commands mirror from here on, whatever the maneuver allows. |
Runaway |
For its ticks, flies at a point a million units away from the target. |
Outofactionsphere |
For its ticks, flies to the object at the action sphere's centre. |
Attack |
For its ticks, steers at its aim point (see each update) with the pilot's turn limit and ease, at full throttle. Within 12000 units of the target, both radii aside, the throttle is instead the aim point's velocity along the ship's nose over its cruise speed, which is a quarter of the target's speed that way. When the target is behind where the ship will be in 20 updates, a pilot of skill 2 lights the afterburner. The throttle stays at least 0.5. |
Attackmassive |
Steers at its aim point at full throttle until it is within 50 times its cruise speed, plus both radii, of the target's part. |
AttackMediumFighter |
For its ticks, flies at full throttle toward where the target will be, leading it by its velocity less a tenth of the ship's, over the time the ship needs to get there. Within 10000 units of a target that is not within 18 degrees of its nose, it flies straight on instead. |
Avoid |
For its ticks, with the target ahead, pitches hard at half throttle, one way or the other by whether the target is above or below; with the target behind, flies on at full throttle. |
NewAttackRun |
Takes the way out from the target's part that is clear of its hull (ai_escape_direction, 0x00402500), or for a component that the target's model gives a firing arc, the way out the arc names, in the target's frame (firing arcs), then flies to a point 50000 units out that way from the part, or with true twice the target's radius for a target larger than 50000. The way out turns with the part of the target hanging from its root that the aimed part is, or hangs from. It burns full throttle and afterburner while the way out lies along its velocity, half throttle while it lies against it, and ends within 2000 units of the point. true also sets the ship's fighting to -1 at the start; the end sets it back to the target. |
RunToShip |
Flies to the friendly ship chosen for it. By a ship with components it ends within 5000 units of the ship's edge; by one without, it matches the ship's speed along its nose and closes by the distance past 5000. |
EndScript |
Ends the maneuver: it sets the maneuver's end to the tick before, so Fight chooses another. |
ai_escape_direction sums, over each box of the collision trees of the parts in the ship's root's
child list, every part whatever it is linked to, taken as a sphere as wide as its half-size, a push
away from the box for each whose edge is within 20000 units of the point, the harder the nearer, and
normalizes the sum.
The commands that fly to a point (maneuver_steer_to_point, 0x00405C60) go at full throttle
and steer with ai_steer at the pilot's turn limit and no ease, flags 0xB,
unless there is something to avoid, when they steer at full limit with flags 0x3. Once the point
is within 26 degrees of the nose, the ship pitches at its full rate until it is 45 degrees off,
then steers at it again, so it weaves.
Choosing a maneuver¶
fight_choose_maneuver (0x0040A3A0) chooses the next maneuver into the order's data, a
FightData, which Fight starts on its next update. It runs when Fight starts and whenever the
maneuver's time is up.
- Against a target with components: "attack massive object", for 20000 ticks.
- For a ship with components: "attack medium fighter", for 10000 ticks.
- For a ship outside the action sphere whose target is not a player's and is either within 200000
units of it or outside the sphere as well, with no player's ship within 100000 units: "out of
action sphere", for 500 ticks. The AI's first setup (
ai_first_setup,0x0040C9B0) puts the sphere around slot 0 with a radius of 220000 units; a mission'sSetActionCentre(command0x25) moves it (action_sphere_center,0x515D78, andaction_sphere_radius,0x515D74), back to 220000 for a radius of 0. - Otherwise,
fight_choose_by_position(0x0040A000): - Farther from the target than
pursue_distances(0x4E193C), 300000, 200000 or 100000 units by the pilot's skill, times the target's speed over its top speed but at least a quarter: "attack pursue". Fix: the table holds three, and the game indexes it with the skill as a signed word, reading past it or before it, so that some other skills never pursue and others always do. OpenReliant never pursues by distance for a skill outside the table. - With the target behind the ship, one time in ten, "run to ship" toward the nearest friendly ship
with components and combat class 2 or 3, unless the ship is already within 50000 units of one,
its radius aside (
fight_find_ship_to_run_to,0x00409F00). - Within 10000 units of the target: "defend runaway".
- Otherwise a random maneuver from
maneuver_choices(0x4E1918), by where the target is from the ship's nose (ahead within 60 degrees, behind more than 120 degrees off it, or abeam between), then where the ship is from the target's (ahead within 60 degrees, behind more than 96 degrees off it, or abeam between): "attack pursue" when the ship is behind the target, or when the target is ahead and the ship abeam of it, and otherwise one of "defend runaway", the three dodges and "loop the loop".
Its length is what the choice gives, or min + random % (max - min) ticks from the maneuver's
range, with the ship's own random number.
When Fight starts (order_fight_init, 0x0040A4D0) it chooses the first maneuver, draws the wait
for the first missile, sets the ship's fighting to the target, counts one more in the target's
fought_by, and zeroes the ship's recent_damage. When it starts a chosen maneuver it clears its
state, puts the script before its first line, picks the inputs to mirror, and sets the tick it
ends at.
Each update¶
Fight's update (order_fight, 0x0040A5E0) pops the order when its target is no longer valid,
chooses a new maneuver when the last one's time is up and starts one chosen, and then:
- Aims (
fight_aim,0x00409BE0). Every pilot'saim_intervalticks it aims afresh: ahead of the target whereai_lead_aim(0x00401280) can lead it with the fastest of the guns it fires together, and otherwise at the target, or at its part for a component.ai_lead_aim_with_gun(0x00401180) leads it along its heading by its speed times the time the gun's shot takes to reach it, where that is within a quarter of the gun's lifetime, three times its lifetime for a Turret Flak. Fix: the game reads each gun's turret kind as its type, so it leads every ship's shots as a Laser Cannon's; OpenReliant leads by the fastest gun's own type. Fix: for the Turret Flak the game takes the Laser Cannon's lifetime, the table's first gun's, which leads flak past the life of its own shells; OpenReliant the flak's own. The aim point's velocity is a quarter of the target's, turned by the target's per-update turn halfaim_intervaltimes. Each update the aim point moves by that velocity timesframe_duration. - Fires (
fight_fire,0x004096B0), unless the ship is cloaked, or its target is cloaked and stands within both radii and 10000 units of it (cloak_target_far,0x00463BD0), which the order's target never is. Once the pilot'spausehas passed since it last looked, it looks again: where the aim point is withinfire_spreadtimes the target's radius (or its part's) of the line along the ship's nose, and the part is within a quarter of a laser cannon's range (its speed times its lifetime), it fires for the pilot'sburstticks. A friendly ship holds its fire while a player's ship is ahead of it within 50000 units and within a tenth of that distance, plus the player's radius and 500 units, of the line along its nose. It then locks and launches its missiles, timed from the pilot'smissilesrange, and drops its countermeasures, timed from itscountermeasuresrange, while a missile homes on it (Missiles). - Calls for help (
fight_call_for_help,0x00409D10). When the target is the player, the player hit the ship last, itsrecent_damagehas reached 1.2 times its armor class, and one of its armor values is below 3 times its armor class, about half what it starts with, it zeroesrecent_damageand pushes Fight, aimed at the player, on the nearest ship of its side with combat class 1 whose order is Fight or Mill. The first milling ship in the slots takes over from any fighting ship found before it, however near; from there the nearest wins. - Cloaks (
fight_update_cloak,0x00409EC0) as the maneuver'sCloakasked. - Runs the maneuver.
The pilot¶
The Fight order and its maneuvers read the ship's pilot, a record of pilot_stats that
pilotstats.bin's tiers fill (pilots.zig):
| Field | Tier | What it does |
|---|---|---|
turn_limit |
C, first float | The most of each turning input ai_steer gives, and the scale of the inputs the Set commands give |
turn_ease |
C, second float | How far ai_steer lets a turn swing |
aim_interval |
C, the word | The ticks between aims |
fire_spread |
B | How far off the nose line the aim point may be, in the target's radii, to fire |
timings |
A | burst and pause for the guns, then the ranges for missiles and countermeasures |
values[3] |
Its skill, 0 to 2: how far off it pursues, the berth If Goingtocrash gives, and whether Attack lights the afterburner |
Unknown: what the game calls these.
In OpenReliant¶
The Fight order and every command run as described, but for multiplayer, where the host chooses the maneuvers (#55).
Where the game would stop, hang or read past its tables, OpenReliant goes on:
- Fix: a script that runs off its end ends the maneuver, so Fight chooses another, where the game stops with a syntax error.
- Fix: a loop that starts 256 lines in one update without one waiting is left for the next update, where the game hangs.
- Fix: a maneuver number past the table runs nothing, where the game reads past it.
- Fix: a range with no span gives its least, where the game divides by zero.
- Fix: Fight's init leaves a target past the objects for the update to pop, where the game takes its index as a ship's slot whatever it holds, and fights with the pilot the ship's number gives, where the game stops with "pilot not setup for %s" for a ship with no pilot record.