Aetheria/Assets/Scripts/ServerShared/FireControl.cs

2349 lines
138 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
using Random = CultMath.Random;
// Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and
// nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount
// direction and arc are both derived per read from catalog data and the item's authored rotation; neither
// is ever stored on the entity.
// Cut 2 adds the reveal rule (R5) and the resolved targeting-system stats an entity fires with, both
// likewise derived on every read and never cached.
public static class FireControl
{
// The planar unit vector a hardpoint's weapon points along, derived solely from the item's authored
// rotation (R6, the same convention reaction thrusters already use) -- never from a live Unity
// transform. This is the item-rotation path Behavior.Direction now uses unconditionally, after the
// HardpointTransforms readback branch is deleted.
public static float3 MountDirection(EquippedItem item)
{
var itemDirection = item.Entity.Direction.Rotate(item.EquippableItem.Rotation);
return normalize(float3(itemDirection.x, 0, itemDirection.y));
}
// Full arc width in degrees: the per-hardpoint override when authored (nonzero), else
// GameplaySettings.FiringArc. Zero on the hardpoint means "use the default," not "no arc."
public static float ArcFor(EquippedItem item)
{
var hardpoint = item.Hardpoint;
var arc = hardpoint?.FiringArc ?? 0f;
return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;
}
// Planar bearing test (R7: the simulation is 2D, the 3D is set dressing -- target height never enters
// this). An arc of 360 degrees or more passes unconditionally, which is how a turret hardpoint
// (authored FiringArc: 360, Q1) tracks all the way around without a second type.
// Cut 5, 5.4 (Soul finding 11): a planar bearing shorter than 1e-6 -- point-blank range, target and firer
// co-located -- returns true rather than falling through to normalize's NaN. This used to live as a
// separate special case in Weapon.ArcAllowsFire; one bearing test now owns it, so a target at a weapon's
// own exact position can never fail to bear regardless of caller.
public static bool InArc(EquippedItem weapon, float3 toTarget)
{
var arc = ArcFor(weapon);
if (arc >= 360f) return true;
var planarToTarget = float3(toTarget.x, 0, toTarget.z);
if (lengthsq(planarToTarget) < 1e-6f) return true;
var planarTarget = normalize(planarToTarget);
return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f));
}
// The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon
// detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct
// hit (nothing polices that), so a null return means "not fused".
public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius)
{
var data = item?.Data as WeaponItemData;
blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f;
return blastRadius > 0f ? data.Fuse : null;
}
// The one gate on the trigger (operator rulings fire-on-solutions, controls-solution-trigger, 2026-10-06). A gun
// fires on its solution without the aim, else free along the aim if the aim is inside its arc, else it holds.
// A fused weapon fires whatever the arc says: a target it cannot bear on is no reason to keep it silent, the
// round flies along the arc-clamped aim and bursts at the target's range (Fire; operator ruling 2026-09-30,
// "the user is hoping that the arc gets the weapon close enough to still splash some damage"). A mine layer
// lays a body, not a round, and is never arc-gated. The price of a free shot at a target that does not bear is
// HitProbability's: zero.
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
{
if (FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon)) return true;
var solution = Solution(weapon, shooter, shooter.Target.Value);
return solution.Bears || solution.Free;
}
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
// a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The
// answer is Solve's own -- there is no second copy of the arming test.
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity);
// The same answer against a target the shooter's pilot has commanded but not yet applied.
public static bool Refuses(Weapon weapon, Entity shooter, Entity target) =>
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused;
// A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay.
public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData;
// The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30).
// A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its
// target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
// never one Solve refuses: a refused weapon is not a weapon it can use.
// The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) =>
AgentFires(weapon, shooter, target, minHitProbability, null);
// The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) =>
AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim);
private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim)
{
// A mine layer lays only at a designated target in range, never at nothing and never on a hit probability.
if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated;
if (FuseOf(weapon.Item, out _) == null)
return HitProbability(weapon, shooter, target, aim) >= minHitProbability;
var solution = Solve(weapon, weapon.Item, shooter, target);
return solution.Designated && solution.Outcome != FireOutcome.Refused;
}
// Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming
// (Entity.Aim -- the player's mouse or a turret's tracking), restricted by the mount's arc. The
// arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of
// the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter
// with no aim set (Aim zero) aims down the mount, the same fallback InArc takes for a zero bearing.
// The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture,
// and a target outside the arc merely priced the shot at zero.
public static float2 AimDirection(EquippedItem item, Entity shooter)
{
var mount = MountDirection(item).xz;
var look = shooter.Aim.xz;
if (lengthsq(look) < 1e-6f) return mount;
look = normalize(look);
if (InArc(item, float3(look.x, 0, look.y))) return look;
var half = radians(ArcFor(item) / 2f);
var turn = mount.x * look.y - mount.y * look.x >= 0f ? half : -half;
return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn));
}
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
else if (subject.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
}
return new GunSolution
{
Subject = subject.Entity,
Bears = bears,
Free = InArc(weapon.Item, shooter.Aim),
Direction = bears && subject.Entity != null
? TravelDirection(weapon, shooter, subject.Entity)
: AimDirection(weapon.Item, shooter)
};
}
// The aiming read, and the only reader of live gear beside the book (rulings visibility-per-capability-reveal,
// quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own
// book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and
// not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on
// every read, never cached; watching a ship reveals no gear. Any aiming choice that reads an item's class or
// stats (Combat's target-item choice, its dps) asks this; it is the item gate of Entity's aim slot (TrySelectTargetItem,
// ResolvedAim). A mount is aimed by TrySelectMount, which reads no item at all.
public static bool IsRevealed(Entity observer, EquippedItem item) =>
!item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item);
// Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online --
// not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the
// authored unaided figures, which make aiming without one a last resort rather than an alternative.
// Cut 3's roll is the only planned reader.
public static float Accuracy(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value
? system.Accuracy
: entity.ItemManager.GameplaySettings.UnaidedAccuracy;
}
public static float Resolution(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value ? system.Resolution : 1f;
}
// Cut 6d (docs/fire-control-cut.md): falls back to GameplaySettings.UnaidedPrecision, the same shape
// Accuracy/Tracking already fall back to their own Unaided* floors. The old coin-flip rule could get away
// with a bare 0 here (0 meant "never draw the aimed-item branch," a legitimate value under that rule); the
// dart-throw kernel reads Precision as a grouping tightness, and 0 would ask Sigma for an infinite, division-
// guarded group -- not the authored "spraying at the silhouette" floor the spec asks for.
public static float Precision(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value ? system.Precision : entity.ItemManager.GameplaySettings.UnaidedPrecision;
}
// Cut 5, 5.1 (Soul finding 3): falls back to GameplaySettings.UnaidedTracking, exactly the shape Accuracy
// falls back to UnaidedAccuracy. Tracking is now always positive -- Commit no longer carries a zero case.
public static float Tracking(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;
}
// Two trackings, two units. Tracking(Entity) above is the ship's targeting gear in METRES: how much changing of
// vector a shooter forgives (PDeviation's tolerance). TrackingRate is a RATE in degrees per second: how fast the
// gun's mount can swing, and it alone prices the angular term. The ship's metres never forgive the mount and the
// gun's rate never forgives the jink (ruling jink-per-ship).
//
// How fast the target sweeps across the shooter's sky, in degrees per second: the component of the relative
// velocity transverse to the line of sight over the range, floored at one metre so a target on top of the
// shooter does not read infinite. Planar, allocation-free, and the one place this angle is computed.
public static float AngularVelocity(float2 offset, float2 relativeVelocity)
{
var range = max(length(offset), 1f);
return degrees(abs(offset.x * relativeVelocity.y - offset.y * relativeVelocity.x) / (range * range));
}
public static float AngularVelocity(Entity source, Entity target) =>
AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity);
// The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the
// one product of the two. An unauthored gun is +infinity and so is its rate whatever the gear reads: a mount with no
// limit follows anything, and infinity times a gear that reads zero is not a number.
public static float TrackingRate(Weapon weapon, Entity source) =>
float.IsPositiveInfinity(weapon.Tracking)
? weapon.Tracking
: weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking;
// Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to
// aim, while the roll measured deviation against a separately-computed prediction -- two functions
// claiming to answer the same question will eventually disagree, and an AI would then aim at one point and
// be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call
// it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in
// (no shooter here leads its own motion into the shot), matching every existing call site this replaces.
public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target)
{
var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
return weapon.Velocity > .01f
? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity)
: target.Position;
}
// Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI
// reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads
// it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads
// a tick apart may differ; nothing here or anywhere else caches one.
// Cut 10 (docs/fire-control-cut.md): the hot path. Every AI and turret calls this per weapon per tick,
// and the common case is a target out of range or out of arc -- so the cheap gates run first and bail,
// exactly as they did before the diagnostics refactor. The factors are computed only for a shot that is
// actually possible, and they come from the same functions Inspect uses: one fire-control model, shared
// at the level of its factors rather than by routing the hot path through a diagnostic struct.
// HitProbabilityMatchesInspect pins that the two can never disagree.
// Cut 12.2 (docs/fire-control-cut.md): the shooter-side share of the probability -- everything Fire
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
// live, by Commit at the commit tick. 0 the moment any gate closes.
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _);
// The `out range` overload is HitProbability's own gate call -- one computation of `target.Position -
// source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly
// the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to
// drift or to have their subtraction order flipped in only one of them).
private static float PFire(Weapon weapon, Entity source, Entity target, out float range) =>
PFire(weapon, source, target, out range, out _, out _);
// The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked
// (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else.
// F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any
// gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not
// visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the
// burst reach and the range clamp all measure the same distance, so a target's height never moves one of
// them without the others.
public static bool Designated(Weapon weapon, Entity source, Entity target, out float range)
{
range = 0f;
if (target == null) return false;
range = length((target.Position - source.Position).xz);
return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range &&
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
}
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
Bears(weapon, source, target, out range, out _);
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
{
designated = Designated(weapon, source, target, out range);
return designated && InArc(weapon.Item, target.Position - source.Position);
}
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
// arc from Solve and Inspect and decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
{
inArc = Bears(weapon, source, target, out range, out designated);
if (!inArc) return 0f;
var settings = source.ItemManager.GameplaySettings;
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
return Accuracy(source) * PSensor(settings, Resolution(source), info);
}
// Cut 12.2 fix batch (F6, Soul, 2026-09-24): the one place a bearing, a silhouette and PSpread are
// assembled into a live forecast -- HitProbability and Inspect both call this instead of each carrying
// its own copy. They used to each build their own bearing from TravelDirection and the target's facing;
// a mutant that read the SHOOTER's facing instead of the target's survived in HitProbability's own copy
// (M11) while the identical mistake in Inspect's copy was caught (M22), because two functions computing
// the same thing are two places for the same bug to hide and only be found once.
// Cost fix batch (Self, 2026-09-24): Forecast reads only sil.POnHull and sil.Span, never sil.Intervals, so
// it rents the Silhouette's own scratch array from ArrayPool<Interval>.Shared and returns it before
// returning -- Silhouette itself (given this pooled buffer) allocates nothing, measured directly.
// Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive
// for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools.
// S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call"
// overclaimed -- a gated-in HitProbability call still allocates roughly 120 bytes (measured, Release; ~280
// for Inspect, which reads one more gated stat). 12.3 fix batch correction: that cost is exactly three
// GetBehavior<TargetingSystem> calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each
// one walks Entity.Equipment, a ReactiveCollection<T> whose enumerator is heap-allocated per foreach. It is
// NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates
// nothing to enumerate. With an item or a mount aimed at, ResolveAimPoint also allocates: CellsOf (a List, then
// ToArray) about 944 B per HitProbability, measured; MountCells (a LINQ Select, then ToArray) not measured. Both are on top of the three GetBehavior calls. None of this is
// Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice
// and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters
// operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the
// common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0).
// Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of
// fire, along the same travel direction, so the live price and the HUD read one evasion.
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim)
{
var travel = TravelDirection(weapon, source, target);
var evasion = Evasion(target, travel);
var bearing = Bearing(target, travel);
var buffer = ArrayPool<Interval>.Shared.Rent(targetHull.Shape.Coordinates.Length);
float pSpread;
Silhouette sil;
try
{
sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount);
var settings = source.ItemManager.GameplaySettings;
pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize);
}
finally
{
ArrayPool<Interval>.Shared.Return(buffer);
}
// sil.Intervals is the returned buffer -- cleared here so nothing outside this function can read it
// after it goes back to the pool and another caller starts overwriting it.
sil.Intervals = null;
return (sil, pSpread, evasion);
}
public static float HitProbability(Weapon weapon, Entity source, Entity target) =>
HitProbability(weapon, source, target, (AimTarget?) null);
// Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step.
public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) =>
HitProbability(weapon, source, target, (AimTarget?) aim);
// A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more.
private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim)
{
var pFire = PFire(weapon, source, target, out var range);
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
}
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
// every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible --
// which is precisely why the hot path must not be routed through it. Both read the same factor
// functions below, and PBase is HitProbability's own answer, so the gates and the product have one
// owner and the HUD cannot grow a second, subtly different model.
public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target)
{
var settings = source.ItemManager.GameplaySettings;
var diagnostic = new FireControlDiagnostic
{
HasTarget = target != null,
Accuracy = Accuracy(source),
Resolution = Resolution(source),
Precision = Precision(source),
Tracking = Tracking(source),
MinRange = weapon.MinRange,
MaxRange = weapon.Range
};
var solution = Solve(weapon, weapon.Item, source, target);
diagnostic.Outcome = solution.Outcome;
diagnostic.BurstReach = solution.BurstReach;
if (target == null) return diagnostic;
var toTarget = target.Position - source.Position;
diagnostic.Range = length(toTarget.xz);
diagnostic.Designated = solution.Designated;
diagnostic.InArc = InArc(weapon.Item, toTarget);
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution);
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim);
diagnostic.PSpread = pSpread;
diagnostic.POnHull = sil.POnHull;
diagnostic.Evasion = evasion;
diagnostic.PEvasion = PDeviation(evasion, diagnostic.Tracking);
diagnostic.AngularVelocity = AngularVelocity(source, target);
diagnostic.TrackingRate = TrackingRate(weapon, source);
diagnostic.PMount = PMount(diagnostic.AngularVelocity, diagnostic.TrackingRate);
diagnostic.PBase = HitProbability(weapon, source, target);
return diagnostic;
}
// Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the
// formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as that
// ceiling directly. Resolution 1 (the unaided fallback) yields a ceiling of 1: needs complete
// information, the floor by construction. The max guards a zero or negative authored Resolution; no
// authored value should ever reach it.
private static float InfoDemandCeiling(GameplaySettings settings, float resolution) =>
settings.TargetDetectionInfoThreshold + (1f - settings.TargetDetectionInfoThreshold) / max(resolution, 1e-3f);
private static float PSensor(GameplaySettings settings, float resolution, float info) =>
saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info));
// Whether the weapon's own barrel-dispersion cone reaches the target's actual silhouette at this range:
// weapon hardware, range-dependent, aim-point-blind. 1 for any zero-spread weapon. Cut 12.2: span comes
// from the same Silhouette pOnHull reads, so the two factors can never disagree about the target's size
// (the old bounding half-extent, max(Width, Height), is gone).
private static float PSpread(float spread, float span, float range, float cellSize)
{
if (spread <= 0) return 1f;
var halfExtent = .5f * span * cellSize;
var angularRadius = degrees(atan(halfExtent / range));
return saturate(angularRadius / (spread / 2f));
}
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the shot's world-planar travel
// direction -- PredictedIntercept minus the source's own position, planar (R7). Frozen into PendingShot
// at Fire (R10); HitProbability and Inspect recompute it fresh, at the current source/target positions,
// for their forecast. Below a length of 1e-6 (point-blank, source and intercept coincide) it falls back
// to world +z, the same fallback Apply used before this cut (`:463`).
public static float2 TravelDirection(Weapon weapon, Entity source, Entity target)
{
var toIntercept = (PredictedIntercept(weapon, source, target) - source.Position).xz;
return lengthsq(toIntercept) < 1e-6f ? float2(0, 1) : normalize(toIntercept);
}
// Cut 12.2: the only place a world direction meets a target's facing. One function, so nothing else may
// compute a bearing (the authority map's forbidden-writer rule) -- Commit and the live forecast both read
// this, never a second copy of the formula.
private static float2 Bearing(Entity target, float2 travelDirection) => normalize(target.ToSchematic(travelDirection));
// Cut 12.2: the lateral axis a bearing implies, fixed once here. Silhouette and Lane both read it instead
// of each carrying their own copy of the sign.
private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x);
// Cut 12.2 fix batch (S1, Soul's second pass): the one function that projects a unit cell onto an axis --
// the exact support of the axis-aligned unit square centred on c along `axis`, half-width
// h = (|axis.x| + |axis.y|) / 2. Silhouette's shadow (axis = ell) and Lane's own admission test (axis =
// ell) now read this SAME arithmetic instead of Lane re-deriving its own copy of centre/h -- the two used
// to differ only in that Lane's copy was a plain re-derivation, not a second formula, but "not a second
// formula" is exactly why they never drifted apart in exact arithmetic and exactly why the real defect was
// never here: it was the second, independent SlabAlongB gate below, deleted with this fix.
private static Interval Extent(float2 c, float2 axis)
{
var h = (abs(axis.x) + abs(axis.y)) / 2f;
var centre = dot(c, axis);
return new Interval { Lo = centre - h, Hi = centre + h };
}
// The one deviation shape: the share of a shot that survives `deviation` metres of miss against a shooter that
// forgives `tolerance` metres. Every deviation-like price (flight, evasion, a missile's miss) goes through it.
public static float PDeviation(float deviation, float tolerance) =>
tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance);
// The mount's factor: the share of a shot that survives a target sweeping across the sky at `angularVelocity`
// degrees per second against a gun whose mount follows `rate` degrees per second. Half at the rate, a sixteenth at
// twice it: 0.5^((omega/rate)^2). An infinite rate (an unauthored gun) gives exactly 1; a rate of zero follows
// nothing that moves.
public static float PMount(float angularVelocity, float rate)
{
if (rate <= 0f) return angularVelocity <= 0f ? 1f : 0f;
var ratio = angularVelocity / rate;
return pow(.5f, ratio * ratio);
}
// The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and
// does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon
// (flight time 0, realized deviation 0) still feels a jinking target.
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
{
var elapsed = now - shot.FireTime;
var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed;
deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection);
return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking);
}
// How far a target can and does move off the line of fire in the solution window: the term DeviationProbability
// and HitProbability both add. Behaviour (its observed unpredictability across the line of sight) realises the
// evasion and capability (its envelope's reach) caps it, so a coasting ship evades nothing whatever it could do
// and a steady burn or a dither much faster than the window averages out. A target that is not a Ship has no
// envelope. This is the one call guns and munitions make.
public static float Evasion(Entity target, float2 lineOfSight)
{
var ship = target as Ship;
return ship == null
? 0f
: Evasion(ship.Envelope, ship.Direction, ship.Manoeuvre, lineOfSight, target.ItemManager.GameplaySettings.SolutionWindow);
}
// The pure core, allocation-free. n is perpendicular to the line of sight; u is the half-width the observed
// innovation spreads over the window (0.5 * rms * T^2); the answer is that spread, capped by the half-width of
// what the envelope can reach across the line of sight. Zero spread returns before any reach is evaluated.
public static float Evasion(in ManoeuvreEnvelope envelope, float2 heading, in ManoeuvreTrack track, float2 lineOfSight, float window)
{
var los = lengthsq(lineOfSight) < 1e-12f ? float2(0, 1) : normalize(lineOfSight);
var n = float2(-los.y, los.x);
var spread = .5f * track.LateralRms(n) * window * window;
if (spread <= 0f) return 0f;
return min(.5f * (Reach(envelope, heading, n, window) + Reach(envelope, heading, -n, window)), spread);
}
// The largest displacement from the coasting path along `direction` within `window`, for a ship that turns
// kinematically (controls-turn-inertia) and thrusts within its body-frame box. Eight faces (the four thrust axes
// and the four corners of the box), each turned toward the direction at full rate and thrusting whenever it
// points within 90 degrees of it, then held; and the no-turn support of the box. Matched a brute-force search
// within 0.1% (map M26).
public static float Reach(in ManoeuvreEnvelope e, float2 heading, float2 direction, float window)
{
if (lengthsq(heading) < 1e-12f || lengthsq(direction) < 1e-12f) return 0f;
var forward = normalize(heading);
var right = forward.Rotate(ItemRotation.Clockwise);
var wanted = normalize(direction);
var beta = atan2(dot(wanted, right), dot(wanted, forward));
// The box's support along the wanted direction with no turning: thrust is always free to fire the right sign.
var c = cos(beta);
var s = sin(beta);
var best = .5f * ((c > 0f ? e.Forward * c : -e.Reverse * c) + (s > 0f ? e.Right * s : -e.Left * s)) * window * window;
best = max(best, ReachFace(e.Forward, 0f, beta, e, window));
best = max(best, ReachFace(e.Reverse, MathF.PI, beta, e, window));
best = max(best, ReachFace(e.Right, MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(e.Left, -MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Right * e.Right), atan2(e.Right, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Left * e.Left), atan2(-e.Left, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Right * e.Right), atan2(e.Right, -e.Reverse), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Left * e.Left), atan2(-e.Left, -e.Reverse), beta, e, window));
return best;
}
// One face of acceleration `a`, at body angle `faceAngle` (clockwise from forward), turned to point along the
// wanted direction at body angle `beta`, at the rate its side of the envelope allows; thrust only while the face
// points within 90 degrees of the wanted direction, so the integrand is (T - t) a cos(angle between).
private static float ReachFace(float a, float faceAngle, float beta, in ManoeuvreEnvelope e, float window)
{
if (a <= 0f) return 0f;
var turn = Wrap(beta - faceAngle);
var rate = turn > 0f ? e.Clockwise : e.CounterClockwise;
var phi0 = abs(turn);
if (rate <= 1e-4f) return phi0 <= 1e-6f ? .5f * a * window * window : 0f;
var turnTime = phi0 / rate;
var onTime = phi0 <= MathF.PI / 2f ? 0f : (phi0 - MathF.PI / 2f) / rate;
if (onTime >= window) return 0f;
var endTime = min(turnTime, window);
float Integral(float t)
{
var u = phi0 - rate * t;
return -(window - t) * sin(u) / rate - cos(u) / (rate * rate);
}
var turning = endTime > onTime ? a * (Integral(endTime) - Integral(onTime)) : 0f;
var holding = turnTime < window ? .5f * a * (window - turnTime) * (window - turnTime) : 0f;
return turning + holding;
}
private static float Wrap(float angle)
{
while (angle > MathF.PI) angle -= 2f * MathF.PI;
while (angle <= -MathF.PI) angle += 2f * MathF.PI;
return angle;
}
// The one decision about what a round does, made from the shooter's state and a target and nothing else:
// no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask
// it whether a round is refused before they spend anything -- so a refusal is decided once, by the same
// code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire.
// Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with
// BurstReach the planar distance from the shooter at which the payload is delivered (a direct round's target, a fused
// round's burst point, 0 for an untargeted direct round), which Fire's range factor reads for every outcome. Refused: a fused round whose blast
// radius (its arming distance) exceeds the weapon's Range; it cannot be fired.
public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target)
{
var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc);
// Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the
// weapon's data fields alone, resolved and frozen here like every other payload field -- never decided
// later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen
// only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius
// is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below
// still reads as "resolve this shot with Apply."
var fuse = FuseOf(item, out var blastRadius);
// Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the
// mount's arc) and carries no target -- so nothing at resolution asks whether one is still around --
// unless its target is designated (visible, in range, locked) and in arc:
// * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no
// lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing;
// * valid data but out of arc bursts at the target's range, which the range gate has already held to
// Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash.
// Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the
// first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at
// the first hull"), and bomb fishing bursts at this point only when the line is clear.
// Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot
// without a fuse keeps its target as before.
// Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and
// blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer
// than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a
// valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target
// that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance
// cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a
// burst's later rounds and a power-starved Range included).
var engaged = fuse != null && !(designated && inArc) ? null : target;
var origin = source.Position;
var travelDirection = Solution(weapon, source, target).Direction;
var arming = fuse != null ? blastRadius : 0f;
var burstPosition = engaged != null ? engaged.Position : origin;
var flightDistance = 0f;
if (fuse != null && engaged != null)
{
// Nothing bursts beyond max range: an intercept a receding target leads the round past Range is
// pulled back along its own line to Range.
var intercept = PredictedIntercept(weapon, source, engaged);
var interceptReach = length((intercept - origin).xz);
var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept;
burstPosition = Armed(origin, travelDirection, arming, point);
flightDistance = max(fireRange, arming);
}
else if (fuse != null)
{
var reach = designated ? fireRange : weapon.Range;
burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach);
flightDistance = max(reach, arming);
}
else if (engaged != null) flightDistance = fireRange;
var solution = new FireSolution
{
Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct,
PFire = pFire,
FireRange = fireRange,
Designated = designated,
Engaged = engaged,
TravelDirection = travelDirection,
BurstPosition = burstPosition,
BurstReach = length((burstPosition - origin).xz),
FlightDistance = flightDistance,
Fuse = fuse,
BlastRadius = blastRadius
};
return solution;
}
// The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed
// out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a
// contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point.
private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) =>
length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point;
// Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the
// payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for
// Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to
// presentation. Cut 5, 5.7 (Soul finding 12): does not store a predicted intercept -- Commit judges
// deviation against a straight-line projection from FireTargetPosition/FireTargetVelocity, never the
// intercept, so a stored copy decided nothing and PendingShot no longer carries one.
// Cut 4 (docs/fire-control-cut.md): damageOverride lets a continuous weapon fire a shot for less than its
// full Damage -- ConstantWeapon.Execute rolls one of these per GameplaySettings.BeamResolveInterval, for
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
// from where it left and the commit measures the target's stray from the line it was fired along.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
{
var zone = source.Zone;
if (IsMineLayer(weapon))
{
Lay(weapon, item, source);
return 0;
}
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
var target = source.Target.Value.Entity;
var now = zone.Time - age;
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
// those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10).
// Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too.
var solution = Solve(weapon, item, source, target);
if (solution.Outcome == FireOutcome.Refused) return 0;
// The mount factor is frozen with the shooter-side probability: nothing at commit time re-reads the sweep.
var pFire = solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f);
var fireRange = solution.FireRange;
var engaged = solution.Engaged;
var travelDirection = solution.TravelDirection;
var burstPosition = solution.BurstPosition;
var fuse = solution.Fuse;
var blastRadius = solution.BlastRadius;
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
{
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position - targetVelocity * age;
}
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
var aim = source.ResolvedAim;
var shot = new PendingShot
{
ShotId = zone.NextShotId(),
Source = source,
Target = engaged,
Weapon = item,
Aimed = aim.Item,
AimedMount = aim.Mount,
Damage = damageOverride ?? weapon.Damage,
FireMinRange = weapon.MinRange,
FireEffectiveRange = weapon.EffectiveRange,
FireDamageAtMaxRange = weapon.WeaponData.DamageAtMaxRange,
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread,
DamageType = weapon.WeaponData.DamageType,
PFire = pFire,
Tracking = Tracking(source),
Precision = Precision(source),
TravelDirection = travelDirection,
Spread = weapon.Spread,
FireRange = fireRange,
FireTime = now,
Speed = weapon.Velocity,
MaxRange = weapon.Range,
FireOrigin = origin,
FireTargetPosition = targetPosition,
FireTargetVelocity = targetVelocity,
ArrivalTime = now + flightTime,
// The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at
// nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of
// the moment it leaves, and a Contact round short of max range can arrive as early as it truly does.
CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon),
BurstPosition = burstPosition,
Fuse = fuse,
BlastRadius = blastRadius,
Committed = false
};
// A round whose delivery point Solve plans reads the falloff here, at that point (BurstReach); a no-lock fused round's
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
var queue = zone.PendingShots;
var place = queue.Count;
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
queue.Insert(place, shot);
return shot.ShotId;
}
// The one place a shot's damage meets range: the weapon's falloff at the planar distance its payload is delivered at,
// over the stats frozen at Fire. A delivery is never farther than the Range it was fired with (a burst is pulled back to
// it), so rounding in its distance must not read as beyond it.
private static void Deliver(ref PendingShot shot, float reach) =>
shot.Damage *= WeaponData.RangeFactor(shot.FireMinRange, shot.FireEffectiveRange, shot.MaxRange, shot.FireDamageAtMaxRange, min(reach, shot.MaxRange));
// Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the
// layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity
// plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's
// own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
// InstantWeapon has already paid. A layer whose item has no blast radius lays nothing.
public static void Lay(Weapon weapon, EquippedItem item, Entity source)
{
var zone = source.Zone;
var data = (MineLayerData) weapon.WeaponData;
if (FuseOf(item, out var blastRadius) == null)
{
zone.Log?.Invoke("A mine layer has no blast radius and lays nothing.");
return;
}
var id = zone.NextBodyId();
var half = weapon.Spread / 2f;
var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
var s = sin(angle);
var c = cos(angle);
var mount = MountDirection(item).xz;
var direction = float2(mount.x * c - mount.y * s, mount.x * s + mount.y * c);
zone.Lay(new Mine
{
Id = id,
Body = new KinematicBody { Position = source.Position.xz, Velocity = source.Velocity + direction * weapon.Velocity },
ArmingDelay = data.ArmingDelay,
FuseDelay = data.FuseDelay,
Lifetime = data.Lifetime,
BlastRadius = blastRadius,
// Mines are flat (ruling effective-range-authoring-ruled): a mine layer carries no falloff authoring
// (StatValidation.ValidateMineLayerFlat), so its figure is the AI's and the panel's at every distance it can engage.
Damage = weapon.Damage,
DamageType = weapon.WeaponData.DamageType,
Layer = source,
LayerFaction = source.Faction
});
}
// Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves
// the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left
// the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives
// its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked
// for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot.
// Resolution reads the zone and the target; there is no credit or loot for a shooter to receive.
// A shot's queue state is settled before anything that can run user code: it is committed in the list before
// ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an
// observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be
// committed, resolved or applied again on every later tick.
public static void Step(Zone zone, float dt)
{
var shots = zone.PendingShots;
if (shots.Count == 0) return;
var now = zone.Time;
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
// left to the next step.
List<(float Arrival, int Id)> arrived = null;
for (int i = 0, end = shots.Count; i < end;)
{
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever
// stage it is at (0b table). The resolution is published as a fresh miss rather than by
// rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what
// the commit decided, while ShotResolved reports what actually happened -- nothing, because the
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
end--;
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
zone.ShotResolved.OnNext(miss);
continue;
}
if (!shot.Committed && now >= shot.CommitTime)
{
shot.Outcome = Commit(zone, ref shot, now);
shot.Committed = true;
// A contact burst short of max range arrives when the round has flown that far, not at the frozen
// max-range time (CommitBurst). Never later than the frozen time.
if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn);
// Written back before ShotCommitted fires: a presenter re-reading the record there (Zone.TryGetShot) sees
// the committed ArrivalTime a contact burst has just shortened.
shots[i] = shot;
zone.ShotCommitted.OnNext(shot.Outcome);
}
if (shot.Committed && now >= shot.ArrivalTime)
{
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
}
i++;
}
if (arrived == null) return;
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
// order their rounds left.
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
foreach (var (_, id) in arrived)
{
var i = shots.FindIndex(q => q.ShotId == id);
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
zone.ShotResolved.OnNext(miss);
continue;
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
}
}
// R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has
// actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would
// be -- forgiven by the targeting system's Tracking, likewise frozen at fire.
//
// Cut 7: the short-circuit below (`p > 0f &&`) is a guard, NOT an invariant. It used to be one -- a shot
// gated to zero at fire had to draw nothing so the shared stream advanced exactly as if it had never
// queued -- but Cut 6b gave every shot its own generator, seeded from (zone, shot id) and discarded here,
// so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is
// gone with the stream it was about, and the test that asserted it became vacuous the same moment; what
// replaced it pins the live rule instead (combat touches no shared stream, on any path).
// Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is
// frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing
// (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors:
// when PFire x pDeviation is already 0, no Silhouette is built. Shared by Commit's own roll and by the
// debug HUD's forecast (TheHudEstimateIsTheCommitPrice) -- one function, one commit-time price.
public static float CommitProbability(PendingShot shot, float now, out Silhouette sil)
{
sil = default;
if (shot.Target == null) return 0f;
var pDeviation = DeviationProbability(shot, now, out _);
var p = shot.PFire * pDeviation;
if (p <= 0f) return 0f;
var bearing = Bearing(shot.Target, shot.TravelDirection);
var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData;
sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimedMount);
var settings = shot.Target.ItemManager.GameplaySettings;
return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull;
}
private static ShotOutcome Commit(Zone zone, ref PendingShot shot, float now)
{
// A fused round with no target rolls nothing: it bursts. Where is decided here, at the commit, from the
// poses of this tick (R4); Apply performs it.
if (shot.DeliveredAtCommit) return CommitBurst(zone, ref shot, now);
// Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to
// draw from the engine-wide shared generator first. A pure function of (zone identity, shot id) --
// the `| 1u` guards
// the degenerate zero seed -- so two runs of the same fight from the same galaxy seed roll identically
// no matter what the UI drew from the shared stream in between. This generator is local and dies with
// the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write
// a shared stream.
// 9.1 (docs/fire-control-cut.md): the per-shot seed is unchanged -- a pure function of
// (zone identity, shot id), Cut 6b's own rule -- but CultMath.Random draws only one xorshift
// round for NextFloat's first call, and one round does not diffuse a seed that differs from its
// neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant
// function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone
// creation, not a roll (Soul C1). SimulationDice.Mix is a standard fmix32 finalizer (MurmurHash3): this is
// a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and
// drawing once is the documented way to get a correlated first output, and a follow-up to give
// CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut.
var random = new Random(SimulationDice.Mix((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));
var p = CommitProbability(shot, now, out var sil);
var hit = p > 0f && random.NextFloat() < p;
var cell = int2.zero;
var bearing = float2.zero;
var lateral = 0f;
var shielded = false;
var shieldBroken = false;
if (hit)
{
// Cut 12.2: the same silhouette pOnHull drew its mass from -- same bearing (live, at this commit
// tick), same sigma (the frozen Precision). A shot that passed the roll always lands on metal (R3:
// one roll decides damage; the off-hull share was already priced into that roll, not resolved here
// as a second stage). The lateral draw picks where along the shadow the shot lands; Lane converts
// that (bearing, lateral) pair into the actual cell -- only the first element (the impact cell,
// nearest the shooter's side) is used here; the rest of the lane is 12.3's armour march.
bearing = Bearing(shot.Target, shot.TravelDirection);
lateral = LateralDraw(sil, random.NextFloat());
var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData;
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
try
{
var count = Lane(hullData, bearing, lateral, buffer);
// R3: a hit that passed its roll always lands on metal. With LateralDraw's half-open clamp,
// the drawn `lateral` always falls strictly inside the interval sil.POnHull priced, so Lane
// must find at least one occupied cell -- an empty lane here is not a miss to fall back on
// silently (that would put a hit marker on (0,0), which may not even be occupied); it is proof
// the draw and the lane have drifted apart.
if (count == 0)
throw new InvalidOperationException(
$"shot {shot.ShotId}: Lane found no occupied cell at bearing {bearing} lateral {lateral} " +
"after a hit passed its roll -- LateralDraw and Lane have drifted apart (R3).");
cell = buffer[0].Cell;
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
// Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse --
// Detonate decides every blast's shields live, at detonation, the host included, charged the
// entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here,
// on the whole frozen Damage.
if (shot.Fuse == null)
{
var shield = shot.Target.Shield;
var shieldActive = shield != null && shield.Item.Active.Value;
if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;
else if (shieldActive) shieldBroken = true;
}
}
// A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a
// Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A
// contact or delayed round detonates only on a hit, so its miss stays a Miss.
var proximity = shot.Fuse == WeaponFuse.Proximity;
var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss,
shielded, shieldBroken, cell, bearing, lateral, now);
if (proximity)
{
outcome.BurstPoint = shot.BurstPosition.xz;
outcome.HasBurstPoint = true;
}
return outcome;
}
// Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses
// before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with
// nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is
// read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at).
// Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs.
private static ShotOutcome CommitBurst(Zone zone, ref PendingShot shot, float now)
{
var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now);
outcome.HasBurstPoint = true;
var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz);
var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius));
// The delivery point is decided here, so the falloff is read here, at the distance the round travelled to it.
Deliver(ref shot, travelled);
if (travelled < reach)
{
outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled;
// The round arrives once it has flown that far, which is sooner than the frozen max-range arrival.
outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now);
}
else outcome.BurstPoint = shot.BurstPosition.xz;
return outcome;
}
// The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the
// max-range point) when it meets nothing first. The shooter's own hull is never a candidate.
// SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land,
// whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes.
private static float HullDistance(Zone zone, PendingShot shot, float reach)
{
var nearest = reach;
foreach (var entity in zone.Entities)
{
if (entity == shot.Source) continue;
nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection));
}
return nearest;
}
// Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from
// `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is
// already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell
// admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells
// wholly behind the origin are skipped. Only a no-lock round asks.
private static float HullEntry(Entity entity, float2 origin, float2 direction)
{
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
var start = entity.ToSchematicPoint(origin);
var bearing = Bearing(entity, direction);
var lateral = dot(start, Lateral(bearing));
var along = dot(start, bearing);
var entry = float.PositiveInfinity;
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
try
{
var count = CellsOnLine(hullData, bearing, lateral, buffer);
for (var i = 0; i < count; i++)
{
if (!(buffer[i].Exit > along)) continue;
entry = (buffer[i].Entry - along) * cellSize;
break;
}
return entry;
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
}
// R4: the commit is authoritative and immutable from here on -- this only performs what Commit already
// decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times,
// once per Unity effect).
// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step
// march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A):
// 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the
// centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's
// own contiguous cell run from its own facing cell, clipped to the penetration depth (a cell is reached
// when entry - impact.entry < penetration; the impact cell is always reached, replacing the deleted
// `> .5f` threshold at zero cost). Whatever a lane does not spend -- penetration exhausted, a gap, or the
// far side -- goes to the hull where the lane ends (Q12-3 = A), summed across every lane into one
// DamageHull call.
// F2 fix batch (Soul, 2026-09-25; operator: "I would prefer if the overlapping damage cells were diffused
// sideways to thicken the line rather than doubling up"): lanes are spaced by the shadow width a cell
// casts at this bearing -- Extent's own h, doubled -- not by one cell. At axis-aligned bearings that width
// is exactly 1 (today's spacing, unchanged); at 45 degrees it is sqrt(2). Reusing Extent (the one function
// that owns h, Silhouette's shadow and Lane's own admission test) instead of re-deriving |bx|+|by| a
// second way is the same discipline Cut 12.2's own fix batch already applied to Lane -- two derivations of
// one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral
// interval is exactly this width wide and half-open, lanes this far apart can never both admit the same
// cell: no cell is struck twice, and the footprint widens at angles instead of doubling up.
// F1 fix batch (Soul's second pass, 2026-09-25): the F2 spacing above is correct in exact arithmetic, but
// the loop that used to sit here still asked the wrong question in float: it called Lane() once per lane
// index, each call re-deriving that lane's own lateral s(li) = Lateral + (li - n) * laneSpacing by forward
// float addition, then testing s(li) against a cell's Extent(c, ell) interval independently of every other
// lane's test. Two lane lines are only ever exactly laneSpacing apart in real arithmetic; accumulating that
// offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a
// NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same
// "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself
// (S1 above), now recurring one layer up.
// Self's review (2026-09-25): the first fix for this replaced the per-lane Lane() loop with a ceil-based
// index formula that computed lane membership from a cell's own Extent directly -- but that formula and
// Lane's own half-open test (Lo <= s < Hi) were still two separate computations, agreeing only when a
// cell's own (Hi - Lo) equalled laneSpacing exactly in float. That is the identical trap one level removed,
// not removed. `Lanes` below is the actual single owner: it calls Lane() itself once per lane index, so
// Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function,
// and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula --
// see `Lanes`.
// Cut 12.3 fix batch (2026-09-25, operator ruling "one code path" -- docs/fire-control-cut.md, Cut 12.3
// status): the earlier fix batch's own survey-then-branch (ApplySequential vs. ApplyWithSharedItems) was a
// split authority -- a second reachability derivation for "is this item shared" alongside Lanes' own
// membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There
// is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a
// pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork.
//
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model
// a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every
// arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a
// proximity fuse detonates at arrival with no roll, whether the shot hit or missed
// (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a
// contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery
// locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the
// centre lane (see the fuse-point comment on the Contact/Delayed branch below).
private static void Apply(Zone zone, PendingShot shot)
{
// A committed shot at a ship is an act on its side (ruling ew-hostility-from-belief-ruled): the target provokes the shooter, hit or miss.
if (shot.Target != null && shot.Source != null && shot.Target != shot.Source) shot.Target.Provoke(shot.Source);
// A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target,
// which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not.
// Detonate is still the one blast path.
if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity)
{
Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
return;
}
switch (shot.Fuse)
{
case null:
ApplyDirectHit(shot);
return;
case WeaponFuse.Contact:
case WeaponFuse.Delayed:
ApplyBlastHit(shot);
return;
}
}
// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step
// march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A):
// 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the
// centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's
// own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact
// cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not
// spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 =
// A), summed across every lane into one DamageHull call.
// Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner
// the delayed fuse point also reads (see "The penetrator").
private static void ApplyDirectHit(PendingShot shot)
{
if (!shot.Outcome.Hit) return;
// Cut 5, 5.2 (Soul finding 4): the frozen decision, not a re-check -- a shield that recharges during
// the flight does not retroactively survive a hit that broke it at commit time.
if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();
if (shot.Outcome.Shielded)
{
Publish(shot.Target, AbsorbLayer.Shield, ImpactPoint(shot.Target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage);
return;
}
var target = shot.Target;
// Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return
// before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst
// and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half).
target.IncomingHit.OnNext(shot.Source);
// Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own --
// shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so
// turning the target after commit changes nothing here (TurningAfterCommitChangesNothing).
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var bearing = shot.Outcome.Bearing;
var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut
var laneCount = 2 * n + 1;
// F2: the shadow width at this bearing, from Extent's own h (the cell argument does not affect h).
var shadowExtent = Extent(float2.zero, Lateral(bearing));
var laneSpacing = shadowExtent.Hi - shadowExtent.Lo;
var buffers = new LaneCell[laneCount][];
for (var li = 0; li < laneCount; li++)
buffers[li] = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers);
// Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the
// delayed fuse point below -- no second reach derivation exists anywhere in this file.
for (var li = 0; li < laneCount; li++)
walked[li] = Reach(buffers[li], walked[li], shot.Penetration);
var metalLanes = 0;
for (var li = 0; li < laneCount; li++)
if (walked[li] > 0) metalLanes++;
// The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own
// guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0.
var damagePerLane = shot.Damage / metalLanes;
var hullDamage = ApplyPooled(target, buffers, walked, laneCount, damagePerLane);
for (var li = 0; li < laneCount; li++) ArrayPool<LaneCell>.Shared.Return(buffers[li]);
Publish(target, AbsorbLayer.Hull, ImpactPoint(target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
target.DamageHull(hullDamage);
}
// Where a landed direct hit meets the receiver: the impact cell's world point on its sim pose, at its height.
private static float3 ImpactPoint(Entity target, int2 cell)
{
var world = target.ToWorldPoint((float2) cell);
return float3(world.x, target.Position.y, world.y);
}
// The one publisher of an absorbed impact: once per receiver, on its Capabilities, before the damage it reports.
private static void Publish(Entity receiver, AbsorbLayer layer, float3 position, float3 direction, float magnitude, DamageType damageType) =>
receiver.Capabilities.PublishAbsorb(new AbsorbEvent(position, direction, magnitude, damageType, layer));
// A blast's impact on one entity: at the blast point (on the entity's height), travelling from the blast out through
// the entity's centre, or zero when they coincide.
private static void PublishBlast(Entity entity, float2 blast, AbsorbLayer layer, float magnitude, DamageType damageType)
{
var away = entity.Position.xz - blast;
var direction = lengthsq(away) > 0f ? normalize(away) : float2.zero;
Publish(entity, layer, float3(blast.x, entity.Position.y, blast.y), float3(direction.x, 0, direction.y), magnitude, damageType);
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit --
// on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host,
// before the blast is handed to Detonate (which then treats the host like any other entity in the radius:
// Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint).
private static void ApplyBlastHit(PendingShot shot)
{
if (!shot.Outcome.Hit) return;
var target = shot.Target;
target.IncomingHit.OnNext(shot.Source);
// "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the
// committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty,
// R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen
// Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit
// walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so
// the disc is always centred on metal.
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var bearing = shot.Outcome.Bearing;
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
float2 schematicPoint;
try
{
var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer);
var t = buffer[0].Entry;
if (shot.Fuse == WeaponFuse.Delayed)
{
var reach = Reach(buffer, walked, shot.Penetration);
t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit);
}
schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing);
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
// "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint
// cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while
// every bystander is judged against where it actually is at arrival -- the same live rule a proximity
// burst already applies to everyone.
// The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer
// the muzzle than the blast radius is detonated on at the radius, along the round's own flight line.
var hullPoint = target.ToWorldPoint(schematicPoint);
var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz;
Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
}
// Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved
// verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the
// identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always
// reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third:
// a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P
// (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch.
private static int Reach(LaneCell[] cells, int walked, float penetration)
{
if (walked == 0) return 0;
var impactEntry = cells[0].Entry;
var reach = 1;
while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++;
return reach;
}
// Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" --
// docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far.
// Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the
// instant a lane reaches a cell, exactly as before. A lane that reaches an ITEM cell instead deposits its
// own post-armour remainder into that item's pool and blocks there -- a pool of one lane included, so a
// single-cell item is this rule's own degenerate case, not a separate fast path. A pool resolves the
// moment no other unfinished lane can still reach that item down its own remaining (already-clipped) walk
// -- for an item only one lane can ever touch, that is immediately, the same instant it is deposited. Once
// resolved the item's pool is gone, so a non-convex item a lane crosses a second time (or a second lane
// arriving after a first pool already resolved) opens a brand new pool under the identical rule -- there is
// no separate "solo absorb after a forced resolve" behaviour anywhere in this function.
//
// Cycle rule (Self's default, recorded as such in the map): two lanes can cross two different shared
// (multi-cell, non-convex) items in reversed relative order -- lane A reaches item1 before item2, lane B
// reaches item2 before item1 -- so neither pool can ever become ready in each lane's own causal order and
// every remaining lane ends up blocked. When that happens (no lane can advance and no pool is
// independently ready), every CURRENTLY OPEN pool resolves at once, together, from whatever deposits it
// already holds. Each blocked lane sits in exactly one open pool, so this batch always clears every blocked
// lane in one pass, needs no ordering key, and is mirror-symmetric by construction (nothing about which
// lane is "left" or "right" ever enters the tie-break). If somehow no pool is open when every lane is
// blocked, that is a real defect -- rem would otherwise be silently dropped -- so this throws instead of
// breaking out of the loop.
private static float ApplyPooled(Entity target, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane)
{
// Pooled bookkeeping (this file's own established convention -- ArrayPool is already how Apply rents
// its own per-lane cell buffers): a shot that never crosses an item is the overwhelmingly common case,
// and must not pay a per-shot heap allocation for lane state it never needs to keep past this call.
var pointer = ArrayPool<int>.Shared.Rent(laneCount);
var rem = ArrayPool<float>.Shared.Rent(laneCount);
var finished = ArrayPool<bool>.Shared.Rent(laneCount);
var blocked = ArrayPool<bool>.Shared.Rent(laneCount);
try
{
for (var li = 0; li < laneCount; li++)
{
pointer[li] = 0;
blocked[li] = false;
finished[li] = walked[li] == 0;
rem[li] = finished[li] ? 0f : damagePerLane;
}
// Lazily allocated: a shot that never crosses an item (the overwhelmingly common case) never allocates
// a pool at all.
Dictionary<EquippedItem, List<(int lane, float amount)>> pools = null;
var hullDamage = 0f;
void FinishLane(int li)
{
finished[li] = true;
hullDamage += rem[li];
}
// "No other unfinished lane still has that item among its remaining clipped cells" -- read directly off
// each other lane's own remaining walk (from just past wherever it currently sits), never from a
// precomputed reachability table. A lane already IN this item's own pool has already arrived and does
// not need to arrive again.
bool StillReachableByAnotherLane(EquippedItem item, List<(int lane, float amount)> contributors)
{
for (var li2 = 0; li2 < laneCount; li2++)
{
if (finished[li2]) continue;
var alreadyContributed = false;
for (var k = 0; k < contributors.Count; k++)
if (contributors[k].lane == li2) { alreadyContributed = true; break; }
if (alreadyContributed) continue;
// No +1 needed when li2 is blocked at this same item's cell: `alreadyContributed` above already
// caught that case (a blocked lane is always a contributor to the item it is blocked on), so
// `pointer[li2]` itself is never this item's own cell by the time this line runs.
for (var i = pointer[li2]; i < walked[li2]; i++)
if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true;
}
return false;
}
void Resolve(EquippedItem item)
{
var contributors = pools[item];
pools.Remove(item); // a later visit to this same item opens a fresh pool under the same rule
var amounts = ArrayPool<float>.Shared.Rent(contributors.Count);
try
{
for (var k = 0; k < contributors.Count; k++) amounts[k] = contributors[k].amount;
target.ItemAbsorb(item, new Span<float>(amounts, 0, contributors.Count));
for (var k = 0; k < contributors.Count; k++)
{
var lane = contributors[k].lane;
rem[lane] = amounts[k];
blocked[lane] = false;
pointer[lane]++;
// One writer for lane completion: AdvanceLane, not here. Unblocking and advancing the
// pointer is enough -- the next call to AdvanceLane(lane), later this pass or the next while
// iteration, is the one place that checks `pointer >= walked` and calls FinishLane.
}
}
finally
{
ArrayPool<float>.Shared.Return(amounts);
}
}
void Deposit(int li, EquippedItem item)
{
pools ??= new Dictionary<EquippedItem, List<(int, float)>>();
if (!pools.TryGetValue(item, out var list)) pools[item] = list = new List<(int, float)>();
list.Add((li, rem[li]));
blocked[li] = true;
if (!StillReachableByAnotherLane(item, list)) Resolve(item);
}
bool AdvanceLane(int li)
{
var moved = false;
while (!finished[li] && !blocked[li])
{
if (pointer[li] >= walked[li])
{
FinishLane(li);
moved = true;
break;
}
var cell = buffers[li][pointer[li]].Cell;
rem[li] = target.ArmorAbsorb(cell, rem[li]);
moved = true;
var item = target.GearOccupancy[cell.x, cell.y];
if (item != null) Deposit(li, item);
else pointer[li]++;
}
return moved;
}
bool AllFinished()
{
for (var li = 0; li < laneCount; li++)
if (!finished[li]) return false;
return true;
}
while (!AllFinished())
{
var progressed = false;
for (var li = 0; li < laneCount; li++)
if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true;
if (!progressed)
{
if (pools == null || pools.Count == 0)
throw new InvalidOperationException(
"FireControl.ApplyPooled: every remaining lane is blocked but no item pool is open -- " +
"a lane's own damage would be lost rather than reaching an item or the hull.");
// Cycle rule: resolve every currently open pool at once, from whatever it already holds -- a
// snapshot of the keys, since Resolve mutates `pools` as it goes.
foreach (var item in new List<EquippedItem>(pools.Keys)) Resolve(item);
}
}
return hullDamage;
}
finally
{
ArrayPool<int>.Shared.Return(pointer);
ArrayPool<float>.Shared.Return(rem);
ArrayPool<bool>.Shared.Return(finished);
ArrayPool<bool>.Shared.Return(blocked);
}
}
// Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's
// damage split. Lane(s) is exactly this function's n = 0 case -- Lanes calls Lane(hull, b, centreLateral +
// k * laneSpacing, ...) once per lane index k, so a lane's own cell set can never disagree with what
// Lane(s) alone returns for that s: there is no second formula for "does this cell belong to this lane",
// only Lane's own half-open Extent test, called at 2n+1 different lines. This is what removes the trap a
// ceil-based index formula reintroduced one level up from Cut 12.2's own crash (see Apply's comment above):
// that formula and Lane's own test were still two computations, agreeing only when a cell's own (Hi - Lo)
// equalled the externally supplied laneSpacing exactly in float.
// What is left, once every lane's own result is individually exact, is a DIFFERENT and much narrower
// question: can two separate calls to Lane(), at two different (and individually correct) lines, still walk
// the SAME cell? Yes, rarely -- laneSpacing (F2's shadow width, one value shared by every lane) can differ
// by a few ulps from one specific cell's own computed (Hi - Lo), the same float drift Soul's second pass
// found. Lanes resolves that by ownership, not by a competing membership rule: a cell walked by more than
// one lane keeps the lane closest to the centre (ties are impossible -- equal |k| means the same lane), and
// the centre lane (k = 0) never loses a cell to a side lane. Its own final cell set is therefore always
// exactly Lane(centreLateral)'s own output, unmodified -- R3 (the centre lane always meets metal, Commit's
// own guard) and the 12.2 placement contract (HitsLandOnTheFacingEdge) hold structurally, not by chance. A
// side lane that cedes a cell simply omits it from its own walk; the walk's existing contiguity rule (a
// cell more than 1e-4 past the previous SURVIVING cell's exit ends the lane) then decides on its own whether
// the lane continues past the gap that leaves -- no new rule, the same one `Lane` already applies at a
// genuine gap. laneSpacing itself is untouched: it is still the one F2 value that places every lane's own
// query line.
public static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers)
{
var laneCount = 2 * n + 1;
var raw = new LaneCell[laneCount][];
var rawCount = new int[laneCount];
for (var li = 0; li < laneCount; li++)
{
var k = li - n;
var s = centreLateral + k * laneSpacing;
raw[li] = ArrayPool<LaneCell>.Shared.Rent(hull.Shape.Coordinates.Length);
rawCount[li] = Lane(hull, b, s, raw[li]);
}
// Ownership: whichever lane is closest to the centre keeps a cell two lanes both walked; the centre
// lane (k = 0, |k| = 0) can never be outranked.
var ownerLane = new Dictionary<int2, int>();
for (var li = 0; li < laneCount; li++)
{
var k = Math.Abs(li - n);
for (var i = 0; i < rawCount[li]; i++)
{
var cell = raw[li][i].Cell;
if (!ownerLane.TryGetValue(cell, out var curLi) || Math.Abs(curLi - n) > k)
ownerLane[cell] = li;
}
}
var walked = new int[laneCount];
for (var li = 0; li < laneCount; li++)
{
var w = 0;
for (var i = 0; i < rawCount[li]; i++)
{
if (ownerLane[raw[li][i].Cell] != li) continue; // ceded to a closer lane -- this lane's own gap
if (w > 0 && raw[li][i].Entry > buffers[li][w - 1].Exit + 1e-4f) break;
buffers[li][w++] = raw[li][i];
}
walked[li] = w;
ArrayPool<LaneCell>.Shared.Return(raw[li]);
}
return walked;
}
private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now)
{
return new ShotOutcome
{
ShotId = shot.ShotId,
Source = shot.Source,
Target = shot.Target,
Weapon = shot.Weapon,
Result = result,
Shielded = shielded,
ShieldBroken = shieldBroken,
Cell = cell,
Bearing = bearing,
Lateral = lateral,
ArrivalIn = max(0f, shot.ArrivalTime - now),
DamageType = shot.DamageType
};
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is
// an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays
// themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches. Nothing draws: every candidate entity in radius takes its share, unconditionally.
//
// For each candidate (an entity whose centre lies within radius plus half its own hull diagonal --
// CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass):
// take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull
// cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area
// of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an
// unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over
// the covered cells.
//
// Every shield decision for a blast is made here, live, charged the entity's summed shares under today's
// CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here
// too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it
// breaks and the cells take their shares.
//
// Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first"
// runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve
// its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives
// anywhere, because a disc's schedule has no walk -- no cell's share depends on another
// cell's leftover, so every deposit is known before any item resolves and there is nothing for a
// reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not
// call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares,
// and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one
// DamageHull call.
public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType)
{
// No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A
// prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a
// bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the
// shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no
// area is zero for every cell, so nothing is covered and nothing is delivered.
//
// The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from
// Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses).
foreach (var entity in zone.Entities.ToArray())
{
var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
var shape = hullData.Shape;
var centre = entity.ToSchematicPoint(worldPlanar);
var rCells = radius / cellSize;
var normaliser = PI * rCells * rCells;
// Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and
// an item's pool deposits in.
var coords = shape.Coordinates;
List<(int2 Cell, float Share)> covered = null;
var totalShare = 0f;
for (var i = 0; i < coords.Length; i++)
{
var overlap = CircleSquareOverlap(centre, rCells, coords[i]);
if (overlap <= 0f) continue;
var share = damage * overlap / normaliser;
(covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share));
totalShare += share;
}
// An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt.
if (covered == null) continue;
var shield = entity.Shield;
var shieldActive = shield != null && shield.Item.Active.Value;
if (shieldActive)
{
if (shield.CanTakeHit(damageType, totalShare))
{
PublishBlast(entity, worldPlanar, AbsorbLayer.Shield, totalShare, damageType);
shield.TakeHit(damageType, totalShare);
continue;
}
shield.Break();
}
// Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once,
// from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell
// item, exactly as it does in ApplyPooled.
Dictionary<EquippedItem, List<float>> itemPools = null;
var hullTotal = 0f;
foreach (var (cell, share) in covered)
{
var postArmor = entity.ArmorAbsorb(cell, share);
var item = entity.GearOccupancy[cell.x, cell.y];
if (item == null) { hullTotal += postArmor; continue; }
itemPools ??= new Dictionary<EquippedItem, List<float>>();
if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List<float>();
pool.Add(postArmor);
}
if (itemPools != null)
foreach (var (item, pool) in itemPools)
{
var amounts = pool.ToArray();
entity.ItemAbsorb(item, amounts);
for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i];
}
PublishBlast(entity, worldPlanar, AbsorbLayer.Hull, totalShare, damageType);
entity.DamageHull(hullTotal);
}
}
// Whether the disc reaches any occupied hull cell of the entity: Detonate's own coverage test (the same
// ToSchematicPoint, SchematicCellSize and CircleSquareOverlap), so what triggers a mine and what its blast
// reaches are one geometry.
public static bool Touches(Entity entity, float2 worldPlanar, float radius)
{
var rCells = radius / entity.ItemManager.GameplaySettings.SchematicCellSize;
var centre = entity.ToSchematicPoint(worldPlanar);
var coords = (entity.ItemManager.GetData(entity.Hull) as HullData).Shape.Coordinates;
for (var i = 0; i < coords.Length; i++)
if (CircleSquareOverlap(centre, rCells, coords[i]) > 0f) return true;
return false;
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square,
// centred on `cell` (the unit square centred on its integer coordinate, the same convention every other
// frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same
// (schematic-cell) units. Allocates nothing.
private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell)
{
var xlo = cell.x - .5f - diskCentre.x;
var xhi = cell.x + .5f - diskCentre.x;
var ylo = cell.y - .5f - diskCentre.y;
var yhi = cell.y + .5f - diskCentre.y;
return RectDiskOverlap(xlo, xhi, ylo, yhi, r);
}
// The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle
// [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the
// disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is
// min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses
// ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is
// integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2).
private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r)
{
if (r <= 0f) return 0f;
xlo = max(xlo, -r);
xhi = min(xhi, r);
if (xlo >= xhi) return 0f;
Span<float> bp = stackalloc float[6];
var n = 0;
bp[n++] = xlo;
bp[n++] = xhi;
// The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered
// length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one.
Span<float> ys = stackalloc float[2] { ylo, yhi };
for (var k = 0; k < 2; k++)
{
var y = ys[k];
if (abs(y) >= r) continue;
var xc = sqrt(r * r - y * y);
if (-xc > xlo && -xc < xhi) bp[n++] = -xc;
if (xc > xlo && xc < xhi) bp[n++] = xc;
}
// Insertion sort: n is at most 6.
for (var i = 1; i < n; i++)
{
var key = bp[i];
var j = i - 1;
while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; }
bp[j + 1] = key;
}
var area = 0f;
for (var i = 0; i < n - 1; i++)
{
var a = bp[i];
var b = bp[i + 1];
if (b - a < 1e-9f) continue;
var mid = .5f * (a + b);
var h = sqrt(max(0f, r * r - mid * mid));
// Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0)
// is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to
// the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage.
var upperIsDisc = h <= yhi;
var lowerIsDisc = -h >= ylo;
var upper = upperIsDisc ? h : yhi;
var lower = lowerIsDisc ? -h : ylo;
if (upper <= lower) continue;
float piece;
if (upperIsDisc && lowerIsDisc)
piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
else if (upperIsDisc)
piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a);
else if (lowerIsDisc)
piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
else
piece = (yhi - ylo) * (b - a);
area += piece;
}
return max(0f, area);
}
// The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r]
// first -- every caller here already clips its integration bounds into that range, so this is a guard
// against float rounding at the boundary, not a second clipping rule.
private static float AntiderivSqrt(float x, float r)
{
var xc = clamp(x, -r, r);
return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r));
}
// Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's
// reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the
// discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian
// exactly over the shadow's intervals, so an exact 1D integral does not undershoot. The floor survives as
// a design minimum on group tightness (F12-3, TheSigmaFloorHoldsAtHalfACell) -- a targeting system is never
// authored tighter than half a schematic cell's width of spread, regardless of what the exact integral
// would otherwise allow.
private const float SigmaFloor = 0.5f;
private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);
// The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed and AimedMount both HitProbability and Fire
// read) -- the cells of the mount aimed at when the aim was frozen at a mount (the shooter has not learned what is
// fitted there, so never the fitted item's cells), else the aimed item's own cell centroid when it currently occupies
// cells on the target, else the hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted.
private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed, HardpointData mount)
{
var aimedCells = mount != null ? MountCells(mount) : aimed == null ? null : CellsOf(target, aimed);
if (aimedCells != null && aimedCells.Length > 0)
return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length;
return hullData.Shape.CenterOfMass;
}
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the merged lateral shadow the
// hull casts across the bearing, and the exact 1D Gaussian mass it carries -- the one function HitProbability's
// forecast, Inspect's HUD factors and Commit's roll and placement all read. Nowhere else may compute a
// sigma, a projection or a bearing. Allocates one interval array of length N (occupied-cell count, <=128 on
// shipped hulls) plus a sort -- only on this, the gated-in, path (Cut 10's own gated-out guard is
// unaffected: HitProbability and CommitProbability both bail on a zero shooter/deviation factor before
// this is ever called).
// Cost fix batch (Self, 2026-09-24): `buffer`, when supplied, replaces the `new Interval[coords.Length]`
// allocation -- the caller owns renting and returning it (ArrayPool<Interval>.Shared, sized to at least
// coords.Length). Only HitProbability's and Inspect's forecast reads POnHull/Span and is done with the
// array before returning, so only they pool it; Commit keeps its own array alive across the LateralDraw
// call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be
// larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length,
// never buffer.Length.
public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, HardpointData mount = null)
{
var ell = Lateral(bearing);
var coords = hull.Shape.Coordinates;
var intervals = buffer ?? new Interval[coords.Length];
for (var i = 0; i < coords.Length; i++)
intervals[i] = Extent((float2) coords[i], ell);
Array.Sort(intervals, 0, coords.Length);
// Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept
// interval whenever its own Lo does not exceed that interval's Hi.
var count = 0;
for (var i = 0; i < coords.Length; i++)
{
if (count > 0 && intervals[i].Lo <= intervals[count - 1].Hi)
intervals[count - 1].Hi = max(intervals[count - 1].Hi, intervals[i].Hi);
else
intervals[count++] = intervals[i];
}
var sigma = Sigma(precision);
var aimPoint = ResolveAimPoint(target, hull, aimed, mount);
var a = dot(aimPoint, ell);
var pOnHull = 0f;
for (var i = 0; i < count; i++)
pOnHull += Phi((intervals[i].Hi - a) / sigma) - Phi((intervals[i].Lo - a) / sigma);
var span = count > 0 ? intervals[count - 1].Hi - intervals[0].Lo : 0f;
return new Silhouette
{
Intervals = intervals,
Count = count,
AimPoint = aimPoint,
A = a,
Sigma = sigma,
Span = span,
POnHull = saturate(pOnHull)
};
}
// Phi(z) = 1/2 (1 + erf(z / sqrt(2))): the standard normal CDF, CultMath's erf the one function underneath
// every Gaussian-mass sum this cut computes.
private static float Phi(float z) => .5f * (1f + erf(z * (1f / SQRT2)));
private const float SQRT2 = 1.4142135f;
// Cut 12.2: the lateral draw. One NextFloat u, taken after the hit roll -- walks the cumulative interval
// mass to u * POnHull to select interval k, then inverts the Gaussian CDF inside it. `p` here is the
// absolute CDF value at the drawn point (Phi(lo_k) plus the leftover target mass within interval k), not
// a value renormalised to [0,1] -- inverting that gives s directly in the same units A and Sigma are in.
// Clamped into [lo_k, hi_k) at the end to absorb rounding (a guard, not an invariant); erfinv's own
// handling of the domain edges (+-infinity at p = 0 or 1) means the clamp is what actually keeps s finite
// for an interval with negligible mass.
private static float LateralDraw(Silhouette sil, float u)
{
var target = u * sil.POnHull;
var cumulative = 0f;
for (var k = 0; k < sil.Count; k++)
{
var interval = sil.Intervals[k];
var loValue = Phi((interval.Lo - sil.A) / sil.Sigma);
var hiValue = Phi((interval.Hi - sil.A) / sil.Sigma);
var mass = hiValue - loValue;
if (target <= cumulative + mass || k == sil.Count - 1)
{
var p = clamp(loValue + (target - cumulative), loValue, hiValue);
var s = sil.A + sil.Sigma * SQRT2 * erfinv(2f * p - 1f);
// Half-open [lo_k, hi_k), matching the shadow interval itself ("Each occupied cell c projects
// to [dot(c,ell)-h, dot(c,ell)+h)"). A draw that rounds to exactly Hi is a real, reachable
// float value (Soul found 3 of them enumerating all 2^24 NextFloat outputs on the
// TurningArmour geometry) -- clamping to the closed [Lo, Hi] let s == Hi through, which Lane's
// own half-open filter (`s >= centre + h` continues) then rejects for every cell, returning an
// empty lane for a shot that already passed its roll (R3 violation). The epsilon matches
// Lane's own contiguity tolerance; every merged interval is at least one cell's shadow width
// wide (>= 2h), far above it.
var hiExclusive = max(interval.Lo, interval.Hi - 1e-4f);
return clamp(s, interval.Lo, hiExclusive);
}
cumulative += mass;
}
return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll
}
// Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below
// walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for
// the first cell ahead of its origin) reads all of them. One admission test either way.
private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer)
{
var ell = Lateral(b);
var coords = hull.Shape.Coordinates;
var n = 0;
for (var i = 0; i < coords.Length; i++)
{
var c = (float2) coords[i];
var lateral = Extent(c, ell);
if (s < lateral.Lo || s >= lateral.Hi) continue; // half-open, the same admission Silhouette sums over
// F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the
// struct, so the sort below needs no closure over `b`. S3 fix batch: the sort itself now uses
// LaneCell's own IComparable<LaneCell>, not a per-call Comparer<T>.Create allocation the "never
// allocates" comment used to contradict. Projection is also the "along-bearing projection" the cut
// doc orders entry by.
AlongBearing(c, b, ell, s, out var entry, out var exit);
buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) };
}
Array.Sort(buffer, 0, n);
return n;
}
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the
// bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied
// cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by
// entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while
// consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk,
// the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the
// walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's
// armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked
// count; never allocates on its own.
// Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow
// interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and
// not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a
// "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box
// intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an
// interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul
// reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow
// sum counted metal for can no longer be un-counted here.
public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer)
{
var n = CellsOnLine(hull, b, s, buffer);
var walked = n > 0 ? 1 : 0;
for (var i = 1; i < n; i++)
{
if (buffer[i].Entry > buffer[i - 1].Exit + 1e-4f) break;
walked++;
}
return walked;
}
// Cut 12.2 fix batch (S1): the along-bearing entry/exit parameters for a cell the shadow test above has
// ALREADY admitted -- unconditional, never a gate. The doc's own proof ("s lies in the shadow is exactly
// the same event as this lane has metal") makes the intersection this computes a certainty, not a
// hypothesis to re-test: for a convex shape, a value inside its projection onto ell is achieved by a
// nonempty segment of the line ell=s, so the box-slab solve below cannot come back empty in exact
// arithmetic. It only ever disagreed with the shadow test by float rounding at the interval's own edge,
// which is exactly where SlabAlongB used to throw the metal away; here that same rounding is absorbed by
// clamping entry <= exit instead of rejecting.
private static void AlongBearing(float2 c, float2 b, float2 ell, float s, out float entry, out float exit)
{
entry = float.NegativeInfinity;
exit = float.PositiveInfinity;
SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit);
SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit);
if (entry > exit) exit = entry; // rounding only -- the shadow test already proved a real intersection
}
// The slab (ray-box) bound of point(t) = t*b + s*ell against the unit square centred on cell c, one axis at
// a time: solving bAxis*t + val in [lo, hi], then folding the result into the running (entry, exit) bound.
// A near-zero bAxis component means the ray does not move along that axis at all, so it adds no constraint
// on t (the shadow test above already established this axis's bound holds).
private static void SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit)
{
if (abs(bAxis) < 1e-9f) return; // no motion along this axis: already covered by the shadow admission test
var t1 = (lo - val) / bAxis;
var t2 = (hi - val) / bAxis;
entry = max(entry, min(t1, t2));
exit = min(exit, max(t1, t2));
}
// The hull-schematic cells a hardpoint covers, laid as Entity builds its Hardpoints[,] grid.
private static int2[] MountCells(HardpointData hardpoint) =>
hardpoint.Shape.Coordinates.Select(c => hardpoint.Position + c).ToArray();
// The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source
// of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read.
private static int2[] CellsOf(Entity target, EquippedItem item)
{
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var cells = new List<int2>();
foreach (var v in hullData.Shape.Coordinates)
if (target.GearOccupancy[v.x, v.y] == item)
cells.Add(v);
return cells.ToArray();
}
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a shot in flight. Born in FireControl.Fire, aged by
// FireControl.Step (called from Zone.Update after the entity loop), never serialised -- a zone saved mid-
// flight loses its shots, which is correct, a save is a scene boundary. Zone owns the collection and the
// ShotId; FireControl owns every transition. Nothing else may add, remove or mutate one.
public struct PendingShot
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
public EquippedItem Aimed;
// The mount the aim was at, when it was at a mount rather than an item (Entity.ResolvedAim at Fire), frozen with Aimed:
// a reveal between Fire and Commit does not move where this shot was aimed. Null for an item aim or none.
public HardpointData AimedMount;
// The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing
// downstream re-evaluates any of these.
public float Damage;
// The weapon's falloff stats at Fire (with MaxRange below), frozen like the rest: what the range factor reads, at Fire or
// at commit.
public float FireMinRange;
public float FireEffectiveRange;
public float FireDamageAtMaxRange;
public float Penetration;
public float DamageSpread;
public DamageType DamageType;
// Cut 12.2 (docs/fire-control-cut.md): renamed from PBase -- its meaning changes from "the whole shot's
// frozen roll price" to "the shooter's own share of it," behind the same gates (Accuracy x PSensor). The
// spread and hull factors are no longer frozen here; they are priced live at Commit, against the target's
// facing then (Bearing timing).
public float PFire;
public float Tracking;
public float Precision;
// Cut 12.2: frozen at Fire alongside the rest of the shooter's own decision (R10) -- the shot's
// world-planar travel direction (FireControl.TravelDirection), and the weapon stats PSpread reads at
// Commit against the target's live silhouette.
public float2 TravelDirection;
public float Spread;
public float FireRange;
// Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for
// a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate
// bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until
// Apply's fuse switch (12.4(b)).
public float3 BurstPosition;
public WeaponFuse? Fuse;
public float BlastRadius;
// Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so
// nothing at resolution reads the shooter's position.
public float3 FireOrigin;
public float3 FireTargetPosition;
public float3 FireTargetVelocity;
public float FireTime;
// The weapon's Velocity at Fire, frozen with the rest so a presenter draws the same line whatever happens to the stat later.
public float Speed;
// The weapon's Range at Fire, likewise frozen: how far a presenter may draw a round that misses. FireRange above
// is the distance to the engaged target (0 for a round with none), not how far a round that misses flies on.
public float MaxRange;
public float CommitTime;
public float ArrivalTime;
public bool Committed;
public ShotOutcome Outcome;
// A fused round with no target: where it bursts is decided at its commit (the first hull its aim line meets), so its
// damage reads the falloff there rather than at Fire.
public bool DeliveredAtCommit => Fuse != null && Target == null;
}
// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a
// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range.
public enum FireOutcome
{
Direct,
Burst,
Refused
}
// A gun's solution against its subject (FireControl.Solution): whether the gun bears on the subject, whether the
// aim is free (inside the arc), and the direction a round from it travels.
public struct GunSolution
{
public Entity Subject;
public bool Bears;
public bool Free;
public float2 Direction;
}
// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point; the HUD reads it for
// Burst, Fire's range factor for both outcomes) are what the HUD shows; the rest is what Fire freezes into the PendingShot.
public struct FireSolution
{
public FireOutcome Outcome;
public float BurstReach;
// How far the round flies: to its burst point, or for a direct round to its target.
public float FlightDistance;
public float PFire;
public float FireRange;
// Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here.
public bool Designated;
public Entity Engaged;
public float2 TravelDirection;
public float3 BurstPosition;
public WeaponFuse? Fuse;
public float BlastRadius;
}
public struct FireControlDiagnostic
{
public bool HasTarget;
public bool Designated;
public bool InArc;
public float Range;
public float MinRange;
public float MaxRange;
public float Info;
public float InfoDemandCeiling;
public float Accuracy;
public float Resolution;
public float Precision;
public float Tracking;
public float PSensor;
// Cut 12.2 (optional, presentation): the shooter-side factor alone, Accuracy x PSensor -- the same value
// Fire freezes into PendingShot.PFire.
public float PFire;
public float PSpread;
public float POnHull;
// The target's evasion along the shot (metres) and the share of the shot that survives it at this shooter's Tracking.
public float Evasion;
public float PEvasion;
// The mount: how fast the target sweeps the shooter's sky (degrees per second), the rate this gun on this ship
// follows (degrees per second) and the share of the shot that survives it.
public float AngularVelocity;
public float TrackingRate;
public float PMount;
public float PBase;
// What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do
// not describe it. BurstReach is read only when Outcome is Burst.
public FireOutcome Outcome;
public float BurstReach;
}
// What a ship can do at this instant, in its own frame: acceleration along each body axis (m/s^2) and the kinematic
// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Ship derives it each update as the box
// extremes of its thrusters' columns (Ship.RecalculateEnvelope); nothing stores it. Zero is the envelope of anything
// that is not a Ship.
public readonly struct ManoeuvreEnvelope
{
public readonly float Forward, Reverse, Left, Right, Clockwise, CounterClockwise;
public ManoeuvreEnvelope(float forward, float reverse, float left, float right, float clockwise, float counterClockwise)
{
Forward = forward;
Reverse = reverse;
Left = left;
Right = right;
Clockwise = clockwise;
CounterClockwise = counterClockwise;
}
}
// How unpredictably an entity is changing its motion, observed from its measured acceleration. The trend is an
// exponential average of the acceleration, the innovation is what departs from it (low-passed at a quarter of the
// window), and the moments are the exponential average of that innovation's outer product. A steady burn and
// coasting have no innovation; a weave at the window's scale does; dithering much faster than the window averages out.
// A change of vector is an acceleration that differs from the last observation's by more than ChangeThreshold. When
// none has happened for a whole window the unpredictability is over: the track forgets it outright (the moments
// return to exactly zero and the trend settles on the steady acceleration), so a ship that stops jinking stops
// evading within the window and the zero-spread early return is reachable again.
public struct ManoeuvreTrack
{
public const float ChangeThreshold = 0.01f;
public float2 Trend;
public float2 Innovation;
public float3 Moments;
public float2 Last;
public float Quiet;
public void Observe(float2 acceleration, float dt, float window)
{
if (dt <= 0f || window <= 0f) return;
var changed = abs(acceleration.x - Last.x) > ChangeThreshold || abs(acceleration.y - Last.y) > ChangeThreshold;
Quiet = changed ? 0f : Quiet + dt;
Last = acceleration;
if (Quiet >= window)
{
Trend = acceleration;
Innovation = float2(0, 0);
Moments = float3(0, 0, 0);
return;
}
var k = 1f - exp(-dt / window);
var k4 = 1f - exp(-4f * dt / window);
Trend += k * (acceleration - Trend);
Innovation += k4 * ((acceleration - Trend) - Innovation);
Moments += k * (float3(Innovation.x * Innovation.x, Innovation.x * Innovation.y, Innovation.y * Innovation.y) - Moments);
}
// The root-mean-square innovation across the axis n (a unit vector).
public float LateralRms(float2 n) => sqrt(max(0f, n.x * n.x * Moments.x + 2f * n.x * n.y * Moments.y + n.y * n.y * Moments.z));
}
// Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's
// lateral (ell) coordinate.
// S3 fix batch (Hands, 2026-09-25): IComparable<Interval> (sorted by Lo) instead of a separate IComparer<T> --
// Array.Sort(T[], int, int, IComparer<T>) boxes/wraps the comparer into a fresh delegate on CoreCLR every call
// even when the comparer instance itself is a cached singleton; Array.Sort(T[], int, int) resolves T's own
// IComparable<T> at JIT time and allocates nothing per call. Same order as the deleted IntervalByLoComparer.
public struct Interval : IComparable<Interval>
{
public float Lo;
public float Hi;
public int CompareTo(Interval other) => Lo.CompareTo(other.Lo);
}
// Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds
// the merged, sorted shadow (only the first Count entries are valid -- the array is sized to the hull's own
// occupied-cell count, not trimmed, to avoid a second allocation). A, Sigma and POnHull are read together by
// the lateral draw; Span is read by PSpread so the two factors can never disagree about the target's size.
public struct Silhouette
{
public Interval[] Intervals;
public int Count;
public float2 AimPoint;
public float A;
public float Sigma;
public float Span;
public float POnHull;
}
// Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit
// parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction.
// S3 fix batch (Hands, 2026-09-25): IComparable<LaneCell> replaces the separate LaneCellComparer, for the same
// reason Interval's own comparer was deleted -- the (T[], int, int, IComparer<T>) sort overload allocates a
// wrapper delegate per call even for a cached singleton comparer; (T[], int, int) does not. Same order as the
// deleted comparer: entry ascending, ties by projection along b, then cell index for full determinism.
public struct LaneCell : IComparable<LaneCell>
{
public int2 Cell;
public float Entry;
public float Exit;
// F9 (Soul's fix batch, 2026-09-24): dot(Cell, b), precomputed once per candidate so Lane's sort tie-break
// needs no closure over the bearing.
public float Projection;
public int CompareTo(LaneCell other)
{
var byEntry = Entry.CompareTo(other.Entry);
if (byEntry != 0) return byEntry;
var byProjection = Projection.CompareTo(other.Projection);
if (byProjection != 0) return byProjection;
var byX = Cell.x.CompareTo(other.Cell.x);
return byX != 0 ? byX : Cell.y.CompareTo(other.Cell.y);
}
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at
// fire time when the flight is shorter than the horizon), immutable from then on, published once on
// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only
// readers, and nothing outside presentation may read one to change state.
public sealed class ShotOutcome
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
// What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no
// target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway
// (Burst, at BurstPoint). Hit is derived from it, not stored beside it.
public ShotResult Result;
public bool Hit => Result == ShotResult.Hit;
// World planar point a proximity or no-target round detonates at, frozen by the commit; HasBurstPoint says
// whether this round has one (a contact or delayed round detonates where it hits, not at a frozen point).
public float2 BurstPoint;
public bool HasBurstPoint;
public bool Shielded;
// Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active,
// and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than
// deciding it, and a shield that recharges mid-flight cannot retroactively dodge it.
public bool ShieldBroken;
public int2 Cell;
// Cut 12.2 (R4): the committed geometry, frozen alongside the rest of the outcome -- the schematic-frame
// bearing and the lateral offset the roll's placement drew. Apply reads these, not a live position or
// facing (TurningAfterCommitChangesNothing).
public float2 Bearing;
public float Lateral;
public float ArrivalIn;
public DamageType DamageType;
}
// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no
// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither
// a hit nor a miss, and the presentation must not call it one.
public enum ShotResult
{
Miss,
Hit,
Burst
}