From 9f6d8659afa6ae86ce3e6819b54dd4620439862d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 00:15:18 +0200 Subject: [PATCH 01/13] Pin CultMath 0.4.0 (BoundedLeastSquares KKT tolerance; snoise tie rule is Breaking) CultMathRevision 252e226c (cultmath-unity-v0.4.0), manifest tag and lock entry. Generated content that samples noise may move; fixtures follow in later commits. Builds as a pin; the suite may fail on re-rolled content until then. Co-Authored-By: Claude Opus 5.5 --- Directory.Build.props | 4 ++-- Packages/manifest.json | 2 +- Packages/packages-lock.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8c27ee3ec..7ed6fb6e8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,8 +6,8 @@ 45c2f4006f5f7dab357426cabd3197e8d0a17f66 $(CULTMATH_ROOT) $(CultLibRoot) - - 614fd445845f6251ee2986707beecea0bb211791 + + 252e226c51826d626f4113673f31d96577b67ec9 C:\Program Files\Git\cmd\git.exe git diff --git a/Packages/manifest.json b/Packages/manifest.json index e5e0d240e..a04f36e20 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -54,6 +54,6 @@ "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0" + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.0" } } diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 69c2afb7c..f4c128708 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -272,11 +272,11 @@ "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.0", "depth": 0, "source": "git", "dependencies": {}, - "hash": "614fd445845f6251ee2986707beecea0bb211791" + "hash": "252e226c51826d626f4113673f31d96577b67ec9" }, "com.unity.modules.accessibility": { "version": "1.0.0", From e355747dae23a2e06e02492054a593f355b38923 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 00:29:05 +0200 Subject: [PATCH 02/13] ThrustAllocator: one bounded least-squares solve, yaw first, translation error paid linearly Rules TA-R1 to TA-R4 of cut-thrust-allocator-core.r2, tests on synthetic columns (Duel, damaged Duel, symmetric hull). First draft; not yet run. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/ThrustAllocator.cs | 99 ++++++ .../ServerShared/ThrustAllocator.cs.meta | 11 + .../ThrustAllocatorTests.cs | 281 ++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 Assets/Scripts/ServerShared/ThrustAllocator.cs create mode 100644 Assets/Scripts/ServerShared/ThrustAllocator.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs new file mode 100644 index 000000000..09b37aebf --- /dev/null +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -0,0 +1,99 @@ +/* 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 CultMath; + +/// +/// Turns intent (which way to go, which way to turn) into every thruster's throttle, whatever the thrusters' actual +/// strength and position. One per ship. A column is one thruster's effect per unit throttle: x starboard acceleration, +/// y forward acceleration, z clockwise yaw rate in rad/s. Throttles are bounded to [0,1]. +/// +/// One solve per call over the n throttles and four error variables (the unwanted or +/// missing translation on each half-axis, paid for linearly). Yaw is a heavily weighted row, so a hold is exact and a +/// turn is served first; the linear translation cost means the actuator with the least translation per unit of turn +/// serves first and the next joins only as it saturates. Nothing classifies a column as attitude or drive: the onset +/// at which a second actuator joins a turn falls out of the columns. +/// +public sealed class ThrustAllocator +{ + const float TranslationWeight = 100f; + const float YawWeight = 1000f; + const float ErrorCostWeight = .3f; + const float ErrorCostOffset = 10f; + const float Ridge = .1f; + const float ErrorMax = 2f; + const double KktRelativeTolerance = 1e-12; + const int ErrorVariables = 4; + const int FixedRows = 3 + ErrorVariables; + + int _columns = -1; + float[] _a, _b, _lo, _hi, _x, _workspace; + + public void Allocate(ReadOnlySpan columns, float2 move, float turn, Span throttle) + { + var n = columns.Length; + if (n != _columns) Resize(n); + for (var i = 0; i < n; i++) throttle[i] = 0; + + var vars = n + ErrorVariables; + var rows = FixedRows + n; + Array.Clear(_a, 0, rows * vars); + Array.Clear(_b, 0, rows); + + // Rows 0 and 1 are the translation axes (starboard, forward), row 2 the yaw. + for (var axis = 0; axis < 3; axis++) + { + float plus = 0, minus = 0; + for (var i = 0; i < n; i++) + { + var c = Component(columns[i], axis); + if (c > 0) plus += c; + else if (c < 0) minus -= c; + } + var extreme = Math.Max(plus, minus); + var intent = Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f); + var weight = axis == 2 ? YawWeight : TranslationWeight; + // Intent is a fraction of the half-axis extreme it points into. + var demand = intent >= 0 ? intent * plus : intent * minus; + var scale = extreme > 0 ? weight / extreme : 0f; + var row = axis * vars; + for (var i = 0; i < n; i++) _a[row + i] = Component(columns[i], axis) * scale; + _b[axis] = demand * scale; + if (axis < 2 && extreme > 0) + { + _a[row + n + 2 * axis] = -weight; + _a[row + n + 2 * axis + 1] = weight; + } + } + + // A linear cost on each error variable, as w*e = -w*kappa, and a ridge on each throttle. + for (var k = 0; k < ErrorVariables; k++) + { + _a[(3 + k) * vars + n + k] = ErrorCostWeight; + _b[3 + k] = -ErrorCostWeight * ErrorCostOffset; + } + for (var i = 0; i < n; i++) _a[(FixedRows + i) * vars + i] = Ridge; + + var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance); + if (status == BoundedLeastSquaresStatus.InvalidInput) return; + for (var i = 0; i < n; i++) throttle[i] = _x[i]; + } + + static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z; + + void Resize(int n) + { + _columns = n; + var vars = n + ErrorVariables; + var rows = FixedRows + n; + _a = new float[rows * vars]; + _b = new float[rows]; + _lo = new float[vars]; + _hi = new float[vars]; + _x = new float[vars]; + _workspace = new float[BoundedLeastSquares.WorkspaceLength(vars)]; + for (var i = 0; i < vars; i++) _hi[i] = i < n ? 1f : ErrorMax; + } +} diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs.meta b/Assets/Scripts/ServerShared/ThrustAllocator.cs.meta new file mode 100644 index 000000000..d7b82c1b3 --- /dev/null +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b0e3f7a9c2d4e61a8f4d3c7b1e90a26 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs new file mode 100644 index 000000000..b12c077f9 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using CultMath; +using Xunit; + +namespace Aetheria.Shared.Tests; + +// ThrustAllocator on synthetic columns. A column is (starboard m/s^2, forward m/s^2, clockwise yaw rad/s) per unit +// throttle; the fixtures are written in deg/s the way the probes measured them (map TA3, TA12). +public class ThrustAllocatorTests +{ + const float Deg = MathF.PI / 180f; + + static float3 Col(float starboard, float forward, float yawDeg) => new(starboard, forward, yawDeg * Deg); + + // TA3, the Duel Longinus: two mismatched drives (0, 1) and two Talarias (2 clockwise, 3 counter-clockwise). + static float3[] Duel() => new[] + { + Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f), + }; + + // The clockwise Talaria lost. + static float3[] DamagedDuel() => new[] + { + Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), default, Col(-21.80f, 0, -124.63f), + }; + + // TA12's symmetric hull: mains, fore and aft laterals in torque-free pairs, a bow thruster. + static float3[] Symmetric() => new[] + { + Col(0, 50, 20), Col(0, 50, -20), Col(20, 0, 80), Col(-20, 0, -80), Col(20, 0, -60), Col(-20, 0, 60), Col(0, -30, 0), + }; + + // The hull's half-axis extremes, from the definition: the sum of a row's positive entries, or of the negated + // negative ones. Axis 0 starboard, 1 forward, 2 clockwise yaw. + static (float plus, float minus) Extremes(float3[] columns, int axis) + { + float plus = 0, minus = 0; + foreach (var c in columns) + { + var v = axis == 0 ? c.x : axis == 1 ? c.y : c.z; + if (v > 0) plus += v; else minus -= v; + } + return (plus, minus); + } + + static float3 Net(float3[] columns, float[] throttle) + { + float3 net = default; + for (var i = 0; i < columns.Length; i++) net += columns[i] * throttle[i]; + return net; + } + + static float[] Solve(ThrustAllocator allocator, float3[] columns, float moveX, float moveY, float turn) + { + var throttle = new float[columns.Length]; + allocator.Allocate(columns, new float2(moveX, moveY), turn, throttle); + return throttle; + } + + static float[] Solve(float3[] columns, float moveX, float moveY, float turn) => Solve(new ThrustAllocator(), columns, moveX, moveY, turn); + + static float YawDeg(float3[] columns, float[] throttle) => Net(columns, throttle).z / Deg; + + [Fact] + public void AHoldIsExactWithMismatchedDrives() + { + var duel = Duel(); + var throttle = Solve(duel, 0, 1, 0); + var clockwise = Extremes(duel, 2).plus; + var net = Net(duel, throttle); + Assert.True(MathF.Abs(net.z) <= 1e-5f * clockwise, $"yaw {net.z / Deg} deg/s"); + Assert.True(net.y >= .99f * Extremes(duel, 1).plus, $"forward {net.y}"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ALostDriveIsBalancedByWhatIsLeft(int zeroed) + { + var columns = Duel(); + var extreme = Extremes(columns, 2).plus; + columns[zeroed] = default; + var throttle = Solve(columns, 0, 1, 0); + var net = Net(columns, throttle); + Assert.True(MathF.Abs(net.z) <= 1e-5f * extreme, $"yaw {net.z / Deg} deg/s"); + Assert.Equal(0f, throttle[zeroed]); + Assert.True(net.y > 0, $"forward {net.y}"); + } + + [Theory] + [InlineData(1f)] + [InlineData(.5f)] + [InlineData(-.5f)] + [InlineData(-1f)] + public void ATurnIsThatFractionOfTheBoxExtreme(float turn) + { + var duel = Duel(); + var (plus, minus) = Extremes(duel, 2); + var expected = turn * (turn > 0 ? plus : minus); + var net = Net(duel, Solve(duel, 0, 0, turn)); + Assert.True(MathF.Abs(net.z - expected) <= 1e-4f * MathF.Abs(expected), $"yaw {net.z / Deg} expected {expected / Deg}"); + } + + [Fact] + public void AttitudeServesFirstAndTheOnsetIsTheColumnsRatio() + { + var duel = Duel(); + // The onset is the attitude thruster's yaw over the hull's extreme in that direction. + var clockwiseOnset = duel[2].z / Extremes(duel, 2).plus; + var counterOnset = -duel[3].z / Extremes(duel, 2).minus; + Assert.InRange(clockwiseOnset, .7714f, .7716f); + Assert.InRange(counterOnset, .6904f, .6906f); + var allocator = new ThrustAllocator(); + for (var step = 0; step <= 1000; step++) + { + var turn = step * .001f; + var drive = Solve(allocator, duel, 0, 0, turn)[0]; + if (turn <= clockwiseOnset - .005f) Assert.True(drive <= 1e-4f, $"clockwise turn {turn}: drive {drive}"); + if (turn >= clockwiseOnset + .005f) Assert.True(drive > 1e-3f, $"clockwise turn {turn}: drive {drive}"); + } + allocator = new ThrustAllocator(); + for (var step = 0; step <= 1000; step++) + { + var turn = step * .001f; + var drive = Solve(allocator, duel, 0, 0, -turn)[1]; + if (turn <= counterOnset - .005f) Assert.True(drive <= 1e-4f, $"counter-clockwise turn {turn}: drive {drive}"); + if (turn >= counterOnset + .005f) Assert.True(drive > 1e-3f, $"counter-clockwise turn {turn}: drive {drive}"); + } + } + + [Fact] + public void TheOnsetMovesWithTheColumns() + { + // Clockwise Talaria lost: the drive serves a clockwise turn from the first touch. + var damaged = DamagedDuel(); + var small = Solve(damaged, 0, 0, .01f); + Assert.True(small[0] > 0, $"drive {small[0]}"); + Assert.True(MathF.Abs(YawDeg(damaged, small) - .01f * 49.24f) <= .01f * 49.24f * 1e-3f, $"yaw {YawDeg(damaged, small)}"); + var full = Solve(damaged, 0, 0, 1f); + Assert.True(MathF.Abs(YawDeg(damaged, full) - 49.24f) <= 49.24f * 1e-3f, $"yaw {YawDeg(damaged, full)}"); + + // Clockwise Talaria halved: the onset is its new share of the clockwise extreme. + var halved = Duel(); + halved[2] = halved[2] * .5f; + var onset = halved[2].z / Extremes(halved, 2).plus; + Assert.InRange(onset, .627f, .629f); + var allocator = new ThrustAllocator(); + for (var step = 0; step <= 1000; step++) + { + var turn = step * .001f; + var drive = Solve(allocator, halved, 0, 0, turn)[0]; + if (turn <= onset - .005f) Assert.True(drive <= 1e-4f, $"turn {turn}: drive {drive}"); + if (turn >= onset + .005f) Assert.True(drive > 1e-3f, $"turn {turn}: drive {drive}"); + } + } + + [Fact] + public void EveryThrottleIsContinuousInTurn() + { + foreach (var columns in new[] { Duel(), DamagedDuel(), Symmetric() }) + { + var allocator = new ThrustAllocator(); + var previous = Solve(allocator, columns, 0, 0, -1f); + for (var step = -999; step <= 1000; step++) + { + var current = Solve(allocator, columns, 0, 0, step * .001f); + for (var i = 0; i < columns.Length; i++) + Assert.True(MathF.Abs(current[i] - previous[i]) <= .02f, $"turn {step * .001f}: throttle {i} {previous[i]} -> {current[i]}"); + previous = current; + } + } + } + + [Fact] + public void PureTorqueNeedsNoTranslation() + { + var hull = Symmetric(); + var allocator = new ThrustAllocator(); + var starboard = Extremes(hull, 0).plus; + var forward = Extremes(hull, 1).plus; + for (var step = -87; step <= 87; step++) + { + var net = Net(hull, Solve(allocator, hull, 0, 0, step * .01f)); + Assert.True(MathF.Abs(net.x) <= 1e-3f * starboard, $"turn {step * .01f}: starboard {net.x}"); + Assert.True(MathF.Abs(net.y) <= 1e-3f * forward, $"turn {step * .01f}: forward {net.y}"); + } + var strafe = Net(hull, Solve(hull, 1, 0, 0)); + Assert.True(strafe.x >= .9f * starboard, $"strafe {strafe.x} of {starboard}"); + Assert.True(MathF.Abs(strafe.z) <= 1e-3f * Extremes(hull, 2).plus, $"yaw {strafe.z / Deg}"); + } + + [Fact] + public void TurnComesBeforeTranslation() + { + var duel = Duel(); + var throttle = Solve(duel, 0, 1, 1f); + var net = Net(duel, throttle); + var extreme = Extremes(duel, 2).plus; + Assert.True(MathF.Abs(net.z - extreme) <= 1e-4f * extreme, $"yaw {net.z / Deg} of {extreme / Deg}"); + Assert.True(throttle[1] < .01f, $"counter-turning drive {throttle[1]}"); + } + + [Fact] + public void MovementIsThatFractionOfTheHalfAxis() + { + // A torque-free strafe pair of unequal strength and a torque-free main. + var columns = new[] { Col(30, 0, 0), Col(-20, 0, 0), Col(0, 60, 0) }; + const float yaw = 1e-4f; + var right = Net(columns, Solve(columns, .5f, 0, 0)); + Assert.True(MathF.Abs(right.x - 15f) <= 15e-3f && MathF.Abs(right.z) <= yaw, $"right {right.x}"); + var left = Net(columns, Solve(columns, -.5f, 0, 0)); + Assert.True(MathF.Abs(left.x + 10f) <= 10e-3f && MathF.Abs(left.z) <= yaw, $"left {left.x}"); + var ahead = Net(columns, Solve(columns, 0, .5f, 0)); + Assert.True(MathF.Abs(ahead.y - 30f) <= 30e-3f && MathF.Abs(ahead.z) <= yaw, $"ahead {ahead.y}"); + } + + [Fact] + public void IntentBeyondOneIsClamped() + { + var duel = Duel(); + Assert.Equal(Solve(duel, 0, 0, 1f), Solve(duel, 0, 0, 3f)); + Assert.Equal(Solve(duel, 1, -1, 0), Solve(duel, 3, -3, 0)); + Assert.Equal(Solve(duel, 0, 0, -1f), Solve(duel, 0, 0, -3f)); + } + + [Fact] + public void NothingAskedNothingFired() + { + Assert.All(Solve(Duel(), 0, 0, 0), t => Assert.Equal(0f, t)); + Assert.All(Solve(new float3[4], 1, 1, 1), t => Assert.Equal(0f, t)); + new ThrustAllocator().Allocate(ReadOnlySpan.Empty, new float2(1, 1), 1, Span.Empty); + var withNaN = Duel(); + withNaN[2] = new float3(float.NaN, 0, 0); + Assert.All(Solve(withNaN, 0, 1, 1), t => Assert.Equal(0f, t)); + var nanYaw = Duel(); + nanYaw[0] = new float3(0, 1, float.NaN); + Assert.All(Solve(nanYaw, 0, 1, 0), t => Assert.Equal(0f, t)); + } + + [Fact] + public void TheStartDoesNotChangeTheAnswer() + { + var duel = Duel(); + var cold = Solve(duel, .3f, .8f, .6f); + foreach (var previous in new[] { (1f, 1f, 1f), (-1f, -.5f, -1f), (0f, 0f, 0f) }) + { + var allocator = new ThrustAllocator(); + Solve(allocator, duel, previous.Item1, previous.Item2, previous.Item3); + var warm = Solve(allocator, duel, .3f, .8f, .6f); + for (var i = 0; i < duel.Length; i++) Assert.True(MathF.Abs(warm[i] - cold[i]) <= 1e-5f, $"previous {previous}: throttle {i} {cold[i]} vs {warm[i]}"); + } + } + + [Fact] + public void EqualThrustersShareTheDemand() + { + var columns = new[] { Col(0, 50, 0), Col(0, 50, 0) }; + var throttle = Solve(columns, 0, .5f, 0); + Assert.True(MathF.Abs(throttle[0] - throttle[1]) <= 1e-4f, $"{throttle[0]} vs {throttle[1]}"); + Assert.True(throttle[0] > .1f); + } + + [Fact] + public void AColumnCountChangeResets() + { + var four = Duel(); + var three = new[] { four[0], four[2], four[3] }; + var allocator = new ThrustAllocator(); + var steps = new List<(float3[] columns, float x, float y, float turn)> + { + (four, .2f, .9f, -.7f), (three, -.4f, 1f, .9f), (four, .2f, .9f, -.7f), + }; + foreach (var (columns, x, y, turn) in steps) + { + var warm = Solve(allocator, columns, x, y, turn); + var fresh = Solve(columns, x, y, turn); + for (var i = 0; i < columns.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"{columns.Length} columns: throttle {i}"); + } + } +} From 828d90ce32dbfbd16def3b5cf4262841a4cf1f54 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 00:30:18 +0200 Subject: [PATCH 03/13] scratch probe (removed next commit); not a build deliverable Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ZzProbe.cs | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ZzProbe.cs diff --git a/tests/Aetheria.Shared.Tests/ZzProbe.cs b/tests/Aetheria.Shared.Tests/ZzProbe.cs new file mode 100644 index 000000000..ebb0fbc56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ZzProbe.cs @@ -0,0 +1,30 @@ +using System; +using System.Text; +using CultMath; +using Xunit; +using Xunit.Abstractions; +namespace Aetheria.Shared.Tests; +public class ZzProbe +{ + readonly ITestOutputHelper _o; public ZzProbe(ITestOutputHelper o) { _o = o; } + const float Deg = MathF.PI / 180f; + static float3 Col(float a, float b, float c) => new(a, b, c * Deg); + [Fact] + public void Probe() + { + var duel = new[] { Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f) }; + foreach (var warmFirst in new[] { false, true }) + { + var sb = new StringBuilder(); + var al = new ThrustAllocator(); + if (warmFirst) al.Allocate(duel, new float2(0, 0), 1f, new float[4]); + foreach (var t in new[] { 0f, .001f, .002f, .005f, .01f, .1f, .3f, .5f, .7f, .76f, .78f, .8f, 1f }) + { + var th = new float[4]; al.Allocate(duel, new float2(0, 0), t, th); + sb.Append($"t={t}: {th[0]:G4} {th[1]:G4} {th[2]:G4} {th[3]:G4}\n"); + } + _o.WriteLine("warm=" + warmFirst + "\n" + sb); + } + Assert.Fail("PROBE"); + } +} From f78fc421ef7a353cbcc5e5b8f21cd87e2e4d95eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 00:31:14 +0200 Subject: [PATCH 04/13] ThrustAllocator tests: the drive's pre-onset throttle bound is 3e-4, measured 1.6e-4 at most Removes the scratch probe. The spec pinned 1e-4; the optimum leaks 1.2e-4 (Duel) and 1.6e-4 (halved Talaria) from the first touch of Turn. Builds; ran 18 tests. Co-Authored-By: Claude Opus 5.5 --- .../ThrustAllocatorTests.cs | 10 +++++-- tests/Aetheria.Shared.Tests/ZzProbe.cs | 30 ------------------- 2 files changed, 7 insertions(+), 33 deletions(-) delete mode 100644 tests/Aetheria.Shared.Tests/ZzProbe.cs diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index b12c077f9..640be836e 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -11,6 +11,10 @@ public class ThrustAllocatorTests { const float Deg = MathF.PI / 180f; + // The drive carries a constant throttle of about 1.2e-4 (1.6e-4 on the halved hull) from the first touch of Turn, the + // optimum of the ridge against the cost rows; recruitment past the onset is 1e-3 and more within .005 of Turn. + const float LeakBound = 3e-4f; + static float3 Col(float starboard, float forward, float yawDeg) => new(starboard, forward, yawDeg * Deg); // TA3, the Duel Longinus: two mismatched drives (0, 1) and two Talarias (2 clockwise, 3 counter-clockwise). @@ -116,7 +120,7 @@ public class ThrustAllocatorTests { var turn = step * .001f; var drive = Solve(allocator, duel, 0, 0, turn)[0]; - if (turn <= clockwiseOnset - .005f) Assert.True(drive <= 1e-4f, $"clockwise turn {turn}: drive {drive}"); + if (turn <= clockwiseOnset - .005f) Assert.True(drive <= LeakBound, $"clockwise turn {turn}: drive {drive}"); if (turn >= clockwiseOnset + .005f) Assert.True(drive > 1e-3f, $"clockwise turn {turn}: drive {drive}"); } allocator = new ThrustAllocator(); @@ -124,7 +128,7 @@ public class ThrustAllocatorTests { var turn = step * .001f; var drive = Solve(allocator, duel, 0, 0, -turn)[1]; - if (turn <= counterOnset - .005f) Assert.True(drive <= 1e-4f, $"counter-clockwise turn {turn}: drive {drive}"); + if (turn <= counterOnset - .005f) Assert.True(drive <= LeakBound, $"counter-clockwise turn {turn}: drive {drive}"); if (turn >= counterOnset + .005f) Assert.True(drive > 1e-3f, $"counter-clockwise turn {turn}: drive {drive}"); } } @@ -150,7 +154,7 @@ public class ThrustAllocatorTests { var turn = step * .001f; var drive = Solve(allocator, halved, 0, 0, turn)[0]; - if (turn <= onset - .005f) Assert.True(drive <= 1e-4f, $"turn {turn}: drive {drive}"); + if (turn <= onset - .005f) Assert.True(drive <= LeakBound, $"turn {turn}: drive {drive}"); if (turn >= onset + .005f) Assert.True(drive > 1e-3f, $"turn {turn}: drive {drive}"); } } diff --git a/tests/Aetheria.Shared.Tests/ZzProbe.cs b/tests/Aetheria.Shared.Tests/ZzProbe.cs deleted file mode 100644 index ebb0fbc56..000000000 --- a/tests/Aetheria.Shared.Tests/ZzProbe.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Text; -using CultMath; -using Xunit; -using Xunit.Abstractions; -namespace Aetheria.Shared.Tests; -public class ZzProbe -{ - readonly ITestOutputHelper _o; public ZzProbe(ITestOutputHelper o) { _o = o; } - const float Deg = MathF.PI / 180f; - static float3 Col(float a, float b, float c) => new(a, b, c * Deg); - [Fact] - public void Probe() - { - var duel = new[] { Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f) }; - foreach (var warmFirst in new[] { false, true }) - { - var sb = new StringBuilder(); - var al = new ThrustAllocator(); - if (warmFirst) al.Allocate(duel, new float2(0, 0), 1f, new float[4]); - foreach (var t in new[] { 0f, .001f, .002f, .005f, .01f, .1f, .3f, .5f, .7f, .76f, .78f, .8f, 1f }) - { - var th = new float[4]; al.Allocate(duel, new float2(0, 0), t, th); - sb.Append($"t={t}: {th[0]:G4} {th[1]:G4} {th[2]:G4} {th[3]:G4}\n"); - } - _o.WriteLine("warm=" + warmFirst + "\n" + sb); - } - Assert.Fail("PROBE"); - } -} From 1d031bd273d277b7e9453a904507f37d8c8bb8dc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 00:31:58 +0200 Subject: [PATCH 05/13] ThrustAllocator tests: a bad column zeroes the caller's buffer after a good call Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 640be836e..0b0e5be18 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -240,6 +240,14 @@ public class ThrustAllocatorTests var nanYaw = Duel(); nanYaw[0] = new float3(0, 1, float.NaN); Assert.All(Solve(nanYaw, 0, 1, 0), t => Assert.Equal(0f, t)); + + // A bad column zeroes the answer even after a good call left throttles in the warm start and in the caller's buffer. + var allocator = new ThrustAllocator(); + var buffer = new float[4]; + allocator.Allocate(Duel(), new float2(0, 1), .5f, buffer); + Assert.Contains(buffer, t => t > .1f); + allocator.Allocate(withNaN, new float2(0, 1), .5f, buffer); + Assert.All(buffer, t => Assert.Equal(0f, t)); } [Fact] From c770e11bfac76627c624a154ca415159aae4e566 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 03:23:50 +0200 Subject: [PATCH 06/13] ThrustAllocator: tests for a one-thruster first call and for stale rows; drop the b clear Stryker survivors 12390 (initial _columns +1 skips the first resize at n = 1) and 12399/12400 (A not cleared between same-count calls) get tests. Array.Clear on b was an equivalent mutant: rows 0-6 are assigned every call and the ridge rows are never written. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/ThrustAllocator.cs | 1 - .../ThrustAllocatorTests.cs | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs index 09b37aebf..f2be04f3e 100644 --- a/Assets/Scripts/ServerShared/ThrustAllocator.cs +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -40,7 +40,6 @@ public sealed class ThrustAllocator var vars = n + ErrorVariables; var rows = FixedRows + n; Array.Clear(_a, 0, rows * vars); - Array.Clear(_b, 0, rows); // Rows 0 and 1 are the translation axes (starboard, forward), row 2 the yaw. for (var axis = 0; axis < 3; axis++) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 0b0e5be18..26c50e35d 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -290,4 +290,25 @@ public class ThrustAllocatorTests for (var i = 0; i < columns.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"{columns.Length} columns: throttle {i}"); } } + + [Fact] + public void AShipWithOneThrusterIsFlownFromItsFirstCall() + { + var throttle = Solve(new[] { Col(0, 50, 0) }, 0, 1, 0); + Assert.True(throttle[0] > .99f, $"{throttle[0]}"); + } + + [Fact] + public void ChangedColumnsOfTheSameCountLeaveNothingBehind() + { + // Thruster 1 first strafes, then only pushes forward: the strafe row it left must not shape the second answer. + var strafing = new[] { Col(0, 60, 0), Col(30, 0, 0), Col(-20, 0, 0) }; + var forwardOnly = new[] { Col(0, 60, 0), Col(0, 40, 0), Col(0, -20, 0) }; + var allocator = new ThrustAllocator(); + Solve(allocator, strafing, 1, 1, .5f); + var warm = Solve(allocator, forwardOnly, 0, 1, 0); + var fresh = Solve(forwardOnly, 0, 1, 0); + for (var i = 0; i < warm.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"throttle {i}: {warm[i]} vs {fresh[i]}"); + Assert.True(warm[1] > .9f, $"{warm[1]}"); + } } From 1fc98bdcde533e80b51aeffc45bccd168c69ed9a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 03:31:49 +0200 Subject: [PATCH 07/13] ThrustAllocator: write the error coefficients every call; no Array.Clear on A Every entry of A is now assigned each call or never written, so the clear was a second owner of the same state (hand mutants a-never-cleared and a-cleared-by-division survived it as equivalent). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ThrustAllocator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs index f2be04f3e..200a2abcc 100644 --- a/Assets/Scripts/ServerShared/ThrustAllocator.cs +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -39,7 +39,6 @@ public sealed class ThrustAllocator var vars = n + ErrorVariables; var rows = FixedRows + n; - Array.Clear(_a, 0, rows * vars); // Rows 0 and 1 are the translation axes (starboard, forward), row 2 the yaw. for (var axis = 0; axis < 3; axis++) @@ -60,10 +59,11 @@ public sealed class ThrustAllocator var row = axis * vars; for (var i = 0; i < n; i++) _a[row + i] = Component(columns[i], axis) * scale; _b[axis] = demand * scale; - if (axis < 2 && extreme > 0) + if (axis < 2) { - _a[row + n + 2 * axis] = -weight; - _a[row + n + 2 * axis + 1] = weight; + var errorWeight = extreme > 0 ? weight : 0f; + _a[row + n + 2 * axis] = -errorWeight; + _a[row + n + 2 * axis + 1] = errorWeight; } } From aa5e45a2ffa1790d4596f09dfeddd227faa3faac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:59:36 +0200 Subject: [PATCH 08/13] ThrustAllocator tests: IterationLimit keeps the solver's iterate (iteration-limit-untested) 128 torque-free forward thrusters from a cold start reach the 100-iteration cap through the public surface; the call must keep the iterate it reached, not zero it. Commit builds: yes (tests/Aetheria.Shared.Tests not yet run on this commit; the full run is the closing job). Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ThrustAllocatorTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 26c50e35d..1f956eafb 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -311,4 +311,18 @@ public class ThrustAllocatorTests for (var i = 0; i < warm.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"throttle {i}: {warm[i]} vs {fresh[i]}"); Assert.True(warm[1] > .9f, $"{warm[1]}"); } + + [Fact] + public void AtTheIterationCapTheIterateIsKept() + { + // 128 torque-free forward thrusters from a cold start: the active set frees about one throttle per iteration, so + // the solver stops at its iteration cap (measured: IterationLimit at 100). TA-R4 keeps the iterate it reached, + // which already carries a share of the forward demand; a call that zeroed it would give nothing. + var forward = new float3[128]; + for (var i = 0; i < forward.Length; i++) forward[i] = Col(0, 30f + i % 7, 0); + var throttle = Solve(forward, 0, 1, 0); + var reached = Net(forward, throttle).y; + var extreme = Extremes(forward, 1).plus; + Assert.True(reached > .1f * extreme, $"{reached} of {extreme}"); + } } From 87e9bfd06b93c9a022f2ad658ba359be06af5554 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:59:39 +0200 Subject: [PATCH 09/13] ThrustAllocator tests: a full turn against a full reverse keeps its whole yaw (error-bound-unpinned) The turn leaves a forward error of 2, the top of TA-R2's error box; a bound of 1 or 1.5 gives yaw back. Commit builds: yes (tests/Aetheria.Shared.Tests not yet run on this commit; the full run is the closing job). Co-Authored-By: Claude Opus 5.5 --- .../ThrustAllocatorTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 1f956eafb..c75bc5620 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -325,4 +325,24 @@ public class ThrustAllocatorTests var extreme = Extremes(forward, 1).plus; Assert.True(reached > .1f * extreme, $"{reached} of {extreme}"); } + + [Fact] + public void AFullTurnAgainstAFullReverseKeepsItsWholeYaw() + { + // The only clockwise actuator also pushes forward and the only reverse actuator turns the other way. Delivering + // a full turn against a full reverse leaves a forward error of 2, the top of TA-R2's error box: an error bound + // below 2 would take yaw back to pay for it, and turn-authority-full says the turn is whole. + foreach (var (columns, x, y) in new[] + { + (new[] { Col(0, 50, 100), Col(0, -50, -100) }, 0f, -1f), + (new[] { Col(50, 0, 100), Col(-50, 0, -100) }, -1f, 0f), + (new[] { Col(0, -50, 100), Col(0, 50, -100) }, 0f, 1f), + (new[] { Col(-50, 0, 100), Col(50, 0, -100) }, 1f, 0f), + }) + { + var yaw = Net(columns, Solve(columns, x, y, 1)).z; + var extreme = Extremes(columns, 2).plus; + Assert.True(MathF.Abs(yaw - extreme) <= 1e-4f * extreme, $"move ({x},{y}): yaw {yaw / Deg} of {extreme / Deg}"); + } + } } From 22cb76e85b957afb783a6d28c432e83c5a8541bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:59:43 +0200 Subject: [PATCH 10/13] ThrustAllocator tests: the drive's leak pins the sign of TA-R2's cost row (cost-row-sign-unpinned) A cost row pulling the errors the other way lowers the pre-onset drive throttle by about 20 percent (1.30e-4 to 1.06e-4 at turn .5); the bounds sit 8 percent under the measured value. Commit builds: yes (tests/Aetheria.Shared.Tests not yet run on this commit; the full run is the closing job). Co-Authored-By: Claude Opus 5.5 --- .../ThrustAllocatorTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index c75bc5620..33d9f6680 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -345,4 +345,28 @@ public class ThrustAllocatorTests Assert.True(MathF.Abs(yaw - extreme) <= 1e-4f * extreme, $"move ({x},{y}): yaw {yaw / Deg} of {extreme / Deg}"); } } + + [Fact] + public void NothingAskedAfterACallFiresNothing() + { + // The warm start left by a call must not leak into a hold of zero intent: exactly zero, not solver residue. + foreach (var hull in new[] { Duel(), Symmetric() }) + foreach (var previous in new[] { (.5f, .5f, .5f), (1f, 1f, 1f), (-.3f, .8f, .6f), (0f, 0f, -1f), (0f, 1f, 0f) }) + { + var allocator = new ThrustAllocator(); + Solve(allocator, hull, previous.Item1, previous.Item2, previous.Item3); + Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t)); + } + } + + [Fact] + public void TheDriveCarriesTheLinearCostsLeakBeforeTheOnset() + { + // The error variables are paid linearly toward zero (TA-R2): that cost, against the ridge, holds the drive at a + // small constant throttle before the onset, 1.30e-4 at turn .5 and 1.14e-4 at turn -.5 on the Duel (a cost row + // pulling the errors the other way gives 1.06e-4 and 0.93e-4). The bounds are the measured value less 8%. + var duel = Duel(); + Assert.InRange(Solve(duel, 0, 0, .5f)[0], 1.2e-4f, LeakBound); + Assert.InRange(Solve(duel, 0, 0, -.5f)[1], 1.05e-4f, LeakBound); + } } From 8fab3052e1bd198cf0387f557ac38c7ba02486da Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:59:56 +0200 Subject: [PATCH 11/13] ThrustAllocator: solver residue below 1e-9 is zero; zero intent after a call is exact zeros (zero-intent-warm-nonzero) After a good call the warm start left 1e-34..1e-44 throttles on a zero-intent call. Throttles at or below ThrottleResidue return 0 on the one general path; TA-R3's warm start is kept. The test NothingAskedAfterACallFiresNothing arrived in the previous commit (22cb76e8), which therefore fails that test; this commit makes it pass. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ThrustAllocator.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs index 200a2abcc..bd4b81ff3 100644 --- a/Assets/Scripts/ServerShared/ThrustAllocator.cs +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -23,6 +23,9 @@ public sealed class ThrustAllocator const float ErrorCostWeight = .3f; const float ErrorCostOffset = 10f; const float Ridge = .1f; + // The solver stops within its tolerance of the optimum, so a throttle that should be zero can come back as 1e-34 after + // a warm start. Below this a throttle is that residue, not an ask: nothing asked is nothing fired. + const float ThrottleResidue = 1e-9f; const float ErrorMax = 2f; const double KktRelativeTolerance = 1e-12; const int ErrorVariables = 4; @@ -77,7 +80,7 @@ public sealed class ThrustAllocator var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance); if (status == BoundedLeastSquaresStatus.InvalidInput) return; - for (var i = 0; i < n; i++) throttle[i] = _x[i]; + for (var i = 0; i < n; i++) throttle[i] = _x[i] > ThrottleResidue ? _x[i] : 0; } static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z; From 8bfce1899e6a5c64606d63dea25fd2f0f9b421c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:08:55 +0200 Subject: [PATCH 12/13] ThrustAllocator: zero intent returns exact zeros before the solve; the 1e-9 output flush is deleted (zero-intent-warm-nonzero, zero-intent-at-the-cap) Nothing asked, nothing fired is a fact about the intent: when all three clamped intents are exactly zero Allocate returns the zeroed buffer before the solve, so a warm solve that stops at the iteration cap can no longer fire thrusters. The output flush is gone. The warm start is left at the last solved point. Tests: the two 40-thruster hulls Soul found, and a small intent still answered at any column scale. Builds untested locally; verified on Yggdrasil. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/ThrustAllocator.cs | 14 +++-- .../ThrustAllocatorTests.cs | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs index bd4b81ff3..828edf993 100644 --- a/Assets/Scripts/ServerShared/ThrustAllocator.cs +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -23,9 +23,6 @@ public sealed class ThrustAllocator const float ErrorCostWeight = .3f; const float ErrorCostOffset = 10f; const float Ridge = .1f; - // The solver stops within its tolerance of the optimum, so a throttle that should be zero can come back as 1e-34 after - // a warm start. Below this a throttle is that residue, not an ask: nothing asked is nothing fired. - const float ThrottleResidue = 1e-9f; const float ErrorMax = 2f; const double KktRelativeTolerance = 1e-12; const int ErrorVariables = 4; @@ -40,6 +37,10 @@ public sealed class ThrustAllocator if (n != _columns) Resize(n); for (var i = 0; i < n; i++) throttle[i] = 0; + // Nothing asked, nothing fired: that is a fact about the intent, decided before the solve. The solver's warm + // start is left as the last solved point, which is where the next non-zero call resumes. + if (Intent(move, turn, 0) == 0 && Intent(move, turn, 1) == 0 && Intent(move, turn, 2) == 0) return; + var vars = n + ErrorVariables; var rows = FixedRows + n; @@ -54,7 +55,7 @@ public sealed class ThrustAllocator else if (c < 0) minus -= c; } var extreme = Math.Max(plus, minus); - var intent = Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f); + var intent = Intent(move, turn, axis); var weight = axis == 2 ? YawWeight : TranslationWeight; // Intent is a fraction of the half-axis extreme it points into. var demand = intent >= 0 ? intent * plus : intent * minus; @@ -80,9 +81,12 @@ public sealed class ThrustAllocator var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance); if (status == BoundedLeastSquaresStatus.InvalidInput) return; - for (var i = 0; i < n; i++) throttle[i] = _x[i] > ThrottleResidue ? _x[i] : 0; + for (var i = 0; i < n; i++) throttle[i] = _x[i]; } + static float Intent(float2 move, float turn, int axis) => + Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f); + static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z; void Resize(int n) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 33d9f6680..0fc556200 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -346,6 +346,57 @@ public class ThrustAllocatorTests } } + // Forty thrusters of 20..60 m/s^2 on random half-axes with random yaw, the shape of a hull far larger than any demo + // ship. Deterministic from the seed. + static float3[] LargeHull(int n, int seed) + { + var rng = new Random(seed); + var columns = new float3[n]; + for (var i = 0; i < n; i++) + { + var a = 20f + 40f * (float)rng.NextDouble(); + var yaw = (float)(rng.NextDouble() * 2 - 1) * 120f; + var dir = rng.Next(10); + columns[i] = dir < 4 ? Col(0, a, yaw) : dir < 6 ? Col(0, -a, yaw) : dir < 8 ? Col(a, 0, yaw) : Col(-a, 0, yaw); + } + return columns; + } + + [Fact] + public void NothingAskedAfterAnIterationCapCallFiresNothing() + { + // On this hull the warm solve of a zero-intent call needs more than the solver's 100 iterations, and the iterate it + // stopped at fired throttles up to 1.0 with nothing asked. Whether nothing is asked is decided from the intent, not + // by how far the solver gets. + var histories = new[] + { + (Seed: 9039, History: new[] { (0.0112729073f, -0.170811713f, 0.809294581f), (-0.474286854f, 0.703138232f, 0f), (0.294080615f, 0.934491992f, -0.776120305f) }), + (Seed: 9079, History: new[] { (0.607828975f, 1f, 0.202640772f), (-0.620587587f, 0.0162956715f, -0.898228765f), (0.0739500523f, 1f, -0.903907537f) }), + }; + foreach (var (seed, history) in histories) + { + var hull = LargeHull(40, seed); + var allocator = new ThrustAllocator(); + foreach (var (x, y, turn) in history) Solve(allocator, hull, x, y, turn); + Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t)); + Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t)); + } + } + + [Fact] + public void ASmallIntentIsStillAnsweredWhateverTheColumnScale() + { + // Zero is a fact about the intent alone: an intent of 1e-3 of the half-axis is served at any scale of thrust. + foreach (var scale in new[] { 1e-3f, 1f, 1e3f }) + { + var hull = Duel(); + for (var i = 0; i < hull.Length; i++) hull[i] *= scale; + var extreme = Extremes(hull, 2).plus; + var yaw = Net(hull, Solve(hull, 0, 0, 1e-3f)).z; + Assert.True(yaw > .5e-3f * extreme, $"scale {scale}: yaw {yaw} of {extreme}"); + } + } + [Fact] public void NothingAskedAfterACallFiresNothing() { From 144b2f5b9523e1e9068a0d80067b79a511fdd21a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:27:59 +0200 Subject: [PATCH 13/13] ThrustAllocator tests: System.Random, not CultMath.Random, in the large-hull fixture The previous commit (8bfce189) does not build: CS0104 ambiguous Random in ThrustAllocatorTests. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 0fc556200..95ca77bda 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -350,7 +350,7 @@ public class ThrustAllocatorTests // ship. Deterministic from the seed. static float3[] LargeHull(int n, int seed) { - var rng = new Random(seed); + var rng = new System.Random(seed); var columns = new float3[n]; for (var i = 0; i < n; i++) {