Skip to content

Latest commit

 

History

History
952 lines (784 loc) · 48.8 KB

File metadata and controls

952 lines (784 loc) · 48.8 KB

OMF 2097 AI System

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.


Architecture overview

                   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.


Functions

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".


State

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).

Per-HAR (transient, in struct har at runtime)

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).

Per-move (in each har_animation slot, semi-persistent)

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.

Per-pilot (persistent across tournament)

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_highCAT_LOW weight, ap_lowCAT_HIGH, ap_middleCAT_MEDIUM. Both har_ai_init_move_weights and ai_learning_update agree on the cross-mapping, so it's deliberate (or at least consistent) — but a future reader expecting "the pilot's ap_high value drives high attacks" will be surprised. Mapping table below in Phase 3 — shuffle and score.

pilot_tpl — the 11-pilot template

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.

Globals

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.

Per-tick: move selection (ai_select_move)

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);

Phase 1 — context vector

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.

Phase 2 — base weight (local_34)

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_flags bit) — when set, double the weight; when clear, halve it if ai_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's difficulty > 90?? no — see below) the AI sets HIT_UNKNOWN_20 on its own hit_flags to bias the selector toward CAT_CLOSE follow-ups.

About _236_4_ >> 0x10global_configuration offset 236 covers difficulty (1B) + 3 padding bytes. The packed dword load at +236 reads bytes 236..239; >> 0x10 extracts the upper word (bytes 238-239), which is unrelated to difficulty. This looks like the C source intended global_configuration.something_else at 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.

Phase 3 — shuffle and score

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:

  1. 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".
  2. Random threshold scales with weight — picks happen via random_int(N) <= threshold, so larger N = lower probability. ai_some_trigger enters this denominator: high ai_some_trigger → larger N → less likely to fire. That's why ai_learning_update increments ai_some_trigger for moves matching the pilot's preferred categories — the increment effectively boosts those moves' selection weight (counter- intuitively, the field name implies "trigger threshold").
  3. har_can_perform_move gate — separate from the AI; that's the global "is this move usable right now" gate (cooldowns, force_arena scrap-/destruct-once locks, position constraints, chain ancestry, …). The AI inherits the same gating as a human player.

Phase 4 — apply

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.

ai_state_flags layout

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.

Bit map

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.

Range bucket mapping

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; }                // → 0
dist (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.

Bit 11 (0x800) write paths

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).

Bit 12 (0x1000) write paths

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.

Bit 15 (0x8000) — dead read

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 clear

The 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.


Movement mirror (ai_update_input_from_opponent)

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.


End-of-round drift (ai_learning_update)

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:

  1. For each combat anim slot (15..69), push ai_some_trigger one 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_CLOSE 0xCA / 202 ap_throw
    CAT_VICTORY 0xCC / 204 ap_special
    CAT_JUMP 0xCE / 206 ap_jump
    CAT_LOW 0xD0 / 208 ap_high
    CAT_HIGH 0xD2 / 210 ap_low
    CAT_MEDIUM 0xD4 / 212 ap_middle

    ai_some_trigger shifts by ±1: +1 if the pilot's preferred value is below the current ai_some_trigger, -1 otherwise. So preferences drift slowly, never jump.

  2. Drift local_pref_* and local_attack_middle toward the pilot's pref_* fields:

    Per-HAR field Pilot offset Ghidra/pyomftools name
    local_pref_jump 0xD8 / 216 pref_fwd
    local_pref_fwd 0xDA / 218 pref_back
    local_attack_middle 0xD6 / 214 pref_jump

    The >> 0x10 shifts in the C decompile are Watcom's standard packed-dword idiom (the asm is MOV reg, dword[pilot+offset]; SAR reg, 0x10); Ghidra renders them as iVar._0_2_ = field_at_X; iVar._2_2_ = field_at_X+2; iVar >> 0x10. The actual field consumed is the one at offset+2.

  3. Decrement ai_forget_accum.

Pilot field calibration

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_update C 6982-6999 — the switch on category indexes named fields (ap_throw, ap_high, ap_middle, ap_low, ap_jump, ap_special).
  • Packed-dword reads in har_ai_init_move_weights C 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.


On-hit reinforcement (har_ai_learning_related)

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.


Initial weight setup (har_ai_init_move_weights)

Runs once at HAR load (har_load C 1c38f). For each of the 70 animation slots:

  1. 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.)

  2. Seed ai_some_trigger with a category-specific multiple of the pilot's ap_<category>:

    Category Formula (added to ai_some_trigger)
    CAT_JUMP ap_jump * 3 + 10
    CAT_LOW ap_high * 3
    CAT_HIGH ap_low * 3
    CAT_MEDIUM ap_low * 3 (the packed read takes the wrong word? — verify)
    CAT_CLOSE ap_close * 9 + 10
    CAT_VICTORY ap_special * 3

    Same 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.

  3. Set ai_move_weight to 100 * (2/3)^N where N is popcount(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.


Difficulty scaling (pilot_apply_ai_template)

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.


Personality bit-fields (att_1 / att_2 / att_3)

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.

Bit layout

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.

Personality dice-roll (har_execute_move_string C 5938-5968)

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.

Personality code → behaviour

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

Where the probabilities come from

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.

Why this matters

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 / 0xc000 appear only as preservation bits at pilot_apply_ai_template C 39414-39420 (difficulty-0 clear) and __pilot__ctor C 46972-46974 (object init). No OR ever sets them.
  • The 4 non-trivial reads (har_execute_move_string C 5939, har_input_tick C 43440, ai_evaluate_attack C 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) >> 9 and (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).


pilot_tpl per-pilot table

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 are int8).
  • forget / learning floats are copied straight to the pilot.
  • att_1 (mid-bits column) is shifted into bits 4-10 of pilot.att_1 (P(switch to code 0)). The other five columns att_2_lo/att_2_hi/att_3_lo/att_3_hi feed the four remaining personality-switch probabilities — see Personality bit-fields.
  • sound_2 writes pilot.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 Crystalap_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 Christianlearning = 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 Shirroap_throw = 75, ap_special = 50, att_3_hi = 4 (low chance of personality 4). Mid-tier balanced fighter on Electra.
  • P5 Jean-Paulap_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 Ibrahimap_high/middle/low = 50 (balanced), att_3_lo = 50 (high chance of personality 3 = cautious), defense_seed = 8. Defensive grappler on Shredder.
  • P7 Angelpref_jump_seed = 20, ap_jump = 75, att_2_hi = 50 (frequent personality-2 swaps). Jump-happy Flail pilot.
  • P9 Ravenap_throw = 100, ap_special = 100, learning = 3.0, forget = 0.5. Throw-heavy fast learner; Chronos's grappling specials with rapid adaptation.
  • P10 Major Kreissacklearning = 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

Live vs dead code

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.


What this misses / next steps