Throttle lock is a forward floor on the solve; allocation moves after the performance update

The lock stops being an intent rewrite in Ship.Update: ThrustAllocator.Allocate takes forwardFloor, which ignores the
stick, asks full forward and holds every column with y > 0 at full (ruling overdrive-forward-floor). Ship allocates in
Entity's new UpdateThrottles phase, after every item's UpdatePerformance and before the power bus and Execute, so
liveness and thrust are the ones Execute acts on (finding liveness-read-before-performance).
Tests follow in the next commits; the test project still compiles, new behaviour is untested here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 19:26:49 +02:00
commit 534e402fdf
3 changed files with 26 additions and 12 deletions

View file

@ -1373,6 +1373,10 @@ public abstract class Entity
yield return b;
}
// The phase between the items' performance update and the power bus: what an entity commands its items to do this
// tick, read from the same liveness and stats Execute then acts on. Ship's allocation lives here.
protected virtual void UpdateThrottles() { }
public virtual void Update(float delta)
{
TurnRate = 0;
@ -1402,6 +1406,7 @@ public abstract class Entity
UpdateTemperature(delta);
foreach (var item in _orderedEquipment) item.UpdatePerformance();
UpdateThrottles();
if (_active)
{

View file

@ -98,19 +98,21 @@ public class Ship : Entity
#endregion
// Called by Entity.Update once the items' performance is current and before the power bus is stepped and the items
// execute: liveness, thrust and the throttles they decide are one phase, the one Execute acts on. The lock is a
// bound on the allocator's solve; intent reaches the allocator here and nowhere else.
protected override void UpdateThrottles()
{
if (_exitingWormhole || _enteringWormhole) return;
RecalculateEnvelope();
for (var i = 0; i < _allThrusters.Length; i++)
_columns[i] = _allThrusters[i].Column(_allThrusters[i].NominalThrust);
_allocator.Allocate(_columns, MovementDirection, Turn, _throttles, ThrottleLocked);
for (var i = 0; i < _allThrusters.Length; i++) _allThrusters[i].Axis = _throttles[i];
}
public override void Update(float delta)
{
if (_active && !_exitingWormhole && !_enteringWormhole)
{
RecalculateEnvelope();
// The lock replaces intent here, once; player and agent intent both pass through this one read.
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
for (var i = 0; i < _allThrusters.Length; i++)
_columns[i] = _allThrusters[i].Column(_allThrusters[i].NominalThrust);
_allocator.Allocate(_columns, move, Turn, _throttles);
for (var i = 0; i < _allThrusters.Length; i++) _allThrusters[i].Axis = _throttles[i];
}
var velocityMagnitude = length(Velocity);
if(velocityMagnitude > .01f)
Velocity = normalize(Velocity) * decay(velocityMagnitude, HullData.Drag, delta);

View file

@ -31,11 +31,18 @@ public sealed class ThrustAllocator
int _columns = -1;
float[] _a, _b, _lo, _hi, _x, _workspace;
public void Allocate(ReadOnlySpan<float3> columns, float2 move, float turn, Span<float> throttle)
/// <summary>
/// Solves the throttles for the intent. <paramref name="forwardFloor"/> is the throttle lock: every thruster whose
/// column pushes forward is bounded below at full, the stick is ignored (forward is the only translation asked, so
/// nothing fires against the floored drives), and the turn is served by what remains.
/// </summary>
public void Allocate(ReadOnlySpan<float3> columns, float2 move, float turn, Span<float> throttle, bool forwardFloor = false)
{
var n = columns.Length;
if (n != _columns) Resize(n);
for (var i = 0; i < n; i++) throttle[i] = 0;
if (forwardFloor) move = new float2(0, 1);
for (var i = 0; i < n; i++) _lo[i] = forwardFloor && columns[i].y > 0 ? 1f : 0f;
// 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.