Drawn pose: ship indicators and map centre read DrawAhead; Round takes one time; RailAt logs a missing parent

- ActionGameManager: the visible hostile/friendly indicators and MenuMap.Position read DrawAhead.Position at Clock.Lead.
- DrawAhead.Round drops its lead parameter: the target's lead is the drawn time less its zone's step time, derived inside,
  so a caller cannot pass two times that disagree. Projectile and BallisticFlightTests updated; the hit tests derive their
  lead from the drawn time; two assertions that only varied the now-absent lead are removed.
- Zone.RailAt logs "Requested orbit is not part of this zone!" for a missing parent again (DrawnPoseTests.AMissingParentOrbitIsLoggedAtADrawnTime).

Builds: not yet compiled at this commit (verified by the run that follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 00:50:29 +02:00
commit e5eb87050e
6 changed files with 70 additions and 62 deletions

View file

@ -336,7 +336,7 @@ public class ActionGameManager : MonoBehaviour
Input.Global.ZoneMap.performed += context =>
{
ToggleMenuTab(MenuTab.Map);
MenuMap.Position = CurrentEntity.Position.xz;
MenuMap.Position = DrawAhead.Position(CurrentEntity, Clock.Lead).xz;
};
Input.Global.Inventory.performed += context => ToggleMenuTab(MenuTab.Inventory);
@ -1316,13 +1316,13 @@ public class ActionGameManager : MonoBehaviour
foreach (var indicator in _visibleHostileIndicators)
{
indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity);
indicator.Value.Place.Target = indicator.Key.Position.ToUnity();
indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity();
PlaceFill(indicator.Value, indicator.Key);
}
foreach (var indicator in _visibleFriendlyIndicators)
{
indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity);
indicator.Value.Place.Target = indicator.Key.Position.ToUnity();
indicator.Value.Place.Target = DrawAhead.Position(indicator.Key, Clock.Lead).ToUnity();
PlaceFill(indicator.Value, indicator.Key);
}
var look = Input.Player.Look.ReadValue<Vector2>();

View file

@ -51,7 +51,7 @@ public class Projectile : MonoBehaviour
{
var lead = ActionGameManager.Clock.Lead;
var drawn = DrawAhead.Time(_zone, lead);
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, drawn, lead));
transform.position = V(DrawAhead.Round(_shot, _known, _barrel, BlendTime, drawn));
if (DrawAhead.RoundOver(_shot, _known, drawn)) Finish();
}

View file

@ -91,18 +91,18 @@ public static class DrawAhead
t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z));
}
// A ballistic round at the drawn time (DrawAhead.Time, passed in by the caller, as lead is): on its frozen line while the outcome is unknown or a
// A ballistic round at the drawn time (DrawAhead.Time, passed in by the caller; the target's lead is that time less its zone's step time, derived here, never a second input): on its frozen line while the outcome is unknown or a
// miss, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing.
// A round the sim published as a hit or burst steers from its line, from the commit tick, onto where it ends by the
// time damage lands: a burst's frozen point, else the impact cell on the target's drawn pose; and is held there after.
public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, double drawnTime, float lead)
public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, double drawnTime)
{
var time = (float) drawnTime;
var drawn = Lifted(shot, Line(shot, known?.Result, time), barrel, blendTime, time);
if (known == null || known.Result == ShotResult.Miss) return drawn;
var end = known.HasBurstPoint
? float3(known.BurstPoint.x, shot.FireOrigin.y, known.BurstPoint.y)
: Impact(known.Target, known.Cell, lead);
: Impact(known.Target, known.Cell, (float) (drawnTime - known.Target.Zone.ExactTime));
var commit = shot.ArrivalTime - known.ArrivalIn;
var steer = shot.ArrivalTime > commit ? saturate((time - commit) / (shot.ArrivalTime - commit)) : 1f;
return drawn + (end - drawn) * steer;

View file

@ -533,8 +533,13 @@ public class Zone
// The same rail law at any time, with no memo read or written: a parent is its own rail at that time.
private float2 RailAt(CultRecordKey orbitID, double time)
{
if (!orbitID.IsSet() || !Orbits.TryGetValue(orbitID, out var orbit))
if (!orbitID.IsSet())
return float2.zero;
if (!Orbits.TryGetValue(orbitID, out var orbit))
{
Log?.Invoke("Requested orbit is not part of this zone!");
return float2.zero;
}
var parentPosition = !orbit.Data.Parent.IsSet() ? orbit.Data.FixedPosition : RailAt(orbit.Data.Parent.Key, time);
return parentPosition + Offset(orbit, time);
}

View file

@ -67,28 +67,28 @@ public sealed partial class FireControlCut124Tests
// The shooter moves on after the round left: the line is the one frozen at Fire, not one drawn from where it is now.
e.Shooter.Position += float3(7, 0, 3);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime, 0f);
var start = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime);
Assert.Equal(origin.x, start.x, 3);
Assert.Equal(origin.z, start.z, 3);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f, 0f);
var second = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime + 1f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, second.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, second.z, 3);
foreach (var known in new[] { null, outcome })
foreach (var later in new[] { 0f, 1f, 50f })
{
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later, 0f);
var arrival = DrawAhead.Round(burst, known, burst.FireOrigin, 0f, burst.ArrivalTime + later);
Assert.Equal(burst.BurstPosition.x, arrival.x, 3);
Assert.Equal(burst.BurstPosition.z, arrival.z, 3);
}
// Past the weapon's range it is held at the range point, however late it is asked.
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f, 0f);
var held = DrawAhead.Round(burst, Known(burst, ShotResult.Miss), burst.FireOrigin, 0f, burst.FireTime + NoLockRange / BallisticSpeed + 50f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
// Before it was fired it is still at the origin, not behind it.
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f, 0f);
var before = DrawAhead.Round(burst, null, burst.FireOrigin, 0f, burst.FireTime - 5f);
Assert.Equal(origin.x, before.x, 3);
Assert.Equal(origin.z, before.z, 3);
@ -100,12 +100,12 @@ public sealed partial class FireControlCut124Tests
// A round not known to miss waits at its arrival point however late it is asked.
foreach (var at in new[] { direct.ArrivalTime, direct.FireTime + 99f })
{
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at, 0f);
var waiting = DrawAhead.Round(direct, null, direct.FireOrigin, 0f, at);
Assert.Equal(d.Target.Position.x, waiting.x, 2);
Assert.Equal(d.Target.Position.z, waiting.z, 2);
}
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f, 0f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f, 0f);
Assert.Equal(dOrigin.z + 100f / 2f, DrawAhead.Round(direct, null, direct.FireOrigin, 0f, direct.FireTime + 2.5f).z, 2);
var heldDirect = DrawAhead.Round(direct, Known(direct, ShotResult.Miss), direct.FireOrigin, 0f, direct.FireTime + 1000f / BallisticSpeed + 99f);
Assert.Equal(dOrigin.z + 1000f, heldDirect.z, 2);
}
@ -119,7 +119,7 @@ public sealed partial class FireControlCut124Tests
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = SafeAssert.OnlyShot(e.Zone);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f, 0f);
var late = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.FireTime + 5000f);
Assert.Equal(origin.x, late.x, 3);
Assert.Equal(origin.z, late.z, 3);
}
@ -139,7 +139,7 @@ public sealed partial class FireControlCut124Tests
var after = SafeAssert.OnlyShot(e.Zone);
Assert.Equal(BallisticSpeed, after.Speed, 4);
var aim = normalize(float2(2, 1));
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f, 0f);
var at = DrawAhead.Round(after, null, after.FireOrigin, 0f, after.FireTime + 1f);
Assert.Equal(origin.x + aim.x * BallisticSpeed, at.x, 3);
Assert.Equal(origin.z + aim.y * BallisticSpeed, at.z, 3);
}
@ -164,7 +164,7 @@ public sealed partial class FireControlCut124Tests
for (var i = 0; i < 25; i++)
{
var at = shot.FireTime + i * .37f;
DrawAhead.Round(shot, known, barrel, .1f, at, .01f);
DrawAhead.Round(shot, known, barrel, .1f, at);
DrawAhead.RoundOver(shot, known, at);
DrawAhead.RoundEnd(shot, known?.Result);
DrawAhead.Impact(e.Target, cell, .01f * i);
@ -198,7 +198,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(NoLockRange, after.MaxRange, 4);
Assert.Equal(endBefore, DrawAhead.RoundEnd(after, ShotResult.Miss), 4);
var aim = normalize(float2(2, 1));
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f, 0f);
var held = DrawAhead.Round(after, Known(after, ShotResult.Miss), after.FireOrigin, 0f, after.FireTime + 1000f);
Assert.Equal(origin.x + aim.x * NoLockRange, held.x, 3);
Assert.Equal(origin.z + aim.y * NoLockRange, held.z, 3);
}
@ -269,17 +269,17 @@ public sealed partial class FireControlCut124Tests
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
{
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime + lead, lead);
var waiting = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime + lead);
Assert.Equal(origin.x, waiting.x, 3);
Assert.Equal(origin.z + 100f, waiting.z, 3);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime + lead, lead);
var missed = DrawAhead.Round(shot, Known(shot, ShotResult.Miss), shot.FireOrigin, 0f, shot.ArrivalTime + lead);
Assert.Equal(origin.x, missed.x, 3);
Assert.Equal(origin.z + 100f + lead * speed, missed.z, 3);
}
for (var time = shot.FireTime; time <= shot.ArrivalTime + 1f; time += 1f / 144f)
{
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time, 0f);
var drawn = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, time);
Assert.True(drawn.z - origin.z <= 100f + 1e-3f, "the round was drawn past its arrival point");
}
}
@ -315,7 +315,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(fired.FireTime + 1.05f, record.ArrivalTime, 2);
Assert.True(record.ArrivalTime < fired.ArrivalTime);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime);
var expected = outcome.HasBurstPoint
? outcome.BurstPoint
: record.FireOrigin.xz + record.TravelDirection * (20f * (record.ArrivalTime - record.FireTime));
@ -323,7 +323,7 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(expected.y, stop.z, 3);
foreach (var later in new[] { 0f, .5f, 5f })
{
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later, 0f);
var drawn = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime + later);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.z, 3);
}
@ -350,11 +350,11 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(want.z, got.z, 3);
}
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime, 0f));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f, 0f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend, 0f));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend, 0f));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime, 0f));
Close(barrel, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime));
Close(Line(blend / 2f) + offset * .5f, DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend / 2f));
Close(Line(blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + blend));
Close(Line(2f * blend), DrawAhead.Round(shot, null, barrel, blend, shot.FireTime + 2f * blend));
Close(Line(0f), DrawAhead.Round(shot, null, barrel, 0f, shot.FireTime));
}
// Placement depends on sim time alone: any history of frames walked to a checkpoint draws what a fresh call draws, which
@ -379,18 +379,10 @@ public sealed partial class FireControlCut124Tests
while (elapsed + 1e-4f < checkpoint)
{
elapsed = min(checkpoint, elapsed + frames[frame++ % frames.Length]);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed, 0f);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, 0f);
// A frame that falls `lead` after the step draws what the sim time lead later would.
foreach (var lead in new[] { 1f / 60f, 1f / 90f })
{
var ahead = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint, lead);
Assert.Equal(fresh.x, ahead.x, 3);
Assert.Equal(fresh.y, ahead.y, 3);
Assert.Equal(fresh.z, ahead.z, 3);
DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + elapsed);
}
var drawn = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
var fresh = DrawAhead.Round(shot, miss, shot.FireOrigin, .1f, shot.FireTime + checkpoint);
Assert.Equal(fresh.z, drawn.z, 3);
Assert.Equal(shot.FireOrigin.z + speed * min(checkpoint, 1000f / speed), drawn.z, 3);
Assert.Equal(shot.FireOrigin.x, drawn.x, 3);
@ -420,11 +412,13 @@ public sealed partial class FireControlCut124Tests
e.Target.TurnRate = 3f;
var cell = FarCell(e);
var hit = Hit(shot, e.Target, cell, shot.CommitTime);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime, 0f);
var arrivalOnTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, shot.ArrivalTime);
var cellSize = e.Items.GameplaySettings.SchematicCellSize;
foreach (var lead in new[] { 0f, step / 2f, .999f * step })
// The drawn time is the only input: the target's lead is that time less its zone's step time.
foreach (var drawnTime in new[] { shot.ArrivalTime, shot.ArrivalTime + step / 2f, shot.ArrivalTime + .5f })
{
var lead = (float) (drawnTime - e.Zone.ExactTime);
var position = e.Target.Position;
var direction = e.Target.Direction;
e.Target.Position += float3(e.Target.Velocity.x, 0, e.Target.Velocity.y) * lead;
@ -439,13 +433,10 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(expected.x, impact.x, 3);
Assert.Equal(expected.y, impact.y, 3);
Assert.Equal(expected.z, impact.z, 3);
foreach (var simTime in new[] { shot.ArrivalTime, shot.ArrivalTime + .5f })
{
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, simTime + lead, lead);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
var drawn = DrawAhead.Round(shot, hit, shot.FireOrigin, .1f, drawnTime);
Assert.Equal(expected.x, drawn.x, 3);
Assert.Equal(expected.y, drawn.y, 3);
Assert.Equal(expected.z, drawn.z, 3);
}
}
@ -464,26 +455,21 @@ public sealed partial class FireControlCut124Tests
foreach (var t in new[] { commit - 1f, commit })
{
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t, 0f);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t, 0f);
var steered = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, t);
var line = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, t);
Assert.Equal(line.x, steered.x, 3);
Assert.Equal(line.y, steered.y, 3);
Assert.Equal(line.z, steered.z, 3);
}
var m = commit + 1f;
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m, 0f);
var end = DrawAhead.Impact(e.Target, cell, 0f);
var onTheLine = DrawAhead.Round(shot, null, shot.FireOrigin, 0f, m);
var end = DrawAhead.Impact(e.Target, cell, (float) (m - e.Zone.ExactTime));
Assert.True(length((end - onTheLine).xz) > 1f, "the fixture put the impact cell on the line");
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, 0f);
var halfway = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m);
Assert.Equal(onTheLine.x + (end.x - onTheLine.x) * .5f, halfway.x, 3);
Assert.Equal(onTheLine.y + (end.y - onTheLine.y) * .5f, halfway.y, 3);
Assert.Equal(onTheLine.z + (end.z - onTheLine.z) * .5f, halfway.z, 3);
var ahead = DrawAhead.Round(shot, hit, shot.FireOrigin, 0f, m, .02f);
Assert.Equal(halfway.x, ahead.x, 3);
Assert.Equal(halfway.y, ahead.y, 3);
Assert.Equal(halfway.z, ahead.z, 3);
}
// The sim states facts and draws nothing: no drawing function, and no lead, blend or barrel, lives in FireControl;
@ -526,8 +512,8 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(origin.x, outcome.BurstPoint.x, 3);
Assert.Equal(origin.z + NoLockRange, outcome.BurstPoint.y, 3);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime, 0f).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime, 0f);
Assert.Equal(origin.z + 50f, DrawAhead.Round(record, null, record.FireOrigin, 0f, record.ArrivalTime).z, 3);
var stop = DrawAhead.Round(record, outcome, record.FireOrigin, 0f, record.ArrivalTime);
Assert.Equal(outcome.BurstPoint.x, stop.x, 3);
Assert.Equal(outcome.BurstPoint.y, stop.z, 3);
}

View file

@ -394,6 +394,23 @@ public sealed class DrawnPoseTests : IDisposable
}
}
// An orbit whose parent the zone does not hold is reported when a rail is evaluated at a drawn time.
[Fact]
public void AMissingParentOrbitIsLoggedAtADrawnTime()
{
var w = Build();
Step(w, 1f / 60f, 30);
var log = new List<string>();
w.Zone.Log = log.Add;
w.Zone.Orbits.Remove(w.PlanetOrbit);
foreach (var lead in new[] { .004f, .5f })
{
log.Clear();
w.Zone.OrbitPositionAt(w.MoonOrbit, w.Zone.ExactTime + lead);
Assert.Contains("Requested orbit is not part of this zone!", log);
}
}
private static void AssertNear(float2 expected, float2 actual, float eps, string what) =>
Assert.True(length(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})");
}