Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls

F1 (correctness): LateralDraw clamped the drawn lateral to the closed
[Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24
NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own
geometry and found 3 that draw exactly Hi, which Lane's own half-open filter
then admits no cell for -- a hit that passed its roll landing nowhere (R3).
Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a
thrown InvalidOperationException, since an empty lane is provably impossible
after the fix. Committed Soul's edge probe as
LateralDrawIsHalfOpenAtTheShadowsTopEdge.

F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette
-> PSpread (or recomputed range) in their own copy; a shooter-frame bearing
bug in HitProbability's copy survived while the identical bug in Inspect's
copy was caught. Collapsed into one Forecast function and one PFire(out range)
overload; both callers, and Fire, read the one computation.

F2: FireControlCut12Tests' zone names carried a Guid, breaking every other
cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the
die every run -- the actual source of the flake in
TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name.

F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first
interval, and a wrong-sigma placement, were unreachable when every fixture
projects to one interval), a real-roll empirical test for CommitProbability
(the old one called the pure function twice and compared it to itself),
enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to
actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's
average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree).

F9: Lane's own comment claimed no allocation while its sort used
Comparer<T>.Create over a closure; LaneCell now carries its own precomputed
projection and Lane sorts with a stateless singleton comparer. Corrected an
overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane
draw ~68% per shot, not deterministic; the fixture's fixed zone name is what
actually makes it reproduce).

Cost: Silhouette takes an optional caller-rented Interval[] buffer; the
forecast path (HitProbability/Inspect, via Forecast) rents from
ArrayPool<Interval>.Shared and returns it before returning, since neither
reads sil.Intervals after the call. Commit/CommitProbability still get an
owned array, since LateralDraw needs it alive past the call. Measured on
LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before,
1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before,
2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed
is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call),
not in this batch's scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-24 23:08:19 +02:00
commit 8bd25f6faa
4 changed files with 426 additions and 63 deletions

View file

@ -162,10 +162,15 @@ public static class FireControl
{
range = 0f;
if (target == null) return 0f;
if (!source.VisibleEntities.Contains(target)) return 0f;
// F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any
// gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the
// real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The
// gates below still short-circuit the rest of the shooter-side probability in the same order as before.
var toTarget = target.Position - source.Position;
range = length(toTarget);
if (!source.VisibleEntities.Contains(target)) return 0f;
if (range < weapon.MinRange || range > weapon.Range) return 0f;
if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f;
if (!InArc(weapon.Item, toTarget)) return 0f;
@ -175,16 +180,47 @@ public static class FireControl
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 -- the forecast path (HitProbability, Inspect) no longer allocates on a gated-in call.
// 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.
private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range)
{
var bearing = Bearing(target, TravelDirection(weapon, source, target));
var buffer = ArrayPool<Interval>.Shared.Rent(targetHull.Shape.Coordinates.Length);
float pSpread;
Silhouette sil;
try
{
sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer);
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);
}
public static float HitProbability(Weapon weapon, Entity source, Entity target)
{
var pFire = PFire(weapon, source, target, out var range);
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var bearing = Bearing(target, TravelDirection(weapon, source, target));
var sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, Precision(source));
var settings = source.ItemManager.GameplaySettings;
return pFire * PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize) * sil.POnHull;
var (sil, pSpread) = Forecast(weapon, source, target, targetHull, Precision(source), range);
return pFire * pSpread * sil.POnHull;
}
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
@ -220,9 +256,8 @@ public static class FireControl
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
var bearing = Bearing(target, TravelDirection(weapon, source, target));
var sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, diagnostic.Precision);
diagnostic.PSpread = PSpread(weapon.Spread, sil.Span, diagnostic.Range, settings.SchematicCellSize);
var (sil, pSpread) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range);
diagnostic.PSpread = pSpread;
diagnostic.POnHull = sil.POnHull;
diagnostic.PBase = HitProbability(weapon, source, target);
@ -300,18 +335,19 @@ public static class FireControl
// 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).
var pFire = PFire(weapon, source, target);
// F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now
// reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for
// fireRange, right beside the copy PFire's gate already computed.
var pFire = PFire(weapon, source, target, out var fireRange);
var travelDirection = target != null ? TravelDirection(weapon, source, target) : float2(0, 1);
var targetVelocity = float3.zero;
var targetPosition = source.Position;
var flightTime = 0f;
var fireRange = 0f;
if (target != null)
{
targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
targetPosition = target.Position;
fireRange = length(targetPosition - source.Position);
flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f;
}
@ -497,7 +533,16 @@ public static class FireControl
try
{
var count = Lane(hullData, bearing, lateral, buffer);
cell = count > 0 ? buffer[0].Cell : int2.zero;
// 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
{
@ -624,24 +669,31 @@ public static class FireControl
// 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).
public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision)
// 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)
{
var ell = Lateral(bearing);
var coords = hull.Shape.Coordinates;
var h = (abs(ell.x) + abs(ell.y)) / 2f;
var intervals = new Interval[coords.Length];
var intervals = buffer ?? new Interval[coords.Length];
for (var i = 0; i < coords.Length; i++)
{
var centre = dot((float2) coords[i], ell);
intervals[i] = new Interval { Lo = centre - h, Hi = centre + h };
}
Array.Sort(intervals, (x, y) => x.Lo.CompareTo(y.Lo));
Array.Sort(intervals, 0, coords.Length, IntervalByLoComparer.Instance);
// 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 < intervals.Length; i++)
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);
@ -699,7 +751,16 @@ public static class FireControl
{
var p = clamp(loValue + (target - cumulative), loValue, hiValue);
var s = sil.A + sil.Sigma * SQRT2 * erfinv(2f * p - 1f);
return clamp(s, interval.Lo, interval.Hi);
// 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;
@ -732,18 +793,14 @@ public static class FireControl
if (!SlabAlongB(c, b, ell, s, out var entry, out var exit)) continue; // the shadow test is a conservative bound; the exact slab can still miss
buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit };
// 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` -- LaneCellComparer.Instance is a stateless
// singleton, not a per-call Comparer<T>.Create allocation the "never allocates" comment used to
// contradict.
buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) };
}
Array.Sort(buffer, 0, n, Comparer<LaneCell>.Create((x, y) =>
{
var byEntry = x.Entry.CompareTo(y.Entry);
if (byEntry != 0) return byEntry;
var byProjection = dot((float2) x.Cell, b).CompareTo(dot((float2) y.Cell, b));
if (byProjection != 0) return byProjection;
var byX = x.Cell.x.CompareTo(y.Cell.x);
return byX != 0 ? byX : x.Cell.y.CompareTo(y.Cell.y);
}));
Array.Sort(buffer, 0, n, LaneCellComparer.Instance);
var walked = n > 0 ? 1 : 0;
for (var i = 1; i < n; i++)
@ -874,6 +931,16 @@ public struct Interval
public float Hi;
}
// Cost fix batch (Self, 2026-09-24): a stateless singleton, not a per-call Comparer<T>.Create -- Silhouette's
// own sort no longer allocates a wrapper on every call, matching the ArrayPool pooling this batch added on the
// forecast path.
internal sealed class IntervalByLoComparer : IComparer<Interval>
{
public static readonly IntervalByLoComparer Instance = new IntervalByLoComparer();
private IntervalByLoComparer() { }
public int Compare(Interval x, Interval y) => x.Lo.CompareTo(y.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
@ -896,6 +963,28 @@ public struct 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;
}
// F9: Lane's own sort order (entry ascending, ties by projection along b, then cell index for full
// determinism) as a stateless singleton -- Array.Sort's IComparer<T> overload needs an instance, and this one
// is allocated exactly once, ever, not per Lane() call.
internal sealed class LaneCellComparer : IComparer<LaneCell>
{
public static readonly LaneCellComparer Instance = new LaneCellComparer();
private LaneCellComparer() { }
public int Compare(LaneCell x, LaneCell y)
{
var byEntry = x.Entry.CompareTo(y.Entry);
if (byEntry != 0) return byEntry;
var byProjection = x.Projection.CompareTo(y.Projection);
if (byProjection != 0) return byProjection;
var byX = x.Cell.x.CompareTo(y.Cell.x);
return byX != 0 ? byX : x.Cell.y.CompareTo(y.Cell.y);
}
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at

View file

@ -446,10 +446,14 @@ public sealed class FireAuthorityTests : IDisposable
Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed
}
// R5's payoff: with Precision authored extremely tight (Cut 6d: a sigma a tiny fraction of one cell) and
// p 1, the aimed item's durability falls -- the lateral draw still converges on the aimed item's own lane
// when the group is that tight, so this stays a same-lane-every-time assertion rather than a statistical
// one. Mutation: the draw ignores Aimed and always picks a uniform random hull lateral offset.
// R5's payoff: with Precision authored extremely tight (Cut 6d) and p 1, the aimed item's durability
// falls. Sigma floors at half a cell (SigmaFloor) regardless of how tight Precision is authored, so the
// lateral draw is not actually deterministic onto the aimed item's own one-cell-wide lane -- it is
// Phi(.5)-Phi(-.5) ~= .68 per shot, not 1. This one Fire() call is a single draw, not a repeated
// statistical check; it passes reliably only because this file's fixture uses one fixed GalaxyZone name
// ("Test", :146), so CombatSeed and this exact draw are the same on every run (F9, Soul's fix batch,
// 2026-09-24: an earlier comment overclaimed "same-lane-every-time" as if this were architecturally
// guaranteed). Mutation: the draw ignores Aimed and always picks a uniform random hull lateral offset.
// Cut 12.2 (docs/fire-control-cut.md): aiming now only prices and draws the LATERAL offset (the item's
// own aim point still centres the scatter, same as before); depth along the shot's own travel direction is
// resolved by Lane to whichever occupied cell is nearest the entry surface, which for a solid hull is

View file

@ -6,6 +6,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using UniRx;
@ -72,6 +73,8 @@ public sealed class FireControlCut12Tests : IDisposable
public InstantWeapon Weapon;
public HullData HullData;
public EquippedItem[] Markers;
public EquippedItem BigMarker;
public int2[] BigMarkerCells;
}
// A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength
@ -82,7 +85,7 @@ public sealed class FireControlCut12Tests : IDisposable
GameplaySettings settings, Shape hullShape, float precision,
float velocity = 0, float spread = 0, float2? targetFacing = null,
bool equipShield = false, float shieldCapacity = 30,
int2[] markerCells = null, float accuracy = 1f)
int2[] markerCells = null, float accuracy = 1f, float penetration = 0, bool equipBigMarker = false)
{
var hullData = new HullData
{
@ -112,7 +115,7 @@ public sealed class FireControlCut12Tests : IDisposable
{
Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0),
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
} }
});
@ -142,12 +145,27 @@ public sealed class FireControlCut12Tests : IDisposable
Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
// F7 (Soul's fix batch, 2026-09-24): a 2x2 Tool item -- every "Marker" fixture elsewhere is the
// catalog's 1x1 stub, where ResolveAimPoint's average (sum of cells / cell count) can't be
// distinguished from summing then multiplying, or from just reading the first cell.
var bigMarkerShape = new Shape(2, 2);
foreach (var c in bigMarkerShape.AllCoordinates) bigMarkerShape[c] = true;
cache.Upsert(new GearData
{
Name = "BigMarker", Hardpoint = HardpointType.Tool, Shape = bigMarkerShape, Durability = 1000000,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut12-" + Guid.NewGuid().ToString("N"), Owner = null }, null);
// Cut 6.1's own convention (FireControlCut6Tests.cs:62-63): every engagement shares one fixed
// GalaxyZone name, so every Zone this fixture builds shares one CombatSeed and every statistical test
// is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect)
// reseeded the die on every run, which is exactly what made TurningArmourIntoTheShotTakesItOnTheArmour
// flaky (F2, Soul's fix batch, 2026-09-24).
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut12", Owner = null }, null);
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
{
@ -182,6 +200,18 @@ public sealed class FireControlCut12Tests : IDisposable
return target.Equipment.Single(x => x.EquippableItem == m);
}).ToArray();
EquippedItem bigMarker = null;
int2[] bigMarkerCells = null;
if (equipBigMarker)
{
var bm = Make("BigMarker", lot++, 1000000);
Assert.True(target.TryFindSpace(bm, out var bmPos));
Assert.True(target.TryEquip(bm, bmPos));
bigMarker = target.Equipment.Single(x => x.EquippableItem == bm);
bigMarkerCells = hullShape.Coordinates.Where(v => target.GearOccupancy[v.x, v.y] == bigMarker).ToArray();
Assert.Equal(4, bigMarkerCells.Length); // fixture precondition: the full 2x2 footprint landed
}
if (equipShield)
{
var reactor = Make("Reactor", lot++, 10);
@ -217,7 +247,7 @@ public sealed class FireControlCut12Tests : IDisposable
return new Engagement
{
Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon,
HullData = hullData, Markers = markers
HullData = hullData, Markers = markers, BigMarker = bigMarker, BigMarkerCells = bigMarkerCells
};
}
@ -393,6 +423,40 @@ public sealed class FireControlCut12Tests : IDisposable
Assert.True(e.HullData.Shape[outcome.Cell], $"hit landed on {outcome.Cell}, off the hull's own schematic -- the aim point fallback produced NaN");
}
// F7 (Soul's fix batch, 2026-09-24): ResolveAimPoint's average was only ever exercised with a 1x1 item
// (every other fixture's "Marker"), where sum/length and sum*length agree and "first cell" and "average"
// are the same cell. A 2x2 "BigMarker" gives a genuine 4-cell footprint: its true centroid is checked two
// ways -- pOnHull does not collapse (the aim point is on the hull, not scaled off it by sum*length), and,
// from broadside, the empirical mean lateral offset of hits centres on the real centroid's own lane, not
// on any single one of its four cells.
[Fact]
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
{
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var ell = float2(-bearing.y, bearing.x);
var centroid = e.BigMarkerCells.Aggregate(float2.zero, (t, c) => t + (float2) c) / e.BigMarkerCells.Length;
var expectedA = dot(centroid, ell);
// pOnHull must not collapse -- a sum*length aim point would be scaled several cells off the hull
// entirely, driving pOnHull toward zero even at Precision 1 (sigma 1, comfortably inside a 9x9 hull).
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
Assert.True(hud.POnHull > .3f, $"pOnHull ({hud.POnHull}) collapsed -- the aim point is likely off the hull");
var outcomes = FireMany(e, 3000);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 300, $"expected a healthy number of hits, got {hits.Count}");
var empiricalMeanLateral = hits.Average(o => (double) o.Lateral);
// Sigma is 1 cell; a "first cell only" aim point sits at least .5 cell off the true centroid on a 2x2
// footprint, well outside this tolerance for a few-hundred-shot mean.
Assert.True(Math.Abs(empiricalMeanLateral - expectedA) < .25,
$"empirical mean lateral offset {empiricalMeanLateral:F3} does not track the centroid's own lane {expectedA:F3}");
}
// BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is
// much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it).
[Fact]
@ -463,15 +527,74 @@ public sealed class FireControlCut12Tests : IDisposable
Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}");
}
// F1 (Soul's fix batch, 2026-09-24): LateralDraw's clamp used to close the top of the shadow interval
// ([Lo, Hi] instead of the spec's half-open [Lo, Hi)). Soul enumerated all 2^24 NextFloat outputs against
// this exact geometry (TurningArmourIntoTheShotTakesItOnTheArmour's own 3x11 hull, bearing (0,-1),
// Precision .4 -> interval [-0.5, 2.5]) and found 3 values of u that drew s == Hi == 2.5 exactly, at which
// Lane's own half-open filter (`s >= centre + h` continues, :731) then found no candidate cell at all,
// returning an empty lane for a shot that had already passed its roll (R3 violation) -- the reflection
// call below reproduces that exact draw. u=0 is the analogous exact-Lo case (the first NextFloat output).
[Fact]
public void LateralDrawIsHalfOpenAtTheShadowsTopEdge()
{
var hull = new HullData { Name = "H", HullType = HullType.Ship, Shape = SolidShape(3, 11) };
var bearing = float2(0, -1);
const float precision = .4f;
var sil = FireControl.Silhouette(null, hull, null, bearing, precision);
Assert.Equal(1, sil.Count); // fixture precondition: one interval, matching Soul's own probe
Assert.Equal(-0.5f, sil.Intervals[0].Lo, 3);
Assert.Equal(2.5f, sil.Intervals[0].Hi, 3);
var lateralDraw = (Func<Silhouette, float, float>) typeof(FireControl)
.GetMethod("LateralDraw", BindingFlags.NonPublic | BindingFlags.Static)
.CreateDelegate(typeof(Func<Silhouette, float, float>));
void AssertLandsOnMetal(float u, string label)
{
var s = lateralDraw(sil, u);
var buffer = new LaneCell[hull.Shape.Coordinates.Length];
var count = FireControl.Lane(hull, bearing, s, buffer);
Assert.True(count > 0, $"{label}: u={u:R} drew s={s:R} (interval [{sil.Intervals[0].Lo},{sil.Intervals[0].Hi}]) -- Lane found no occupied cell for a hit that already passed its roll");
Assert.True(hull.Shape[buffer[0].Cell], $"{label}: Lane's own first cell {buffer[0].Cell} is not on the hull's schematic");
}
AssertLandsOnMetal(0.9999998f, "at the top edge (u drives s to exactly Hi)");
AssertLandsOnMetal(0f, "at the bottom edge (u drives s to exactly Lo)");
}
// Entity.ApplyHit's own penetration march, replicated independently (not by calling ApplyHit or reading
// its source beyond the documented convention 12.2 explicitly leaves untouched: cell centres at
// +.5, .5-unit steps, hullData.Shape[int2(point)] as the stop condition). Used to compute, from a frozen
// Bearing, the full set of cells a march would consume -- independent of which bearing (committed or
// live) the test feeds it.
private static HashSet<int2> ExpectedMarchCells(HullData hull, int2 cell, float2 bearing, float penetration)
{
var cells = new HashSet<int2> { cell };
if (penetration <= .5f) return cells;
var vector = normalize(bearing);
var point = (float2) cell + float2(.5f);
var distance = 0f;
while (distance < penetration && hull.Shape[int2(point)])
{
distance += .5f;
cells.Add(int2(point));
point += vector * .5f;
}
return cells;
}
// TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the
// shooter must not change where the damage actually lands -- Apply reads no live position or facing. The
// armor cell that actually takes damage is checked against an independent lane recompute from the
// committed Bearing/Lateral, not from the post-turn facing.
// shooter must not change where the damage actually lands -- Apply reads no live position or facing.
// F5 (Soul's fix batch, 2026-09-24): the original fixture fired with Penetration 0, so ApplyHit's march
// (Entity.cs, gated at `penetration > .5f`) never ran and never actually read the bearing Apply passed it
// -- a mutant that fed Apply a live (post-turn) bearing instead of the committed one survived (M2a) because
// nothing downstream of Cell ever consumed Bearing. Penetration 3 marches several cells deep, so the full
// damaged-cell set is asserted against the committed Bearing/Lateral, not the live one.
[Fact]
public void TurningAfterCommitChangesNothing()
{
var settings = TestSettings();
var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20); // 5s flight, commits at 4.5s
var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20, penetration: 3); // 5s flight, commits at 4.5s
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
@ -489,16 +612,34 @@ public sealed class FireControlCut12Tests : IDisposable
var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral);
Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute
var expectedFromCommitted = ExpectedMarchCells(e.HullData, committed.Cell, committed.Bearing, 3f);
var before = (float[,]) e.Target.Armor.Clone();
// After commit, before arrival: the target turns 90 degrees and the shooter moves.
e.Target.Direction = float2(1, 0);
var postTurnFacing = float2(1, 0);
e.Target.Direction = postTurnFacing;
e.Shooter.Position += float3(500, 0, 500);
// Fixture precondition: the (wrong) march a live-bearing bug would produce must actually differ from
// the committed one, or this test cannot tell the two apart. "Live" here means exactly what a mutant
// recomputing the bearing fresh at Apply time would read: the current TravelDirection (now stale,
// post-move) folded through the target's NEW facing -- both public FireControl functions, not a
// hand-rolled copy of the frame math.
var liveTravelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var wrongBearing = normalize(e.Target.ToSchematic(liveTravelDirection));
var expectedFromLive = ExpectedMarchCells(e.HullData, committed.Cell, wrongBearing, 3f);
Assert.NotEqual(expectedFromCommitted, expectedFromLive);
e.Zone.Update(2f); // past arrival (5s)
Assert.True(e.Target.Armor[expectedCell.x, expectedCell.y] < before[expectedCell.x, expectedCell.y],
"the committed impact cell must be the one that actually took damage, regardless of the post-commit turn");
foreach (var cell in expectedFromCommitted)
Assert.True(e.Target.Armor[cell.x, cell.y] < before[cell.x, cell.y],
$"cell {cell} is on the committed march and must have taken damage, regardless of the post-commit turn");
foreach (var cell in expectedFromLive)
if (!expectedFromCommitted.Contains(cell))
Assert.Equal(before[cell.x, cell.y], e.Target.Armor[cell.x, cell.y]);
}
// PlacementAndProbabilityShareOneSilhouette replaces 6d's PlacementAndProbabilityShareOneKernel: pins that
@ -588,6 +729,90 @@ public sealed class FireControlCut12Tests : IDisposable
}
}
// F3 (Soul's fix batch, 2026-09-24): every 12.2 fixture, and every shipped hull (Soul's own 360-degree
// sweep), projects to exactly ONE merged interval at its test bearing -- LateralDraw's walk past the
// first interval (the `cumulative += mass` loop continuation) and a Silhouette built with the wrong sigma
// (M12) were both unreachable. This hull is two 2-wide prongs (x in {0,1} and {5,6}, all rows) separated
// by a real gap (x in 2..4, unoccupied) -- a dead-ahead bearing (ell=(-1,0)) gives two disjoint shadow
// intervals, symmetric around the hull's own centre of mass (3,2), which sits in the gap.
[Fact]
public void TwoProngHullSplitsTheScatterAcrossBothIntervals()
{
var shape = new Shape(7, 5);
foreach (var x in new[] { 0, 1, 5, 6 })
for (var y = 0; y < 5; y++)
shape[new int2(x, y)] = true;
const float sigma = 1.5f;
const float precision = 1f / sigma;
var e = Build(TestSettings(), shape, precision); // unaimed: centre of mass (3,2)
// The bearing a shot would freeze, read directly (not through an actual Fire call, which would leave
// a stray shot in the queue for FireMany to double-count).
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
var bearing = normalize(e.Target.ToSchematic(travelDirection));
var ell = float2(-bearing.y, bearing.x);
var a = dot(shape.CenterOfMass, ell);
// Independent shadow (raw per-cell intervals, sorted and merged), the same convention
// PlacementAndProbabilityShareOneSilhouette uses.
var raw = shape.Coordinates
.Select(c => dot((float2) c, ell))
.Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f))
.OrderBy(iv => iv.Lo)
.ToList();
var merged = new List<(float Lo, float Hi)>();
foreach (var iv in raw)
{
if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi)
merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi));
else
merged.Add(iv);
}
Assert.Equal(2, merged.Count); // fixture precondition: a real two-interval shadow
double Mass((float Lo, float Hi) iv)
{
const int steps = 4000;
var width = (iv.Hi - iv.Lo) / steps;
double sum = 0;
for (var i = 0; i <= steps; i++)
{
var x = iv.Lo + i * width;
var z = (x - a) / sigma;
var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI));
sum += pdf * (i == 0 || i == steps ? .5 : 1.0);
}
return sum * width;
}
var mass0 = Mass(merged[0]);
var mass1 = Mass(merged[1]);
var totalMass = mass0 + mass1;
Assert.True(totalMass > .05 && totalMass < .95, $"fixture: total pOnHull ({totalMass}) must be non-degenerate");
Assert.True(Math.Abs(mass0 - mass1) / totalMass < .05, "fixture: the two prongs must be near-symmetric for a clean 50/50 check");
var outcomes = FireMany(e, 8000);
var hits = outcomes.Where(o => o.Hit).ToList();
Assert.True(hits.Count > 800, $"expected a healthy number of hits, got {hits.Count}");
var inProngA = hits.Count(o => o.Cell.x <= 1);
var inProngB = hits.Count(o => o.Cell.x >= 5);
Assert.Equal(hits.Count, inProngA + inProngB); // every hit lands in one prong or the other, never the gap
var empiricalShareA = inProngA / (double) hits.Count;
var expectedShareA = mass0 / totalMass;
Assert.True(Math.Abs(empiricalShareA - expectedShareA) < .05,
$"prong A's empirical share {empiricalShareA:F3} does not match its analytic share {expectedShareA:F3}");
// Within-prong shape, not just the aggregate share: prong A is x in {0,1}, projected lateral centres
// 0 and -1 respectively (ell=(-1,0)); a=-3 sits closer to x=1's centre (-1) than x=0's (0), so a real
// Gaussian shape (not a coin flip within the prong, and not "first cell only") puts strictly more mass
// on x=1 than x=0.
var atNearColumn = hits.Count(o => o.Cell.x == 1);
var atFarColumn = hits.Count(o => o.Cell.x == 0);
Assert.True(atNearColumn > atFarColumn,
$"expected the column nearer the aim point (x=1, {atNearColumn} hits) to outweigh the far one (x=0, {atFarColumn} hits)");
}
// HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is
// occupied, AND nothing occupied precedes it in its own lane -- the test recomputes the lane from the
// outcome's own frozen Bearing/Lateral, independently of FireControl.Lane, and checks production picked
@ -659,29 +884,65 @@ public sealed class FireControlCut12Tests : IDisposable
// TheHudEstimateIsTheCommitPrice: for an in-flight shot, the value the HUD's own estimate (through
// FireControl.CommitProbability, the function ActionGameManager's pending-shot line now calls) and the p
// Commit itself rolls against are the same number at the commit tick. Kills a second formula.
// Commit itself rolls against are the same number at the commit tick.
// F4 (Soul's fix batch, 2026-09-24): the original test called CommitProbability twice and compared the
// two calls to each other -- a self-consistency check that never observed what Commit's own roll actually
// uses. Survivors: Commit applying PSpread twice (M15), and Commit rolling against PFire x pOnHull alone,
// dropping deviation and PSpread entirely (M16) -- both invisible to a test that never rolls anything.
// This version fires many shots against one fixed, non-trivial configuration (nonzero weapon Spread, and
// a target jinked off its fire-time projection so pDeviation is strictly less than 1) and checks the
// EMPIRICAL hit fraction over the real Commit path against the HUD's own forecast, with a tolerance sized
// from the sample (3 sigma of a Bernoulli(p, n)).
[Fact]
public void TheHudEstimateIsTheCommitPrice()
{
var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 20); // 5s flight
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(1f); // still short of the 4.5s commit
var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 40, spread: 6f); // 2.5s flight, commits at 2.0s
var origin = e.Target.Position;
const int shots = 800;
var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId);
var hudEstimate = FireControl.CommitProbability(pending, e.Zone.Time, out _);
var pendingSnapshots = new List<PendingShot>();
for (var i = 0; i < shots; i++)
{
e.Target.Position = origin; // reset before each Fire so every shot freezes the same FireTargetPosition
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.001f);
// The fixture's own Targeting system freezes Tracking = 100000 (full-strength isolation, Build's
// own convention) -- against that, a jink small enough to keep the target on this 5x7 hull barely
// registers (pDeviation ~= .99996). Lower the frozen Tracking after the fact, the same way
// CommitProbabilityIsTheExactProductOfItsFourFactors overrides a frozen field, so the jink below
// produces a pDeviation strictly between 0 and 1.
var index = e.Zone.PendingShots.FindIndex(s => s.ShotId == shotId);
var shot = e.Zone.PendingShots[index];
shot.Tracking = 6f;
e.Zone.PendingShots[index] = shot;
pendingSnapshots.Add(shot);
}
ShotOutcome committed = null;
var draws = 0;
using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; draws++; });
e.Zone.Update(4f); // now past commit
Assert.NotNull(committed);
Assert.Equal(1, draws);
// One jink, after every shot is queued, before any of them commit -- pDeviation < 1 for all of them,
// uniformly (FireTargetVelocity is 0, so DeviationProbability's projection never drifts with elapsed
// time; every shot sees the same deviation regardless of the small firing-order time skew above).
e.Target.Position = origin + float3(4, 0, 0);
// The commit tick's own price, recomputed with the pending shot as it stood before Commit mutated its
// Committed flag -- CommitProbability is pure, so calling it again after the fact at the same `now`
// reproduces the exact p Commit rolled against.
var commitTickEstimate = FireControl.CommitProbability(pending, 1f + 4f, out _);
Assert.Equal(hudEstimate, commitTickEstimate, 5); // the target never moved or turned between the two reads
var hits = 0;
var commits = 0;
using var c = e.Zone.ShotCommitted.Subscribe(o => { commits++; if (o.Hit) hits++; });
e.Zone.Update(2.1f); // past every shot's 2.0s commit, short of the 2.5s arrival
Assert.Equal(shots, commits);
var expected = FireControl.CommitProbability(pendingSnapshots[0], e.Zone.Time, out var sil);
Assert.True(sil.POnHull > .02f && sil.POnHull < .98f, $"fixture: pOnHull ({sil.POnHull}) must be non-degenerate");
var pDeviation = FireControl.DeviationProbability(pendingSnapshots[0], e.Zone.Time, out var deviation);
Assert.True(deviation > 0f, "fixture: the jink must actually move the target off its projection");
Assert.True(pDeviation > .02f && pDeviation < .98f, $"fixture: pDeviation ({pDeviation}) must be strictly between 0 and 1");
Assert.True(expected > .02f && expected < .98f, $"fixture: the commit price ({expected}) must be strictly between 0 and 1");
var empirical = hits / (double) shots;
// 3 standard deviations of a Bernoulli(expected, shots) draw, so this fails on the real bug (a
// dropped or duplicated factor) far more often than on sampling noise.
var tolerance = 3.0 * Math.Sqrt(expected * (1 - expected) / shots);
Assert.True(Math.Abs(empirical - expected) < tolerance,
$"empirical hit rate {empirical:F4} over {shots} shots is not within {tolerance:F4} of the HUD's forecast {expected:F4}");
}
// TheSigmaFloorHoldsAtHalfACell: pOnHull at Precision 1000 equals pOnHull at Precision 2 on a one-cell

View file

@ -409,10 +409,19 @@ public sealed class FireControlCut9Tests : IDisposable
var settings = TestSettings();
var checkedConfigurations = 0;
var sawPartialSpread = false;
// F6 (Soul's fix batch, 2026-09-24): a shooter at float3.zero with both entities at Direction's
// default (0,1) let a mutant that reads the SHOOTER's facing instead of the target's (Bearing(source,
// ...) instead of Bearing(target, ...)) survive -- the two facings coincided, so the substitution was
// invisible. An off-origin shooter and a target facing with |fx| > |fy|, distinct from the shooter's
// own (unused) default facing, make that substitution produce a different, wrong bearing.
var origin = float3(37, 0, -11);
foreach (var precision in new[] { .3f, 1.19f, 5f })
{
// 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone.
var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8);
e.Shooter.Position = origin;
e.Target.Position = origin + float3(0, 0, 100);
e.Target.Direction = normalize(float2(.8f, .6f));
foreach (var info in new[] { .2f, .5f, 1f })
{
@ -435,9 +444,9 @@ public sealed class FireControlCut9Tests : IDisposable
checkedConfigurations++;
}
e.Target.Position = float3(0, 0, 5000);
e.Target.Position = origin + float3(0, 0, 5000);
Closed("out of range", d => d.InRange);
e.Target.Position = float3(0, 0, -100);
e.Target.Position = origin + float3(0, 0, -100);
Closed("behind the mount", d => d.InArc);
// A shooter far from the origin: at float3.zero `target - source` and `target + source` are the
@ -448,8 +457,8 @@ public sealed class FireControlCut9Tests : IDisposable
Assert.Equal(500f, far.Range, 3);
Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin");
checkedConfigurations++;
e.Shooter.Position = float3.zero;
e.Target.Position = float3(0, 0, 100);
e.Shooter.Position = origin;
e.Target.Position = origin + float3(0, 0, 100);
e.Shooter.VisibleEntities.Remove(e.Target);
Closed("not visible", d => d.Visible);