Skip to content

Weapons: estimate the post-aim muzzle position for pre-aim line-of-fire tests - #3327

Draft
eun-ice wants to merge 1 commit into
beyond-all-reason:masterfrom
eun-ice:feature/aim-from-estimate
Draft

Weapons: estimate the post-aim muzzle position for pre-aim line-of-fire tests#3327
eun-ice wants to merge 1 commit into
beyond-all-reason:masterfrom
eun-ice:feature/aim-from-estimate

Conversation

@eun-ice

@eun-ice eun-ice commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This PR improves the estimated muzzle position for the preliminary line-of-fire check before a unit shoots.

Part of #3242 (attackers stopping without being able to fire when obstructed by terrain): this makes the engine's pre-aim "can I shoot from here" answer match the real shot more closely, which is the prerequisite for any CommandAI change to keep moving. Also beyond-all-reason/Beyond-All-Reason#9055 (Rocko on a ridge).

BAR companion PR beyond-all-reason/Beyond-All-Reason#9090

Problem

Shooting happens in five phases:

  1. The engine does a preliminary line-of-fire check
  2. The unit starts aiming (rotating turrets/torsos, moving arms/guns/cannons, turning the unit) until angleGood==true
  3. The engine does an actual line-of-fire check with the muzzle position after turning
  4. Additional checks like resources etc. Happen
  5. The unit fires.

This PR improves the preliminary line-of-fire check to use a better estimate of where the muzzle position might be after aiming, at the cost of a a few multiplications and a sqrt

Currently the engine does the preliminary line-of-fire check with either a static aimFromPos given by the unit, or for Cannon/MissileLauncher/StarburstLauncher the actual muzzle position in CWeapon::TryTarget(pos, trg, preFire=false)

When the actual fire check happens after aiming (CWeapon::TryTarget(pos, trg, preFire=true) ) the actual muzzle position is used, which might be different from the estimated muzzle position before aiming.

This can lead to issues:

So neither the current muzzlePos nor the static aimFromPos is a good estimate for where the muzzle will be after aiming.

Idea

The idea of this PR is to allow unit designers to specify a tuple of values to the engine so the engine can make a better estimate of the actual muzzle pos in the preliminary check. If no such values are specified, the engine behaves as before (with the exception of using aimFromPos for Cannon/MissileLauncher/StarburstLauncher.

This PR is an AI proposed simple calculation that only adds one sqrt and a few multiplications to reach a much better estimate where the muzzle position would be after aiming.

Change

  • New optional unit def weapon tag aimFromEstimate = {pivotX, pivotY, pivotZ, lateral, forward, barrelForward, barrelUp}
    (unit space). It models the muzzle as a yaw pivot plus an offset turning with yaw
    only plus an offset turning with yaw and pitch; evaluation is one sqrt, no trig
    (Sim/Weapons/AimFromEstimate.h). The expression is linear in the five offsets,
    so the values can be fitted offline from observed (aim direction, muzzle) pairs.
  • CWeapon::GetAimFromPos(tgtPos, useMuzzle) returns the estimated post-aim muzzle
    for tgtPos when the tag is present, else aimFromPos as before. The direction fed
    into the estimate is what the aiming script would be asked for: CCannon::CalcWantedDir
    for cannons, +trajectoryHeight for missiles, straight line otherwise (new virtual
    GetWantedDirFor).
  • Cannon, MissileLauncher and StarburstLauncher no longer override GetAimFromPos to
    return the current muzzle; they follow the same rule as every other weapon type.
  • Spring.GetUnitWeaponAimFromPos(unitID, weaponNum, x, y, z) -> px, py, pz, isEstimate
    exposes the position the pre-aim test is traced from (debug widgets, tests).
  • Bug fix: Spring.GetUnitWeaponTryTarget(unitID, weaponNum, x, y, z) built
    SWeaponTarget(nullptr, pos, true), whose constructor zeroes groundPos when the
    unit is null, so it always tested (0,0,0) and returned false.
  • Units without the tag: identical behaviour to before, except the three weapon
    classes above now use the AimFromWeapon piece instead of the current muzzle for
    the pre-aim test (changelog caveat).

One tuple per weapon, also for weapons whose QueryWeapon alternates between barrels
(they get the mean muzzle, ~half the barrel spacing of residual error). Per-barrel
tuples were tried and measured: the engine re-queries QueryWeapon only right before a
shot, and whether the piece it holds at aiming time is the barrel that fires next depends
on where the script advances its counter and on burst state — Storm fires the same piece
(26/31), AK mostly the other one (35/86 same), Pawn alternates in a 4-shot pattern. No
selection rule beats the mean, so the simpler tag stays.

Validation

  • test_AimFromEstimate (new, 902 assertions): the trig-free formula against an
    explicitly rotated two-axis turret reference for four geometries × 16 yaws × 7
    pitches, plus degenerate inputs.
  • Full check target: 29/30 pass; the failing testUnitSync is the pre-existing
    duplicate build/install base-content configuration failure on this machine.
  • In-game (BAR, headless, Quicksilver Remake): fitted tuples injected via
    tweakunits. Each unit aims at ~50 ground targets around it; for every shot the
    position the pre-aim test is traced from (Spring.GetUnitWeaponAimFromPos, read
    before the turret turns) is compared with the muzzle position at ProjectileCreated.
    Columns are the mean distance in elmos between those two points:
    • aim piece (today) — current behaviour, the AimFromWeapon piece;
    • best fixed point — the least-squares optimal constant point, i.e. the best any
      hand-tuned fixed aim position could do;
    • engine estimate — this PR with the fitted tag.
      The last column counts pre-aim decisions that disagree with the real shot on a ring
      of terrain pillars raised around the unit afterwards: stall = pre-aim clear but the
      shot is blocked (unit aims and never fires), refuse = pre-aim blocked but the shot
      would be clear (unit drops a target it could hit), today → with the estimate,
      out of n targets.
unit aim piece (today) best fixed point engine estimate LOF stall/refuse today → with estimate
armllt 25.4 27.6 0.1 0/0 → 0/0 (n=52)
corlevlr 22.0 21.5 0.8 0/4 → 1/0 (n=37)
armstump 22.4 25.2 1.3 0/0 → 0/0 (n=71)
corwolv 9.8 4.1 1.3 0/0 → 0/0 (n=35)
cormart 26.3 22.0 2.0 0/0 → 0/0 (n=35)
armrock 13.6 11.1 5.8 0/2 → 0/0 (n=70)
corstorm 11.7 9.5 7.6 0/1 → 0/1 (n=19)
corak 14.4 14.1 11.7 2/0 → 2/0 (n=8)
armpw 13.4 13.5 13.5 11/0 → 2/0 (n=72)

(armrock/corstorm/corak/armpw alternate between two barrels; the mean-muzzle tuple is used, the Pawn gains nothing and the generator skips it.)

AI disclosure

Formula, fitting tool, engine change and tests were written with Claude Code
(Anthropic); the author reviewed every change and ran the validation above.

…re tests

TryTarget(preFire=false), used for target selection and by
SlowUpdate/HoldIfTargetInvalid, traced line of fire from the AimFromWeapon
piece (or, for Cannon/MissileLauncher/StarburstLauncher, the current muzzle),
while the shot itself is tested from the muzzle after the turret has turned.
Where the two disagree a unit either aims and then never fires, or drops a
target it could have hit.

Add an optional unit def weapon tag
  aimFromEstimate = {pivotX, pivotY, pivotZ, lateral, forward, barrelForward, barrelUp}
describing the muzzle as a yaw pivot plus offsets turning with yaw only and
with yaw and pitch (AimFromEstimate.h; one sqrt, no trig, linear in the
offsets so it can be fitted offline). With the tag, GetAimFromPos(tgtPos)
returns the predicted muzzle for that target using the direction the aiming
script would be asked for (CalcWantedDir for cannons, trajectoryHeight for
missiles). Without it the AimFromWeapon piece is used as before; the three
weapon classes that overrode GetAimFromPos to the current muzzle now follow
the same rule.

Weapons that alternate between barrels use one estimate for the mean muzzle:
which barrel fires next cannot be predicted from the QueryWeapon piece the
engine holds at aiming time (measured on BAR's Storm, AK and Pawn the relation
differs per script and burst state), so per-barrel entries were dropped.

Also add Spring.GetUnitWeaponAimFromPos(unitID, weaponNum, x, y, z) and fix
Spring.GetUnitWeaponTryTarget(unitID, weaponNum, x, y, z), which built
SWeaponTarget(nullptr, pos) and therefore always tested (0, 0, 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
eun-ice added a commit to eun-ice/RecoilEngine that referenced this pull request Sep 4, 2026
…fire

A mobile unit inside 0.9 * maxRange of its attack target whose weapons all
fail the preliminary line-of-fire test used to plain-return (unit target) or
stop (ground target) and retry from the same spot every SlowUpdate. With the
test now traced from the predicted muzzle (beyond-all-reason#3327) the answer is honest, but a
unit parked behind a rock or a crest still never fires (beyond-all-reason#3242).

The obvious fix, treating "in range but no solution" like "out of range" and
closing in, has a known failure mode: in a group the rear units are blocked
by the front units, walk past them, and thereby block the units they passed,
so the whole ball creeps forward. The CommandAI cannot tell the two cases
apart because the line-of-fire test only returns a bool. The plain return
has the same failure mode already: the goal set with the attack order is the
target itself, so a unit that enters range without a solution just keeps
walking, which the false positives of the old pre-aim test used to hide.

* TraceRay: add Collision::NOMOBILEFRIENDLIES, a trace-only flag (never part of
  a weapon's avoidFlags) that skips allied units that are able to move.
  Allied structures, features and terrain are still scanned. Honoured by
  TraceRay, TestCone, TestTrajectoryCone and the MissileLauncher trajectory
  scan.
* CWeapon::TryTargetRotate / TryTargetHeading take optional extra avoid flags
  for one test.
* CMobileCAI: when no weapon has a solution although the target is in range,
  test again with mobile allies ignored. If that still fails the blocker is
  static (terrain, feature, allied wall or building) and the unit keeps
  closing in like an out-of-range unit would, for unit and ground targets.
  If it passes, only an allied unit is in the way and it may move out of it,
  so the unit stops and waits instead of walking past it.
  The approach is bounded: it stops at 20% of maxRange (so artillery does not
  walk up to a target that sits behind a wall), an approach that fails
  (unreachable goal) is not retried for 10 seconds, stopToAttack units always
  wait, and the strafeToAttack, gunship, very-close and owner-rotation
  branches keep their old behaviour. Hold-position units follow the same
  logic; an explicit attack order already makes them approach an out-of-range
  target and the temp-order case is cancelled earlier as before.

Fixes beyond-all-reason#3242.
eun-ice added a commit to eun-ice/RecoilEngine that referenced this pull request Sep 5, 2026
…fire

A mobile unit inside 0.9 * maxRange of its attack target whose weapons all
fail the preliminary line-of-fire test used to plain-return (unit target) or
stop (ground target) and retry from the same spot every SlowUpdate. With the
test now traced from the predicted muzzle (beyond-all-reason#3327) the answer is honest, but a
unit parked behind a rock or a crest still never fires (beyond-all-reason#3242).

The obvious fix, treating "in range but no solution" like "out of range" and
closing in, has a known failure mode: in a group the rear units are blocked
by the front units, walk past them, and thereby block the units they passed,
so the whole ball creeps forward. The CommandAI cannot tell the two cases
apart because the line-of-fire test only returns a bool. The plain return
has the same failure mode already: the goal set with the attack order is the
target itself, so a unit that enters range without a solution just keeps
walking, which the false positives of the old pre-aim test used to hide.

* TraceRay: add Collision::NOMOBILEFRIENDLIES, a trace-only flag (never part of
  a weapon's avoidFlags) that skips allied units that are able to move.
  Allied structures, features and terrain are still scanned. Honoured by
  TraceRay, TestCone, TestTrajectoryCone and the MissileLauncher trajectory
  scan.
* CWeapon::TryTargetRotate / TryTargetHeading take optional extra avoid flags
  for one test.
* CMobileCAI: when no weapon has a solution although the target is in range,
  test again with mobile allies ignored. If that still fails the blocker is
  static (terrain, feature, allied wall or building) and the unit keeps
  closing in like an out-of-range unit would, for unit and ground targets.
  If it passes, only an allied unit is in the way and it may move out of it,
  so the unit stops and waits instead of walking past it.
  The approach is bounded: it stops at 20% of maxRange (so artillery does not
  walk up to a target that sits behind a wall), an approach that fails
  (unreachable goal) is not retried for 10 seconds, stopToAttack units always
  wait, and the strafeToAttack, gunship, very-close and owner-rotation
  branches keep their old behaviour. Hold-position units follow the same
  logic; an explicit attack order already makes them approach an out-of-range
  target and the temp-order case is cancelled earlier as before.

Fixes beyond-all-reason#3242.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant