2349 lines
138 KiB
C#
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
|
|
}
|