How the engine drives a CPU-controlled HAR — picking moves, mirroring the opponent's input, learning across rounds, and scaling difficulty.
This is the engine-side AI. Unrelated to the ai animation
tag, which is the launch-attack tag and has nothing to
do with the AI subsystem.
one game tick
│
▼
har_input_tick(har) ← arena_fight_loop / arena_tick
│
control_type == 5 ("AI")?
│
┌───────────────┼─────────────────┐
▼ ▼ ▼
per-tick state movement / combat decision
refresh opponent-mirror │
(ai_state_flags) (ai_update_input_ ▼
from_opponent) ai_select_move(har, opp_anim)
│
shuffle 70 anim slots,
score each by ai_flags ∩ ctx,
scale by ai_some_trigger,
category personality, distance, …,
pick first whose random_int(weight)
passes the threshold,
emit har_input_apply_move_string()
│
▼
HAR input buffer ← same path as a
human player
end of round / round changeover
│
▼
ai_learning_update(har) ← arena_fight_loop / arena_tick
│
├── nudge each move's ai_some_trigger toward
│ pilot->ap_<category>
├── nudge har->local_pref_jump / _fwd / _attack_middle
│ toward pilot->pref_jump / _fwd / _back
└── decrement har->ai_forget_accum
per-frame "this move just hit / blocked"
│
▼
har_ai_learning_related(self, anim_idx, damage)
│
└── ai_some_trigger -= damage * pilot->learning / 4
when a HAR is loaded
│
▼
har_ai_init_move_weights(har) ← har_load
│
├── seed ai_some_trigger per anim from
│ pilot->ap_<category>
└── per-move ai_move_weight = 100 * (2/3)^<set bits in ai_flags>
Three independent timers drive the system: per-tick (move selection), end-of-round (drift toward pilot personality), and on-hit (reinforcement). Together they implement a stateless-per-tick reactive selector layered over slowly-drifting per-move weights.
| Address | Name | Size | Role |
|---|---|---|---|
| 0x534a0 | ai_select_move |
2866 | Per-tick: pick a move from the HAR's 70 animation slots and feed its move_string into the input buffer. |
| 0x540d8 | ai_update_input_from_opponent |
126 | Per-tick: mirror the opponent's directional state into the AI HAR's input (with a CAT_LOW adjustment). Also caches ai_tracked_opponent_anim. |
| 0x512b0 | ai_scale_by_distance |
37 | Helper: weight * 64 / (dist + 64) for dist > 0, otherwise weight * (64 - dist) >> 6. Used to dampen weights as range increases. |
| 0x1aae0 | ai_learning_update |
623 | End-of-round: drift each move's ai_some_trigger toward the pilot's category preference and the HAR's local_pref_* toward pilot prefs. |
| 0x1ae5c | har_ai_learning_related |
173 | On-hit / on-block / on-execute: reduce ai_some_trigger for the move that just landed, scaled by pilot->learning. |
| 0x1dd60 | har_ai_init_move_weights |
645 | At HAR load: seed ai_some_trigger per move from the pilot's ap_<category> and compute initial ai_move_weight. |
| 0x494bc | pilot_apply_ai_template |
2291 | At pilot create: copy pilot_tpl[pilot_id] into the pilot, then apply global_configuration.difficulty scaling. |
| 0x54158 | ai_evaluate_attack |
5773 | Appears unused — 0 callers, no indirect refs. Looks like a dropped earlier variant of ai_select_move; carries similar per-personality logic. |
har_input_tick (0x514f0) is the AI's per-tick driver but it's not
AI-specific — it dispatches to input_read for human players and to
ai_select_move / ai_update_input_from_opponent when
control_type == 5. Other control_type values are: 1 and 2 for
local human (joystick / keyboard), 7 for replay/recording, 8 for
network, 9 for "disabled".
The AI keeps state in three places: the per-HAR struct (transient, reset on round start), the per-move slot inside the HAR (semi- persistent across rounds within a fight), and the pilot record (persistent across the whole tournament).
| Offset | Field | Type | Role |
|---|---|---|---|
| +2275 | ai_projectile_y_threshold |
char | Y screen position threshold. Below this, an opponent projectile counts as "high" (AI_VS_PROJECTILE_HIGH); above as "low". |
| +2284 | local_pref_jump |
int | Per-HAR jump preference, drifts toward pilot->pref_jump via ai_learning_update. Read by har_input_tick to weight jump decisions. |
| +2288 | local_pref_fwd |
int | Per-HAR forward preference (vs back). Drifts toward pilot->pref_fwd. |
| +2292 | ai_forget_accum |
int | Accumulator that throttles ai_learning_update. Each call: ai_forget_accum += pilot->forget; the rest of the body runs only when the accumulator passes a float threshold (rendered confusingly by Ghidra as if (!(bool)uVar6 && cVar8 == cVar7) — a Watcom FPU compare). On run, decremented by 1. |
| +2312 | ai_tracked_opponent_anim |
byte | Last-seen opponent animation id, written by ai_update_input_from_opponent. Used to detect anim changes ("opponent just started a new attack"). |
| +2316 | ai_projectile_x_cache |
int | Cached x-position of the opponent's current projectile. Used to detect "same projectile, no need to react again". |
| +2344 | ai_round_counter |
int | Per-animation completion timer. Despite the name, this is per-anim, not per-round. Counts down ticks until the current animation chains into its successor. |
| +2366 | ai_state_flags |
ushort | Packed per-tick AI mode register. See ai_state_flags layout below. |
Two more fields are AI-relevant but not AI-only: move_timeout_window
(+2310, ms window in which a buffered input is "fresh"), and
local_attack_middle (+2280, drift toward pilot->ap_middle).
A HAR has 70 animation slots; the first ~15 are non-combat (idle, walk, jump, defeat, etc.) and the rest are moves. Each slot carries its own AI metadata.
Offset (in har_animation) |
Field | Role |
|---|---|---|
| +484 | ai_flags (AI_MOVE_TRIGGERS) |
16-bit context bitmask: which situations this move applies to. Set in the editor. See enum below. |
| +486 | pos_constraint (POSITION_CONSTRAINTS) |
16-bit position gate: e.g. PC_WALL requires HAR is touching a wall. Tested by har_can_perform_move. |
| +496 | play_if_hit |
If non-zero, anim id to chain into when this move connects (the cancel-into-follow-up). Heavily weighted by ai_select_move. |
| +497 | category (CATEGORY) |
0-13: MISC / CLOSE / LOW / MEDIUM / HIGH / JUMP / PROJECTILE / BASIC / BK_HAZARD / VICTORY / SCRAP / DESTRUCTION (and a few unknown slots). The single most important per-move classifier in the AI selector. |
| +501 | damage |
Raw damage. Read by the selector as a weight multiplier. |
| +505 | move_string |
The 21-byte input pattern (e.g. P85252) the AI feeds into its input buffer once the move is picked. |
| +532 | ai_move_weight |
Initial selection weight. Set by har_ai_init_move_weights to 100 * (2/3)^N where N is the popcount of ai_flags. So move slots with many context-bits get lower weight (rarer moves). |
| +536 | ai_range_max |
Last anim-tick at which this move's hit window is active. |
| +540 | ai_range_min |
First anim-tick of the hit window. |
| +548 | ai_history |
When this move is chosen, the previous move's anim id is stored in the low 7 bits. har_ai_learning_related walks ai_history to follow chains backwards and apply ai_some_trigger adjustments to each move in the chain. Bit 7 is the in-use marker. |
| +552 | ai_some_trigger |
The "current desirability" weight, updated by both ai_learning_update (drifts toward pilot ap stat) and har_ai_learning_related (decremented when the move hits). The selector uses it inversely: iVar8 = (iVar10 * 64) / (ai_some_trigger + 64) for positive values. So high ai_some_trigger → low effective weight → less likely to pick. |
The pilot struct (428 B) carries the AI's "personality":
| Offset | Field | Role |
|---|---|---|
| +156 | difficulty |
Per-pilot difficulty (used in some paths instead of global_configuration.difficulty). |
| +159 | movement_flags |
Per-pilot movement quirks. |
| +190 | att_1 |
Low nibble = behavioural type (1: aggressive, 3: cautious, 4: counter-puncher; observed in ai_select_move). High bits drive downstream personality switches. |
| +192 | att_2 |
More personality bits (less heavily used). |
| +194 | att_3 |
More personality bits (less heavily used). |
| +200 | ap_close |
Attack-priority for CAT_CLOSE moves. |
| +202 | ap_throw |
Attack-priority for throw / CAT_VICTORY moves. |
| +204 | ap_special |
Attack-priority for CAT_JUMP moves (despite the name — see "name vs use" below). |
| +206 | ap_jump |
Attack-priority weight for an unspecified context. |
| +208 | ap_high |
Drives CAT_LOW move weight in both har_ai_init_move_weights and ai_learning_update. |
| +210 | ap_low |
Drives CAT_HIGH move weight. |
| +212 | ap_middle |
Drives CAT_MEDIUM move weight. |
| +214 | pref_jump |
Target value for har->local_pref_jump (drift each round). |
| +216 | pref_fwd |
Target value for har->local_pref_fwd. |
| +218 | pref_back |
Target for the back-bias side of the same drift. |
| +224 | learning (float) |
Per-hit reinforcement scale (har_ai_learning_related). |
| +228 | forget (float) |
Per-tick decay rate (ai_learning_update accumulator). |
ap_*field names vs use. The struct fields are named after the attack type the value represents (ap_high= "attack-priority, high-attack value"), but the code uses them cross-mapped to the category they weight:ap_high→CAT_LOWweight,ap_low→CAT_HIGH,ap_middle→CAT_MEDIUM. Bothhar_ai_init_move_weightsandai_learning_updateagree on the cross-mapping, so it's deliberate (or at least consistent) — but a future reader expecting "the pilot'sap_highvalue drives high attacks" will be surprised. Mapping table below in Phase 3 — shuffle and score.
pilot_tpl (global @ 0x821dc, type pilot_ai_template, 400 bytes)
is a struct-of-arrays of length 11 (one entry per pilot id). At
pilot-create time, pilot_apply_ai_template indexes into it with
p->pilot_id to copy the per-pilot defaults, then layers
difficulty scaling on top.
pilot_tpl[0..10] columns: stats_2_hi, stats_2_lo, stats_3_lo,
ap_throw, ap_special, ap_jump, ap_high, ap_middle, ap_low,
pref_jump_seed, pref_fwd, pref_back,
offense_seed, defense_seed, movement_flags_bits,
forget (float), learning (float),
att_1, att_2_lo, att_2_hi, att_3_lo, att_3_hi, sound_2, sound_1, sound_3
So pilot personality is data-driven: the same pilot_apply_ai_template
code runs for all 11 pilots, just indexed into a different row.
| Address | Name | Type | Role |
|---|---|---|---|
| 0x821dc | pilot_tpl |
pilot_ai_template (400 B) |
Per-pilot AI template, indexed by pilot_id. |
| 0x847ec+0xec | global_configuration.difficulty |
byte | 0-6 difficulty knob. 0/1 = handicap AI down; 2 = neutral; 4/5/6 = ramp AI up. Applied in pilot_apply_ai_template. |
| 0x90e6c | static_ai_opponent_air_height |
short | Cached opponent Y-position used in projectile-direction classification. Updated by har_input_tick's projectile branch, read in many _DAT_00090e6a >> 0x10 packed-load idioms. |
| 0x90e70 | static_ai_shuffled_moves |
char[70] | Scratch buffer holding a Fisher-Yates-shuffled permutation of move indices 0-69. Refilled at the top of every ai_select_move call. The shuffle is what makes the selector non-deterministic — without it the lowest-indexed move that scores well would always win. |
Called once per AI HAR per tick from har_input_tick. Returns void
— side-effect is feeding a move_string into the HAR's input buffer
via har_input_apply_move_string.
void ai_select_move(har *har, har_animation *opponent_anim);Build an AI_MOVE_TRIGGERS bitmask describing the current situation:
| Bit | Symbol | Meaning |
|---|---|---|
| 0x0001 | AI_GND_STATE_1 |
Ground state 1 (mid-distance) |
| 0x0002 | AI_VS_HIGH_OR_IDLE |
Opponent in CAT_HIGH or ANIM_IDLE |
| 0x0004 | AI_VS_LOW_OR_CROUCH |
Opponent in CAT_LOW or ANIM_CROUCHING |
| 0x0008 | AI_VS_MEDIUM |
Opponent in CAT_MEDIUM |
| 0x0010 | AI_VS_PROJECTILE_HIGH |
Opponent has a projectile out, classified high |
| 0x0020 | AI_GND_STATE_2 |
Ground state 2 (close) |
| 0x0040 | AI_GND_STATE_3 |
Ground state 3 (point-blank) |
| 0x0080 | AI_GND_STATE_0 |
Ground state 0 (far) |
| 0x0100 | AI_AIR_STATE_0 |
Airborne, range bucket 0 |
| 0x0200 | AI_AIR_STATE_1 |
Airborne, range bucket 1 |
| 0x0400 | AI_AIR_STATE_2 |
Airborne, range bucket 2 |
| 0x0800 | AI_AIR_STATE_3 |
Airborne, range bucket 3 |
| 0x2000 | AI_VS_PROJECTILE_LOW |
Opponent projectile classified low |
The "ground state N" / "air state N" bits derive from the AI's own
range bucket: (har->ai_state_flags << 5) >> 0xc extracts a 4-bit
value 0-3 that's then mapped to one of the four bits. So at any
given tick exactly one ground-state bit OR exactly one air-state bit
is on (depending on whether the AI is grounded), plus zero or more
opponent-context bits.
The high three bits (AI_FLAG_1000, AI_FLAG_4000, AI_FLAG_8000)
are unset in any tested code path; placeholders.
A per-tick weight scalar that the eventual random_int threshold
compares against. Starts at 7, then multiplied by:
- State / personality — different ground/air state buckets pick
different starting values (
0x23,0x1c,0x9, etc.). FLAG_AI_COMMITTED(har->rel_flagsbit) — when set, double the weight; when clear, halve it ifai_state_flags & 0x800. The AI-committed flag is itself set by other branches when the AI decides "I'm going for this combo, don't second-guess."- Opponent grounded? Doubled if opponent is in the air with a blockable move, halved if the opponent is grounded.
- Opponent blocking? Doubled if not blocking.
- Opponent stunned + RNG + difficulty — at high difficulty
(
global_configuration.difficulty > 0x5a… wait, that's read off packed-load_236_4_, so it'sdifficulty > 90?? no — see below) the AI setsHIT_UNKNOWN_20on its ownhit_flagsto bias the selector towardCAT_CLOSEfollow-ups.
About
_236_4_ >> 0x10—global_configurationoffset 236 coversdifficulty (1B)+ 3 padding bytes. The packed dword load at +236 reads bytes 236..239;>> 0x10extracts the upper word (bytes 238-239), which is unrelated todifficulty. This looks like the C source intendedglobal_configuration.something_elseat offset 238 and the Ghidra symbol_236_4_is just the dword Ghidra picked. Verifying which logical field this reads needs a finer struct map at +236.
for (i = 0; i < 70; i++) static_ai_shuffled_moves[i] = i;
fisher_yates_shuffle(static_ai_shuffled_moves, 70);
for (k = 15; k < 70; k++) { // skip non-combat slots 0-14
bVar3 = static_ai_shuffled_moves[k];
if (!har_can_perform_move(har, bVar3)) continue;
if (!(anim[bVar3].frame_flags & FLAG_HAS_FRAMES)) continue;
if (!(anim[bVar3].ai_flags & context_vector)) continue;
iVar11 = 100; // base
for (each set bit in anim[bVar3].ai_flags) iVar11 >>= 1;
if (opponent_anim_changed) iVar11 = 0xb4;
iVar11 += 1;
/* category-specific tweaks */
if (anim[bVar3].category == CAT_CLOSE) iVar10 = ...;
if (har->hit_flags & HIT_UNKNOWN_40) iVar10 *= 6;
if (opponent_grounded && in_range_window) iVar10 = ai_scale_by_distance(...);
if (FLAG_AI_COMMITTED) iVar10 = ai_scale_by_distance(iVar10, anim.damage*2);
if (att_1 == 4 && ...) iVar10 ...;
if (opponent_in_ANIM_DAMAGE) iVar10 = ai_scale_by_distance(iVar10, anim.damage*16+0x140);
iVar8 = ai_some_trigger > 0
? (iVar10 * 64) / (ai_some_trigger + 64)
: (iVar10 * 64 - iVar10 * ai_some_trigger) >> 6;
if ((anim[bVar3].ai_flags & extra_state_mask) &&
random_int((iVar8 + iVar10/8) * 2) <= iVar11 * local_34 * local_24 / 128) {
har_input_apply_move_string(har, anim[bVar3].move_string);
return; // first match wins
}
}Key properties:
- Shuffle ⇒ first-match — without the shuffle, the lowest- indexed move would dominate. With it, every move that passes the gates has a fair chance of being "first to win the random roll".
- Random threshold scales with weight — picks happen via
random_int(N) <= threshold, so larger N = lower probability.ai_some_triggerenters this denominator: highai_some_trigger→ larger N → less likely to fire. That's whyai_learning_updateincrementsai_some_triggerfor moves matching the pilot's preferred categories — the increment effectively boosts those moves' selection weight (counter- intuitively, the field name implies "trigger threshold"). har_can_perform_movegate — separate from the AI; that's the global "is this move usable right now" gate (cooldowns,force_arenascrap-/destruct-once locks, position constraints, chain ancestry, …). The AI inherits the same gating as a human player.
har_input_apply_move_string(har, har->animations[bVar3].move_string);The 21-byte string (e.g. "P85252") is read backwards
char-by-char and translated into INPUT_STATE_FLAGS values fed into
har_input_apply_mirrored:
| Char | Direction (left-facing) |
|---|---|
5 |
neutral (0x00) |
1 |
down-back (0x60) |
2 |
down (0x50) |
3 |
down-fwd (0x40) |
4 |
back (0x70) |
6 |
fwd (0x30) |
7 |
up-back (0x80) — but coded same as 8 |
8 |
up (0x80) |
9 |
up-fwd (0x20) |
P |
OR punch (0x01) |
K |
OR kick (0x02) |
So the AI doesn't bypass the input system — it just types fast.
This means a special-move motion the AI emits is the same byte
sequence a human would type, and har_can_perform_move /
input_buffer consume it identically.
A 16-bit packed register on each har (offset 0x93e). Despite the
name, it does double duty — the low byte carries the per-tick
knockback magnitude used by physics and learning code; the high
byte carries the AI mode-register that ai_select_move dispatches
on, plus a couple of standalone flags.
| Mask | Bits | Semantic |
|---|---|---|
0x007f |
0-6 | Per-tick knockback magnitude. Written every parse round by har_apply_anim_flags C 3577-3581 from flag_state.knockback: clears low byte (preserving bit 7) and stores ((knockback & 0x7f) + 10) & 0x7f into bits 0-6. Decayed by × 0.6 in har_check_blocks C 5438-5446 when a throw connects. Read by har_check_blocks C 5438 (the source of the decay) and har_ai_learning_related C 5971/5978 (scales the AI's per-throw learning rate by (other_har.ai_state_flags & 0x7f) / 100). |
0x0780 |
7-10 | AI mode register. Extracted via (ai_state_flags << 5) >> 0xc (= bits 7-10 packed into a 0..15 value). ai_select_move switches on this for ground states 0..3 (when opponent is on the ground) and air states 0..3 (when opponent is airborne). See Range bucket mapping below for which combinations writers actually emit. |
0x0800 |
11 | "Opponent in active attack window." Set when the opponent's current animation has nonzero damage or nonzero play_if_hit — i.e. an active hitting frame or a chain follow-up. Cleared once the AI commits to a move (becomes FLAG_AI_COMMITTED) or when the opponent is in hit-pause / ANIM_STUNNED. Used as both a gate (early bail to ai_update_input_from_opponent for blocking) and a multiplier on ai_select_move's decision threshold. |
0x1000 |
12 | Aerial-damage marker. Set when the HAR is mid-air in ANIM_DAMAGE near a wall edge with notable horizontal speed (corner-bounce setup) or when the damage animation's kind code is 0x11. Cleared when the HAR returns to the ground. Read by har_tick C 2477/2493 to skip the killing-blow death-recovery override (force-ANIM_DAMAGE and rubber-band hit-velocity) when the HAR is already in aerial-damage state. |
0x8000 |
15 | Dead bit. Read at har_tick C 2756 — if (ai_state_flags & 0x8000) pos_y_shft -= vertical_speed (anti-gravity, inverts the gravity step). The bit is unconditionally cleared at C 2759 every tick, and no writer in current code sets it. Verified by grepping every ai_state_flags/ai_state_flags + 1 write site in MASTER.DAT.c. The branch is unreachable — likely a feature that was wired in early development and removed before ship without deleting the read. |
Bits 9-10 (0x200 / 0x400) and 13-14 (0x2000 / 0x4000) have
no named writer or reader. The 0x780 mode-register tests treat them
as part of the mode value, so technically setting bit 9 or 10 would
push the extracted mode into the 4..7 range — but every writer only
ever emits 0x180, 0x100, 0x080, or 0x000, so the extracted
mode stays in 0..3.
The 0x780 mode register is set by har_input_tick C 43326-43339
(and its dead duplicate ai_evaluate_attack C 44754-44766) based
on the L2 distance between the two HARs:
uVar18 = isqrt((dx*dx + dy*dy) * 0x100) >> 8;
if ((combat_flags & FLAG_ON_GROUND) == FLAG_NONE) uVar18 = uVar18 * 3 / 5;
ai_state_flags = (ai_state_flags & 0xf87f) | 0x180; // default: bits 7+8
if (uVar18 < 0x8c) { ai_state_flags &= 0xf87f; byte+1 |= 1; } // → 0x100
if (uVar18 < 0x55) { ai_state_flags &= 0xf87f; byte0 |= 0x80; } // → 0x080
if (uVar18 < 0x28) { ai_state_flags &= 0xf87f; } // → 0dist (px) |
Bits set in 0x780 | Extracted mode | ai_select_move arm |
|---|---|---|---|
≥ 0x8c (≥ 140) |
0x180 |
3 | ground state 3 / air state 3 (far) |
0x55..0x8b (85..139) |
0x100 |
2 | state 2 (medium-far) |
0x28..0x54 (40..84) |
0x080 |
1 | state 1 (medium-close) |
< 0x28 (< 40) |
0 |
0 | state 0 (touching) |
Airborne HARs have their distance pre-multiplied by 3/5, biasing
the mode toward smaller values (the AI considers an airborne
opponent "closer" for ground-state selection). The mask 0xf87f
preserves bits 0-6 (knockback) and bits 11-15 (the standalone
flags) while clearing 7-10, so the mode register is rewritten from
scratch every tick.
Set / cleared inside har_input_tick only — never by flag_state
parse-round writes:
| Site | Action | Trigger |
|---|---|---|
| C 43342-43346 | clear bit 11 | unconditional, runs before the conditional set below |
| C 43349-43350 | set bit 11 | opponent's current_anim's damage or play_if_hit is nonzero |
| C 43369-43370 | clear bit 11 | a CAT_CLOSE candidate has been picked (HAR commits to a grab-window move) |
| C 43391-43393 | clear bit 11 | opponent has hit_pause_ticks != 0 or current_anim == ANIM_STUNNED |
ai_select_move C 44431 |
clear bit 11 | a candidate move has been chosen (becomes FLAG_AI_COMMITTED) |
ai_evaluate_attack C 44777, 44819 |
mirror set/clear (dead) | duplicate of the har_input_tick paths |
Read sites: har_input_tick C 43270 (early bail to
ai_update_input_from_opponent for opponent-mirroring), C 43527,
43581 (gate range-test branches); ai_select_move C 44294, 44314,
44323, 44338, 44464 (mode-register-arm modifiers — halve the
decision threshold or flip a few FLAG_AI_COMMITTED paths);
ai_evaluate_attack C 44700, 44954 (dead).
Set / cleared in the per-frame physics / damage-resolution code; no AI involvement.
| Site | Action | Trigger |
|---|---|---|
har_set_flags C 1925-1926 |
clear bit 12 | combat_flags & FLAG_ON_GROUND is set (HAR returned to ground) |
har_set_flags C 1930-1931 |
set bit 12 | current_anim == ANIM_DAMAGE and animations[9].base_animation.frame_flags & ANIM_KIND_MASK == 0x11 (specific damage-anim kind code) |
har_tick C 2949 |
set bit 12 | HAR is mid-air, in ANIM_DAMAGE, near a wall edge (pos_x_shft >> 8 < 0x14 or > 300), with ` |
Read sites: har_tick C 2477, 2493 — both gate the killing-blow
override that forces ANIM_DAMAGE and applies a rubber-band
apply_hit_velocity_and_animation(har, -5, -9, ...) when the
pilot's HP (pilot.total_value+2 >> 0x10) has gone negative. With
bit 12 set, the override is skipped — the HAR stays in its
existing aerial-damage state instead of restarting the anim.
Read at har_tick C 2756:
if ((har->ai_state_flags & 0x8000) != 0) {
har->pos_y_shft = har->pos_y_shft - har->vertical_speed; // anti-gravity step
}
*(byte *)((int)&har->ai_state_flags + 1) &= 0x7f; // unconditional clearThe bit gets cleared every har_tick and is never set anywhere in
the binary's ai_state_flags write paths. The anti-gravity branch
is unreachable in shipped code.
Called when the AI is not in a committed combo and the opponent's
move is mid-execution (ai_range_min ≤ anim_tick < ai_range_max).
Plants a 0x70 direction code (back) into the AI's input — i.e.
"start blocking" — and downgrades to 0x60 (down-back) when the
opponent is in CAT_LOW, blocking the right height.
Side effect: caches the opponent's current anim id in
ai_tracked_opponent_anim so ai_select_move can later detect a
change-of-anim without scanning anim history.
This is why a competently set up play_if_hit chain on the player's
side feels punishing against the AI: the AI starts blocking the
current attack mid-window and isn't ready for the cancel.
Called twice per arena tick — once for each AI HAR — from both
arena_fight_loop C 23321/23324 and arena_tick C 24222/24225 (the
two paths cover normal play and replay). Gate: only does work when
ai_forget_accum reaches a Watcom FPU compare threshold (rendered
by Ghidra as if (!(bool)uVar6 && cVar8 == cVar7) after the
__FSA(a, ppVar1->forget); a >= 1.0 test).
When it does run:
-
For each combat anim slot (15..69), push
ai_some_triggerone step toward the pilot field that drives that move's category. The category → offset binding is fixed by the engine and confirmed at two consumer sites:Move category Pilot offset Ghidra/pyomftools name CAT_CLOSE0xCA / 202 ap_throwCAT_VICTORY0xCC / 204 ap_specialCAT_JUMP0xCE / 206 ap_jumpCAT_LOW0xD0 / 208 ap_highCAT_HIGH0xD2 / 210 ap_lowCAT_MEDIUM0xD4 / 212 ap_middleai_some_triggershifts by ±1:+1if the pilot's preferred value is below the currentai_some_trigger,-1otherwise. So preferences drift slowly, never jump. -
Drift
local_pref_*andlocal_attack_middletoward the pilot'spref_*fields:Per-HAR field Pilot offset Ghidra/pyomftools name local_pref_jump0xD8 / 216 pref_fwdlocal_pref_fwd0xDA / 218 pref_backlocal_attack_middle0xD6 / 214 pref_jumpThe
>> 0x10shifts in the C decompile are Watcom's standard packed-dword idiom (the asm isMOV reg, dword[pilot+offset]; SAR reg, 0x10); Ghidra renders them asiVar._0_2_ = field_at_X; iVar._2_2_ = field_at_X+2; iVar >> 0x10. The actual field consumed is the one atoffset+2. -
Decrement
ai_forget_accum.
Every category-indexed read of pilot AI weights goes to an offset that doesn't quite line up with the Ghidra/pyomftools field name. Two independent functions agree on the binding:
- Direct named reads in
ai_learning_updateC 6982-6999 — the switch oncategoryindexes named fields (ap_throw,ap_high,ap_middle,ap_low,ap_jump,ap_special). - Packed-dword reads in
har_ai_init_move_weightsC 8460-8494 — each category builds an aligned dword from two adjacent fields and consumes the upper word; the offset of the upper word matches the offset the named-read site reads.
Both sites land on the offsets in the table above. The cross-
mapping (CAT_LOW → ap_high, CAT_HIGH → ap_low) is real engine
behaviour, not a Ghidra naming bug — the consistency between the
named-read and packed-read sites would have to be a coincidence
otherwise.
The simplest interpretation of the names is that they describe
what opponent state to counter, not what move category they
weight: ap_high = "preference for situations where opponent is
in a HIGH state", and the natural counter is a CAT_LOW move; same
for ap_low ↔ CAT_HIGH. (The shipped pilot template values fit:
hyper-aggressive pilots get ap_high = -5 to discourage CAT_LOW
moves — they want to swing high, not duck.) The names for the
non-paired categories (ap_throw, ap_jump, ap_special,
ap_middle) match their target categories more directly.
ap_close (offset 0xC8 / 200) is never read meaningfully by
any AI consumer — its only "read" sites are as the discarded
lower word of a packed-dword load that yields ap_throw
(C 8485-8488 in har_ai_init_move_weights) and pilot-data copy
sites (C 40098, 40818, 41053, 58964 in tournament save / load
code). It's a vestigial field; CAT_CLOSE moves are weighted by
ap_throw (offset 0xCA / 202).
The drift's only purpose is to un-do har_ai_learning_related's
on-hit decrements: hits push ai_some_trigger down (move felt
"successful enough, do it less often" — see selection-formula
discussion above), drift pulls it back toward the pilot's baseline.
Called from har_tick (C 12890), har_execute_move_string (C
190cf, 19149), and har_perform_blocking (C 1a07b) with damage
set to the relevant value (positive on hit landed, negative on hit
absorbed, etc.).
void har_ai_learning_related(har *self, uint anim_idx, int damage) {
while (anim_idx > 0 && anim_idx < 0x46) {
if (anim_idx > 0xe) { // skip non-combat
adjust = (damage + 5) * pilot->learning; // float multiply
self->animations[anim_idx].ai_some_trigger -= adjust >> 2;
}
prev = self->animations[anim_idx].ai_history & 0x7f;
if (prev == 0 || prev == anim_idx) return;
if (self->animations[prev].ai_history & 0x7f == anim_idx) return;
anim_idx = prev; // walk chain
}
}It walks the move's ai_history linkage backwards (each move
stores the anim id of the move that played before it in the low 7
bits of ai_history). For each ancestor in the chain, the
function decrements ai_some_trigger by (damage + 5) * pilot->learning / 4. Cycle-detection on the inner ai_history
prevents infinite loops if two moves point at each other.
Net effect: when a chain like jab → uppercut → super lands,
all three moves' ai_some_trigger get debited proportional to the
damage. With pilots having different learning values, "smart"
pilots adapt faster.
Runs once at HAR load (har_load C 1c38f). For each of the 70
animation slots:
-
If the slot has frames AND the move_string is short (≤ 2 bytes payload), reclassify category as
CAT_VICTORY. (Empty/stub moves are treated as victories.) -
Seed
ai_some_triggerwith a category-specific multiple of the pilot'sap_<category>:Category Formula (added to ai_some_trigger)CAT_JUMPap_jump * 3 + 10CAT_LOWap_high * 3CAT_HIGHap_low * 3CAT_MEDIUMap_low * 3(the packed read takes the wrong word? — verify)CAT_CLOSEap_close * 9 + 10CAT_VICTORYap_special * 3Same cross-mapping as
ai_learning_update. Multiplier varies per category (CLOSE × 9, JUMP × 3 + bonus 10, …) so that aggressive pilots don't end up with all categories equally weighted. -
Set
ai_move_weightto100 * (2/3)^Nwhere N ispopcount(ai_flags). This means a move with many context-bits (e.g. one that applies in 5 different states) starts with much lower selection weight than a single-context move.
global_configuration.difficulty ∈ {0, 1, 2, 3, 4, 5, 6}, applied
on top of the per-pilot template:
| Difficulty | Effect on AI pilots (is_ai != 0) |
|---|---|
| 0 | offense /= 3, defense /= 3, att_1 &= 0xf80f (clear most personality bits), att_2 &= 0xc000, att_3 &= 0xc000, ap_throw -= 0x8c, ap_special -= 0x8c. Stat decrement +5. |
| 1 | offense * 2/3, defense * 2/3. Stat decrement +2. |
| 2 | (default — no scaling) |
| 3 | (no explicit branch — falls through default) |
| 4 | offense = stats * 3/2, defense * 3/2, pref_fwd += 0x14, ap_special += 10, ap_throw += 0x14. Stat decrement -2. |
| 5 | offense = offense * 2 + 0x14, defense * 2 + 0x14, pref_fwd += 0x14, ap_special += 10, ap_throw += 0x14. Stat decrement -4. |
| 6 | offense = offense * 3 + 0x28, defense * 3 + 0x28, pref_fwd += 0x28, ap_throw += 0x1e, ap_special += 0x14. Stat decrement -7. |
Floor: offense and defense clamped to 1.
The "stat decrement" local_1c then subtracts from
p->stats[2] and p->stats[3] (the speed / agility-ish stats), so
hard difficulty raises those stats and easy difficulty lowers
them.
difficulty == 3 has no explicit branch — it inherits the default
template values. So the seven slots are really "−2, −1, 0, 0, +1,
+2, +3" relative to the template.
The three att_* shorts pack a 4-bit personality code plus five
7-bit probability fields. The personality code is consumed by
ai_select_move / har_input_tick to switch the AI's
decision-making style; the probability fields are re-rolled into
the personality code on every move execution by
har_execute_move_string C 5938-5968, so the personality drifts
between codes 0..4 according to per-pilot probabilities baked at
template-apply time.
| Field | Bits | Mask | Meaning |
|---|---|---|---|
att_1 & 0xf |
0-3 | 0x000f |
Personality code (0..7, only 0..4 reached). Read by ai_select_move, har_input_tick, ai_evaluate_attack (dead). |
att_1 & 0x7f0 |
4-10 | 0x07f0 |
P(switch to code 0) — 7-bit probability 0..127 (compared against random_int(100)). Tested first; always-fires path. |
att_1 & 0xf800 |
11-15 | 0xf800 |
Unwritten by pilot_apply_ai_template; preserved across the difficulty-0 clear (mask 0xf80f keeps these). No reader found in the binary. |
att_2 & 0x7f |
0-6 | 0x007f |
P(switch to code 1) |
att_2 & 0x3f80 |
7-13 | 0x3f80 |
P(switch to code 2) |
att_2 & 0xc000 |
14-15 | 0xc000 |
Unwritten by pilot_apply_ai_template; preserved across att_2 &= 0xc000 clears at difficulty 0 and pilot ctor. No reader found. |
att_3 & 0x7f |
0-6 | 0x007f |
P(switch to code 3) |
att_3 & 0x3f80 |
7-13 | 0x3f80 |
P(switch to code 4) |
att_3 & 0xc000 |
14-15 | 0xc000 |
Unwritten in current code; same shape as att_2 & 0xc000. |
Every time the AI fires a move (each har_execute_move_string
call), the personality code is potentially rewritten by a chain of
five conditional dice rolls against random_int(100):
ppVar7 = pilot;
if (random_int(100) < ((att_1 << 5) >> 9)) att_1 = (att_1 & 0xf0) | 0;
else if (random_int(100) < (att_2 & 0x7f)) att_1 = (att_1 & 0xf0) | 1;
else if (random_int(100) < ((att_2 << 2) >> 9)) att_1 = (att_1 & 0xf0) | 2;
else if (random_int(100) < (att_3 & 0x7f)) att_1 = (att_1 & 0xf0) | 3;
else if (random_int(100) < ((att_3 << 2) >> 9)) att_1 = (att_1 & 0xf0) | 4;
/* else preserve previous personality code */The Ghidra-rendered (short << 5) >> 9 and (short << 2) >> 9
are the standard packed-7-bit-field extractions: shift the field
into the top bits of the word, then arithmetic-shift back to
position 0. So (att_1 << 5) >> 9 == bits 4-10 of att_1 as a
7-bit value, and (att_2 << 2) >> 9 == bits 7-13.
The first hit commits and exits; the personality code stays
sticky until the next dice-roll lands. So a pilot with att_1
mid-bits = 100 is permanently in code 0 (always wins the first
roll); one with all five fields = 0 never changes from whatever
code is already there. Most shipped pilots tune the fields to
something in between.
What each code does, observed in ai_select_move /
har_input_tick switches:
| Code | Behaviour |
|---|---|
| 0 | Default — no explicit branch |
| 1 | Doubles base weight (more aggressive engagement) |
| 2 | (no explicit branch — same as 0 in current code) |
| 3 | Cautious — divides local_24 (stun threshold) by 4 and multiplies iVar17 (offense weight) by 3; also affects damage-mode tick scaling at C 43258, 44683 |
| 4 | Counter-puncher — adds extra HIT_UNKNOWN_20 bias when opponent is stunned, special CAT_CLOSE distance handling at C 44501; also affects damage scaling at C 43261, 44686 |
| 5..7 | Never reached — pilot_apply_ai_template only assigns 0..4 |
pilot_apply_ai_template C 39358-39373 writes all five
probability fields from the per-pilot template arrays:
| Pilot field bits | Template source array (C source) |
|---|---|
att_1 bits 4-10 |
att_1_local[pilot_id] & 0x7f (C 39358) |
att_2 bits 0-6 |
att_2_lo_local[pilot_id] & 0x7f (C 39362) |
att_2 bits 7-13 |
att_2_hi_local[pilot_id] & 0x7f (C 39363) |
att_3 bits 0-6 |
att_3_lo_local[pilot_id] & 0x7f (C 39367) |
att_3 bits 7-13 |
att_3_hi_local[pilot_id] & 0x7f (C 39373) |
The five *_local[] arrays are 11-element local tables seeded
from pilot_tpl at the top of the function — see
Difficulty scaling.
At difficulty == 0 the C 39414-39415 / 39420 mask resets
(att_1 &= 0xf80f, att_2 &= 0xc000, att_3 &= 0xc000) clear
all five probability fields so the AI is permanently in
personality code 0 — easy difficulty disables the switching
chain entirely.
The dice-roll explains the AI's "moodiness": within a single
fight a pilot can rotate through up to four behaviour modes,
weighted by their template. Crystal's ATT_HYPER template (C
22774-22779) is only personality-3 (cautious + offense × 3); a
mid-fight Shadow can swap between aggressive (1), counter-
punching (4), and default (0) on consecutive moves.
The five 7-bit probability fields are independent — they don't sum to 100. With every probability at 50, each move has roughly 50% chance to commit code 0, 25% chance code 1, 12.5% code 2, … which biases heavily toward earlier codes; pilots tuned to prefer later codes (cautious, counter-puncher) have to push those probabilities high to overcome the chain order.
Bits 11-15 of att_1 and bits 14-15 of att_2 / att_3
(0xf800 / 0xc000 masks) are dead — verified by
exhaustive grep of every att_1 / att_2 / att_3 access in
MASTER.DAT.c:
- The masks
0xf800/0xc000appear only as preservation bits atpilot_apply_ai_templateC 39414-39420 (difficulty-0 clear) and__pilot__ctorC 46972-46974 (object init). No OR ever sets them. - The 4 non-trivial reads (
har_execute_move_stringC 5939,har_input_tickC 43440,ai_evaluate_attackC 44868, the byte-write at C 5968) all consume either the low nibble (& 0xf, the personality code) or the mid-7-bit fields via(att << 5) >> 9and(att << 2) >> 9(bits 4-10 / 7-13). No read shifts past bit 13. - Pilot-data copies (C 40098, 40818, 41053, etc.) propagate the bits through tournament save / load, but the saved values are never consumed in-engine — they survive in PILOT.TRN files as inert tail bits.
Most likely vestigial placeholders for unfinished personality
flags (the symmetry of 0xf800 / 0xc000 / 0xc000 masks
suggests a five-personality scheme that was extended to four
and the unused bits were left in the layout).
The 400-byte pilot_ai_template global at 0x821dc holds 11
per-pilot rows that pilot_apply_ai_template copies onto its
stack and uses to seed pilot.att_*, pilot.ap_*, pilot.pref_*,
pilot.learning, pilot.forget, etc. — each field is a
column-wise array indexed by pilot.pilot_id (0..10).
The pilot_id ↔ character mapping comes from ENGLISH.DAT
strings 760-770 (and the parallel rows at 749-759, 793-803,
837-847, etc.) — each row enumerates the canonical 11-character
roster in pilot-id order:
pilot_id |
Character (1P-mode opponent) | HAR they pilot |
|---|---|---|
| 0 | Crystal | Jaguar |
| 1 | Steffan | Shadow |
| 2 | Milano | Thorn |
| 3 | Christian | Pyros |
| 4 | Shirro | Electra |
| 5 | Jean-Paul | Katana |
| 6 | Ibrahim | Shredder |
| 7 | Angel | Flail |
| 8 | Cossette | Gargoyle |
| 9 | Raven | Chronos |
| 10 | Major Kreissack | Nova (final boss) |
Generated with scripts/dump_pilot_tpl.py:
| Field | P0 | P1 | P2 | P3 | P4 | P5 | P6 | P7 | P8 | P9 | P10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
stats_2_hi |
5 | 13 | 7 | 9 | 20 | 9 | 10 | 7 | 14 | 14 | 16 |
stats_2_lo |
16 | 9 | 20 | 7 | 1 | 10 | 1 | 10 | 8 | 4 | 15 |
stats_3_lo |
9 | 8 | 4 | 15 | 8 | 11 | 20 | 13 | 8 | 12 | 16 |
ap_throw |
100 | 25 | -50 | 35 | 75 | -50 | 0 | -75 | 25 | 100 | -6 |
ap_special |
75 | 20 | -50 | 25 | 50 | 75 | 50 | 25 | -50 | 100 | -6 |
ap_jump |
-30 | 0 | -50 | 30 | -50 | 100 | -50 | 75 | 50 | 50 | 50 |
ap_high |
-50 | -75 | 50 | 0 | -50 | -50 | 50 | 50 | -25 | 50 | 50 |
ap_middle |
-50 | 50 | 50 | 20 | -50 | -50 | 50 | 50 | -25 | 50 | 50 |
ap_low |
-50 | 75 | 50 | -25 | -50 | 100 | 50 | 50 | -50 | 50 | 50 |
pref_jump_seed |
-10 | 6 | 8 | -2 | -20 | 0 | 2 | 20 | -10 | 7 | 10 |
pref_fwd |
30 | 20 | 30 | 10 | 10 | 10 | 10 | 30 | 0 | 30 | 70 |
pref_back |
10 | -9 | -3 | 10 | 10 | 0 | -10 | 5 | 10 | -7 | -50 |
offense_seed |
-4 | 7 | 4 | 8 | 6 | -7 | -6 | 10 | 0 | 9 | 18 |
defense_seed |
5 | -5 | -2 | -8 | -8 | 11 | 8 | -5 | 10 | -6 | 22 |
movement_flags |
1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 |
forget |
0.250 | 0.400 | 0.100 | 0.350 | 0.200 | 0.070 | 0.050 | 0.400 | 0.200 | 0.500 | 0.100 |
learning |
1.500 | 1.000 | 0.900 | 2.400 | 2.000 | 1.200 | 2.500 | 1.100 | 0.700 | 3.000 | 4.000 |
att_1 (mid-bits) |
30 | 40 | 20 | 20 | 15 | 20 | 40 | 50 | 50 | 30 | 40 |
att_2_lo |
10 | 60 | 30 | 15 | 5 | 10 | 5 | 5 | 5 | 40 | 60 |
att_2_hi |
10 | 30 | 40 | 0 | 5 | 20 | 5 | 50 | 5 | 0 | 30 |
att_3_lo |
40 | 0 | 0 | 30 | 20 | 30 | 50 | 10 | 5 | 0 | 55 |
att_3_hi |
20 | 0 | 0 | 10 | 4 | 45 | 7 | 30 | 5 | 0 | 90 |
sound_2 |
0 | 0 | 151 | 159 | 157 | 154 | 156 | 0 | 0 | 160 | 0 |
Field cross-reference back into pilot_apply_ai_template:
- Byte arrays at offsets 0..167 are written into pilot fields one at
a time (e.g.
p->ap_throw = (short)(char)ap_throw_local[pid]), with sign-extension (the bytes areint8). forget/learningfloats are copied straight to the pilot.att_1(mid-bitscolumn) is shifted into bits 4-10 ofpilot.att_1(P(switch to code 0)). The other five columnsatt_2_lo/att_2_hi/att_3_lo/att_3_hifeed the four remaining personality-switch probabilities — see Personality bit-fields.sound_2writespilot.sound_2(the AI's grunt-on-attack sound). P0/P1/P7/P8/P10 are silent; the rest map to specific samples.
Tournament pilots all use template index 0 (Crystal's), so the 11 templates are tuned for 1P-mode story-mode opponents. Recognisable shapes line up with each character's reputation:
- P0 Crystal —
ap_throw = 100, everything else-30..-50. Throw-only, dislikes engaging at any range; matches a first- opponent "easy" tier where she just grapples in. - P3 Christian —
learning = 2.4,defense_seed = -8,att_3_hi = 10. Glass-cannon learner — adapts fast but takes hits; Pyros's high-damage / low-armour reputation. - P4 Shirro —
ap_throw = 75,ap_special = 50,att_3_hi = 4(low chance of personality 4). Mid-tier balanced fighter on Electra. - P5 Jean-Paul —
ap_jump = 100,ap_low = 100,forget = 0.07(slow drift),learning = 1.2. Heavy jump + low specialist. Matches Katana's air-to-low pressure. - P6 Ibrahim —
ap_high/middle/low = 50(balanced),att_3_lo = 50(high chance of personality 3 = cautious),defense_seed = 8. Defensive grappler on Shredder. - P7 Angel —
pref_jump_seed = 20,ap_jump = 75,att_2_hi = 50(frequent personality-2 swaps). Jump-happy Flail pilot. - P9 Raven —
ap_throw = 100,ap_special = 100,learning = 3.0,forget = 0.5. Throw-heavy fast learner; Chronos's grappling specials with rapid adaptation. - P10 Major Kreissack —
learning = 4.0,offense_seed = 18,defense_seed = 22,pref_fwd = 70,pref_back = -50,att_3_hi = 90(near-certain personality 4 = counter-puncher). Highest learning rate, highest stat seeds, never retreats — the final-boss template, fitting Major Kreissack on Nova.
Other broad observations: P0/P4/P5 dislike middle-range moves
(ap_middle = -50); P10 is the only template with fully
asymmetric forward/backward bias; P5 has the slowest forget
(0.07) so Jean-Paul commits to learned move weights longer than
any other AI.
Refresh the table after a pilot_tpl change with:
.venv/bin/python -m scripts.dump_pilot_tpl # markdown
.venv/bin/python -m scripts.dump_pilot_tpl --csv # CSV
ai_evaluate_attack (0x54158, 5773 B) has 0 callers and
0 indirect refs — verified via xrefs_to_func ai_evaluate_attack
and a byte-pattern search for the entry address 0x54158 (LE
58 41 05 00). The body is structurally a near-clone of the
mid-section of har_input_tick: the C 44754-44766 range-bucket
setup duplicates har_input_tick C 43326-43339 instruction-for-
instruction, the C 44700 early-bail mirrors C 43270, and the
mode-register switch arms mirror ai_select_move's. Most likely
an earlier alternative implementation that was replaced and not
removed.
Recommended rename: __unused_ai_evaluate_attack — matches the
project convention applied to other dead sibling functions
(__unused_har_input_tick, __unused_bk_render_thing, etc.).
Until renamed, readers hunting for a feature ("how does the AI
evaluate counter-attacks?") should not start here — the live
behaviour is in ai_select_move and har_input_tick.
The 0x8000 bit of ai_state_flags is a similar story: read at
har_tick C 2756 (anti-gravity branch) but never set anywhere
in the binary. See ai_state_flags layout § Bit 15
for details.