From 4e6495dd6393b8a4352d5f1acce5c5474f448392 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 14:06:38 +0200 Subject: [PATCH] Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 65 ++++++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 23440d409..2949ee8cd 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -605,6 +605,16 @@ public static class FireControl // 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. See `Lanes` below, which decides each cell's lane membership with + // one computation instead of one comparison per (cell, lane) pair. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -636,14 +646,12 @@ public static class FireControl var laneSpacing = shadowExtent.Hi - shadowExtent.Lo; var buffers = new LaneCell[laneCount][]; - var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); var metalLanes = 0; for (var li = 0; li < laneCount; li++) - { - buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); - walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n) * laneSpacing, buffers[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. @@ -668,6 +676,53 @@ public static class FireControl target.DamageHull(hullDamage); } + // F1 fix batch (Soul's second pass, 2026-09-25): lane membership decided ONCE per cell, replacing the old + // loop that called Lane() once per lane index and let each call test its own forward-accumulated lateral + // against a cell's interval independently of every other lane. Here every candidate cell computes its own + // lane index directly from its own (unmodified) Extent interval: k = ceil((Lo - centreLateral) / + // laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in [Lo, Hi) -- Lane's + // own half-open admission -- derived by one division and one Ceiling call from values neither multiplied + // nor summed by any other lane's arithmetic. A single Math.Ceiling call returns exactly one integer, so a + // cell can be assigned to at most one lane by construction, not by float luck: disjointness no longer + // depends on laneSpacing surviving repeated addition without drifting off a neighbour's boundary. + // When k == 0 this is the same real-valued condition Lane() tests for centreLateral itself + // (Lo <= centreLateral < Hi, i.e. Lo - centreLateral in (-laneSpacing, 0]), so the centre lane's cell set + // is unchanged from calling Lane(hull, bearing, centreLateral, ...) directly -- the 12.2 placement contract + // (HitsLandOnTheFacingEdge) still holds. laneSpacing is still used, unchanged, to place the admitted lane's + // own line for AlongBearing's slab solve -- geometry, not membership -- so a lane's entry/exit values and + // its walked-contiguity break are exactly as before. + private static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers) + { + var ell = Lateral(b); + var coords = hull.Shape.Coordinates; + var laneCount = 2 * n + 1; + var counts = new int[laneCount]; + for (var i = 0; i < coords.Length; i++) + { + var c = (float2) coords[i]; + var lateral = Extent(c, ell); + var k = (int) Math.Ceiling((lateral.Lo - centreLateral) / laneSpacing); + if (k < -n || k > n) continue; + var li = k + n; + var s = centreLateral + k * laneSpacing; + AlongBearing(c, b, ell, s, out var entry, out var exit); + buffers[li][counts[li]++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) }; + } + + var walked = new int[laneCount]; + for (var li = 0; li < laneCount; li++) + { + Array.Sort(buffers[li], 0, counts[li]); + walked[li] = counts[li] > 0 ? 1 : 0; + for (var i = 1; i < counts[li]; i++) + { + if (buffers[li][i].Entry > buffers[li][i - 1].Exit + 1e-4f) break; + walked[li]++; + } + } + return walked; + } + private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) { return new ShotOutcome