From c4bd6e153e809c3232fec5be479eabd2f49aac2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:26:02 +0200 Subject: [PATCH 1/6] Sim speed: one SimClock steps the zone; presenters draw ahead by Lead SimClock (engine-free) turns real frame seconds into whole fixed steps; ActionGameManager.Update feeds it outside the pause gate and Zone.Update(Time.deltaTime) is gone, with IntroCutscene (dead, Unity-driven pose writes). Entity.TurnRate is the sum of the rotation actuators' applied turn per sim second, zeroed each Update; DrawAhead reads it so EntityInstance and ShipInstance draw the latest sim state ahead by Lead. Unverified at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 37 +-- Assets/Scripts/Gameplay/EntityInstance.cs | 5 +- Assets/Scripts/Gameplay/ShipInstance.cs | 2 +- .../ServerShared/Behaviors/AetherDrive.cs | 5 +- .../ServerShared/Behaviors/Thruster.cs | 5 +- Assets/Scripts/ServerShared/DrawAhead.cs | 36 +++ Assets/Scripts/ServerShared/DrawAhead.cs.meta | 11 + Assets/Scripts/ServerShared/Entity.cs | 5 + Assets/Scripts/ServerShared/SimClock.cs | 54 ++++ Assets/Scripts/ServerShared/SimClock.cs.meta | 11 + tests/Aetheria.Shared.Tests/SimClockTests.cs | 262 ++++++++++++++++++ 11 files changed, 393 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/ServerShared/DrawAhead.cs create mode 100644 Assets/Scripts/ServerShared/DrawAhead.cs.meta create mode 100644 Assets/Scripts/ServerShared/SimClock.cs create mode 100644 Assets/Scripts/ServerShared/SimClock.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/SimClockTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index f920522ae..0476acde4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -177,8 +177,6 @@ public class ActionGameManager : MonoBehaviour public Sprite ShieldIcon; public Sprite NoShieldIcon; - public float IntroDuration; - //public PlayerInput Input; // private CinemachineFramingTransposer _transposer; @@ -231,6 +229,7 @@ public class ActionGameManager : MonoBehaviour public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } + public static SimClock Clock { get; private set; } private readonly (float2 direction, string name)[] _directions = { (float2(0, 1), "Front"), @@ -728,6 +727,7 @@ public class ActionGameManager : MonoBehaviour galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } Zone = galaxyZone.Contents; + Clock = new SimClock(1f / 60f); PlayMusic(MusicType.Overworld); Zone.Log = s => Debug.Log($"Zone: {s}"); @@ -818,7 +818,6 @@ public class ActionGameManager : MonoBehaviour } else { - //StartCoroutine(IntroCutscene(targetEntity as Ship)); BindToEntity(targetEntity); } @@ -866,35 +865,6 @@ public class ActionGameManager : MonoBehaviour BindToEntity(staged.Player); } - private IEnumerator IntroCutscene(Ship ship) - { - ZoneRenderer.PerspectiveEntity = ship; - var entityPosition = ship.Position.xz; - var followOrbit = Zone.Orbits.Keys.MinBy(o => lengthsq(Zone.GetOrbitPosition(o) - entityPosition)); - var followPlanet = ZoneRenderer.Planets[Zone.Planets.FirstOrDefault(p => p.Value.Orbit.Key.Equals(followOrbit)).Key]; - DockCamera.Follow = followPlanet.Body.transform; - var rootOrbit = followOrbit; - while (Zone.Orbits[rootOrbit].Data.Parent.IsSet()) - rootOrbit = Zone.Orbits[rootOrbit].Data.Parent.Key; - var rootPlanet = ZoneRenderer.Planets[Zone.Planets.FirstOrDefault(p => p.Value.Orbit.Key.Equals(rootOrbit)).Key]; - DockCamera.LookAt = rootPlanet.Body.transform; - - var shipVelocity = ship.GetBehavior().Limit; - var followOrbitPosition = Zone.GetOrbitPosition(followOrbit); - var shipDirection = normalize(Zone.GetOrbitPosition(rootOrbit) - followOrbitPosition); - ship.Position.xz = followOrbitPosition - shipDirection * shipVelocity * IntroDuration; - - var startTime = Time.time; - while (Time.time - startTime < IntroDuration) - { - ship.Direction = shipDirection; - ship.Velocity = shipDirection * shipVelocity; - yield return null; - } - - BindToEntity(ship); - } - public void Dock() { if (CurrentEntity.Parent != null) return; @@ -1356,8 +1326,9 @@ public class ActionGameManager : MonoBehaviour CurrentEntity.TractorPower = saturate(CurrentEntity.TractorPower + sign(tractorPower - CurrentEntity.TractorPower) * Time.deltaTime * 2); } - Zone.Update(Time.deltaTime); } + // One clock steps the zone, paused or not: a paused frame feeds it no real time. + Clock.Advance(_paused ? 0f : Time.deltaTime, Zone.Update); } private static string ResultLabel(ShotResult result) => result switch diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 61146034e..318a3007e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -416,9 +416,10 @@ public class EntityInstance : MonoBehaviour var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction; group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance; } - LocalSpace.localPosition = transform.position = Entity.Position.ToUnity(); + var drawn = DrawAhead.Position(Entity, ActionGameManager.Clock.Lead); + LocalSpace.localPosition = transform.position = drawn.ToUnity(); if (_influenceInstance) - _influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z); + _influenceInstance.position = new Vector3(drawn.x, 0, drawn.z); } public virtual void OnDestroy() diff --git a/Assets/Scripts/Gameplay/ShipInstance.cs b/Assets/Scripts/Gameplay/ShipInstance.cs index cc110fc8f..18902a422 100644 --- a/Assets/Scripts/Gameplay/ShipInstance.cs +++ b/Assets/Scripts/Gameplay/ShipInstance.cs @@ -138,6 +138,6 @@ public class ShipInstance : EntityInstance emissionModule.rateOverTimeMultiplier = thrusterInstance.BaseEmission * thrusterInstance.Thruster.Axis * thrusterInstance.Thruster.Condition; } - transform.rotation = Ship.Rotation.ToUnity(); + transform.rotation = DrawAhead.Rotation(Ship, ActionGameManager.Clock.Lead).ToUnity(); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index e8498ca7f..fcb6de134 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -151,8 +151,9 @@ public class AetherDrive : Behavior, IPowerConsumer ThrustDirection = forward * (_axis.x * force.x / Entity.Mass) + right * (_axis.y * force.y / Entity.Mass); Entity.Velocity += ThrustDirection; - Entity.Direction = mul(Entity.Direction, - CultMath.float2x2.Rotate(force.z * _axis.z * ItemManager.GameplaySettings.AetherTorqueMultiplier / Entity.Mass)); + var turn = force.z * _axis.z * ItemManager.GameplaySettings.AetherTorqueMultiplier / Entity.Mass; + Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turn)); + if (dt > 0) Entity.TurnRate += turn / dt; if(float.IsNaN(Entity.Velocity.x)) ItemManager.Log("FUCK FUCK FUCK FUCK"); diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index be0d79900..007bf66bc 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -125,8 +125,9 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { var thrust = Thrust; Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt; - Entity.Direction = mul(Entity.Direction, - CultMath.float2x2.Rotate(_input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt)); + var turnRate = _input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass; + Entity.Direction = mul(Entity.Direction, CultMath.float2x2.Rotate(turnRate * dt)); + Entity.TurnRate += turnRate; AddHeat(_input * Evaluate(_data.Heat) * dt); var vis = _input * Evaluate(_data.Visibility); if (!Entity.VisibilitySources.ContainsKey(this) || vis > Entity.VisibilitySources[this]) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs new file mode 100644 index 000000000..da4e7d94a --- /dev/null +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -0,0 +1,36 @@ +/* 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 CultMath; +using static CultMath.math; + +// Where a presenter draws the latest sim state when the frame falls Lead sim seconds after the last step. Pure reads +// of the simulation for presenters (never called by ServerShared): the sim owns every pose and no presenter stores +// one, a previous state or a velocity of its own. +public static class DrawAhead +{ + public static float3 Position(Entity entity, float lead) => + entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; + + // The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds. + public static quaternion Rotation(Ship ship, float lead) + { + var q = ship.Rotation; + var u = float3(q.x, q.y, q.z); + var up = float3(0, 1, 0); + up += 2 * (q.w * cross(u, up) + cross(u, cross(u, up))); + var half = -ship.TurnRate * lead * .5f; + var s = sin(half); + var t = new quaternion(up.x * s, up.y * s, up.z * s, cos(half)); + // Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here). + return normalize(new quaternion( + t.w * q.x + t.x * q.w + t.y * q.z - t.z * q.y, + t.w * q.y - t.x * q.z + t.y * q.w + t.z * q.x, + t.w * q.z + t.x * q.y - t.y * q.x + t.z * q.w, + t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z)); + } + + // Zone time for a presenter that evaluates a sim function of time (FireControl.RoundAt). + public static float Time(Zone zone, float lead) => zone.Time + lead; +} diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs.meta b/Assets/Scripts/ServerShared/DrawAhead.cs.meta new file mode 100644 index 000000000..628d71b5d --- /dev/null +++ b/Assets/Scripts/ServerShared/DrawAhead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 007fe4255cd84c26a010151a399f45c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b83fc141e..a1eb14c49 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -27,6 +27,10 @@ public abstract class Entity public float3 Position; public float2 Direction = float2(0,1); public float2 Velocity; + // Rad per sim second, signed as the step turns Direction: the sum of this step's rotation actuators' applied + // angle over the step. Runtime only, zeroed at the start of Update, so a staged, loaded or wormhole write to + // Direction is never a turn. Read only by DrawAhead. + public float TurnRate; // Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change // across the base update over its delta (thrust, not the drag or gravity a subclass applies before calling it), @@ -1359,6 +1363,7 @@ public abstract class Entity public virtual void Update(float delta) { + TurnRate = 0; if (!_active) return; var velocityBefore = Velocity; diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs new file mode 100644 index 000000000..15e132ac0 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -0,0 +1,54 @@ +/* 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; + +// The one relation between simulation time and real time. Real seconds go in; whole fixed steps come out, each +// handed to the caller's step action with the same Step. Nothing else in the game converts frame time to sim time, +// and nothing integrates a sim quantity on this clock: presenters read Lead to draw ahead of the latest step +// (DrawAhead) and decide no sim fact. +public sealed class SimClock +{ + // The longest real time one frame may feed. A hitch (a load, a breakpoint) is a slow frame, not a burst of + // catch-up steps. + public const float MaxFrameSeconds = .1f; + + private readonly double _period; + private double _accumulator; + + public SimClock(float step, float stepsPerRealSecond = 60) + { + if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step)); + if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond)); + Step = step; + StepsPerRealSecond = stepsPerRealSecond; + _period = 1.0 / stepsPerRealSecond; + } + + // Sim seconds per step, fixed for the clock's life. + public float Step { get; } + + public float StepsPerRealSecond { get; } + + public long Steps { get; private set; } + + // Sim seconds since the last step, 0 <= Lead < Step. + public float Lead => (float) (_accumulator * StepsPerRealSecond * Step); + + // Feeds real seconds (negative counts as none, one frame counts at most MaxFrameSeconds) and runs the whole + // steps they pay for. Returns how many ran. + public int Advance(float realSeconds, Action step) + { + _accumulator += Math.Min(Math.Max(realSeconds, 0f), MaxFrameSeconds); + var ran = 0; + while (_accumulator >= _period) + { + _accumulator -= _period; + step(Step); + Steps++; + ran++; + } + return ran; + } +} diff --git a/Assets/Scripts/ServerShared/SimClock.cs.meta b/Assets/Scripts/ServerShared/SimClock.cs.meta new file mode 100644 index 000000000..30e06c489 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimClock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a9e3edac65f4a8e8313bdbfe69ccebd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs new file mode 100644 index 000000000..0fdc55d81 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -0,0 +1,262 @@ +/* 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 System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// SimClock owns sim time's relation to real time: frames of real seconds in, whole fixed steps out. Nothing here hands +// a clock to a presenter; DrawAhead is pure reads of the latest state. +public sealed class SimClockTests +{ + private static readonly float[] Irregular = { 1f / 144, 1f / 30, 1f / 90 }; + + // Runs frames cycling through the lengths until the clock has run the steps; returns the step values it passed. + private static List Drive(SimClock clock, float[] frames, long steps) + { + var passed = new List(); + for (var frame = 0; clock.Steps < steps; frame++) + clock.Advance(frames[frame % frames.Length], dt => passed.Add(dt)); + return passed; + } + + [Fact] + public void TheStepNeverChanges() + { + foreach (var frames in new[] { new[] { 1f / 60 }, Irregular, new[] { .09f, .004f, 0f, 3f } }) + { + var clock = new SimClock(1f / 60f); + var passed = Drive(clock, frames, 500); + Assert.True(passed.Count >= 500); + Assert.All(passed, dt => Assert.Equal(clock.Step, dt)); + } + var slow = new SimClock(.1f, 6); + Assert.All(Drive(slow, Irregular, 50), dt => Assert.Equal(.1f, dt)); + } + + [Fact] + public void StepsFollowRealTime() + { + // Ten real seconds of irregular frames. + var clock = new SimClock(1f / 60f); + var elapsed = 0.0; + for (var frame = 0; elapsed < 10.0; frame++) + { + var seconds = Irregular[frame % Irregular.Length]; + elapsed += seconds; + clock.Advance(seconds, _ => { }); + } + Assert.InRange(elapsed, 10.0, 10.1); + Assert.InRange(clock.Steps, Math.Round(elapsed * 60) - 1, Math.Round(elapsed * 60) + 1); + + var slow = new SimClock(1f / 60f, 30); + for (var frame = 0; frame < 600; frame++) slow.Advance(1f / 60, _ => { }); + Assert.InRange(slow.Steps, 299, 301); + } + + [Fact] + public void AHitchIsClamped() + { + var clock = new SimClock(1f / 60f); + var ran = clock.Advance(5f, _ => { }); + Assert.Equal(6, ran); + Assert.Equal(6, clock.Steps); + } + + [Fact] + public void PauseFeedsNothing() + { + var clock = new SimClock(1f / 60f); + clock.Advance(.025f, _ => { }); + var lead = clock.Lead; + var steps = clock.Steps; + Assert.True(lead > 0); + foreach (var seconds in new[] { 0f, -1f }) + { + Assert.Equal(0, clock.Advance(seconds, _ => throw new InvalidOperationException())); + Assert.Equal(lead, clock.Lead); + Assert.Equal(steps, clock.Steps); + } + } + + [Fact] + public void LeadIsHonest() + { + var clock = new SimClock(.1f, 6); + double sinceLastStep = 0; + for (var frame = 0; frame < 400; frame++) + { + // The real time since the last step: what was left over plus this frame, less the steps it paid for. + sinceLastStep += Irregular[frame % Irregular.Length]; + var seconds = Irregular[frame % Irregular.Length]; + clock.Advance(seconds, _ => sinceLastStep -= 1.0 / clock.StepsPerRealSecond); + Assert.InRange(clock.Lead, 0f, clock.Step * .99999f); + Assert.Equal(sinceLastStep * clock.StepsPerRealSecond * clock.Step, clock.Lead, 4); + } + } + + [Fact] + public void ABadClockIsRefusedByFieldName() + { + Assert.Equal("step", Assert.Throws(() => new SimClock(0f)).ParamName); + Assert.Equal("stepsPerRealSecond", Assert.Throws(() => new SimClock(1f, 0f)).ParamName); + } +} + +public sealed partial class RunStartTests +{ + private const float ClockFrame = 1f / 60f; + private const int HistorySteps = 1800; + + // The Duel launched as the game launches it, its item random reseeded so play draws the same numbers every time. + private (Zone arena, Ship player) LaunchDuel() + { + var launched = Launch(new Duel()); + Assert.Empty(launched.failures); + _items.Random = new CultMath.Random(0xC0FFEE); + launched.staged.Player.MovementDirection = float2(0, 1); + launched.staged.Player.Turn = .5f; + return (launched.arena, launched.staged.Player); + } + + private static string R(float value) => value.ToString("R", CultureInfo.InvariantCulture); + + // Everything the step decides, as text: the zone clock, each entity's pose and condition, and each shot resolved. + private static string Snapshot(Zone zone, List shots) + { + var text = new StringBuilder(R(zone.Time)); + foreach (var entity in zone.Entities) + { + text.Append($"|{entity.Name} {R(entity.Position.x)},{R(entity.Position.z)} v{R(entity.Velocity.x)},{R(entity.Velocity.y)} " + + $"d{R(entity.Direction.x)},{R(entity.Direction.y)} w{R(entity.TurnRate)} h{R(entity.Hull.Durability)}"); + if (entity.Shield != null) text.Append($" s{R(entity.Shield.Progress)}"); + if (entity.Temperature != null) + { + var heat = 0f; + foreach (var cell in entity.Temperature) heat += cell; + text.Append($" t{R(heat)}"); + } + } + text.Append($"|shots {string.Join(",", shots)}"); + shots.Clear(); + return text.ToString(); + } + + // Feeds the zone through one SimClock with the frames cycled, snapshotting every step until HistorySteps have run. + private static List History(Zone zone, float[] frames) + { + var clock = new SimClock(1f / 60f); + var shots = new List(); + using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}")); + var history = new List(); + for (var frame = 0; history.Count < HistorySteps; frame++) + clock.Advance(frames[frame % frames.Length], dt => + { + zone.Update(dt); + history.Add(Snapshot(zone, shots)); + }); + return history.Take(HistorySteps).ToList(); + } + + // Control: the same launch driven twice the same way is the same history. If this fails the simulation is + // nondeterministic before the clock is involved, and FrameTimesDoNotChangeHistory proves nothing. + [Fact] + public void OneHistoryTwice() + { + var first = History(LaunchDuel().arena, new[] { ClockFrame }); + var second = History(LaunchDuel().arena, new[] { ClockFrame }); + Assert.Equal(first, second); + Assert.Contains(first, line => !line.EndsWith("shots ")); + } + + [Fact] + public void FrameTimesDoNotChangeHistory() + { + var steady = History(LaunchDuel().arena, new[] { ClockFrame }); + var irregular = History(LaunchDuel().arena, new[] { 1f / 144, 1f / 30, 1f / 90 }); + Assert.Equal(steady.Count, irregular.Count); + for (var step = 0; step < steady.Count; step++) + Assert.True(steady[step] == irregular[step], $"step {step}: {steady[step]} != {irregular[step]}"); + } + + private static float SignedTurn(float2 before, float2 after) => + atan2(before.x * after.y - before.y * after.x, dot(before, after)); + + [Fact] + public void TurnRateIsTheActuatorsTurn() + { + var (arena, player) = LaunchDuel(); + Assert.Equal(0f, player.TurnRate); // staged: nothing has turned it + var turned = 0; + for (var step = 0; step < 240; step++) + { + var before = player.Direction; + arena.Update(ClockFrame); + var angle = SignedTurn(before, player.Direction); + Assert.Equal(abs(angle), abs(player.TurnRate * ClockFrame), 5); + // A positive turn demand turns clockwise, and TurnRate is signed the way the step turned Direction. + if (abs(player.TurnRate) > 1e-4f) + { + turned++; + Assert.True(player.TurnRate > 0 == dot(player.Direction, before.Rotate(ItemRotation.Clockwise)) > 0); + } + } + Assert.True(turned > 60, "the held turn turned the ship"); + + // A write to Direction between steps (staging, a load, a wormhole exit) is not a turn. + player.Turn = 0; + for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame); + player.Direction = normalize(float2(3, -4)); + arena.Update(ClockFrame); + Assert.Equal(0f, player.TurnRate); + } + + [Fact] + public void DrawAheadIsTheStateAtZeroLead() + { + var (arena, player) = LaunchDuel(); + for (var step = 0; step < 120; step++) arena.Update(ClockFrame); + var before = Snapshot(arena, new List()); + + var zero = DrawAhead.Position(player, 0); + Assert.Equal(player.Position.x, zero.x); + Assert.Equal(player.Position.y, zero.y); + Assert.Equal(player.Position.z, zero.z); + var rotation = DrawAhead.Rotation(player, 0); + Assert.Equal(1f, abs(rotation.x * player.Rotation.x + rotation.y * player.Rotation.y + rotation.z * player.Rotation.z + rotation.w * player.Rotation.w), 5); + + const float lead = .007f; + var ahead = DrawAhead.Position(player, lead); + Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6); + Assert.Equal(player.Position.y, ahead.y); + Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6); + Assert.Equal(arena.Time + lead, DrawAhead.Time(arena, lead)); + Assert.Equal(before, Snapshot(arena, new List())); + } + + // The turn a presenter draws ahead is the turn the next step makes: a ship held in a steady turn, drawn one whole + // step ahead of the pose it was last given, is where the sim puts that pose next. + [Fact] + public void DrawnRotationFollowsTheTurn() + { + var (arena, player) = LaunchDuel(); + player.Turn = 1f; + for (var step = 0; step < 300; step++) arena.Update(ClockFrame); + var rate = player.TurnRate; + Assert.True(abs(rate) > .05f, "the ship is turning"); + var drawn = DrawAhead.Rotation(player, ClockFrame); + arena.Update(ClockFrame); + Assert.True(abs(player.TurnRate - rate) < 1e-3f * abs(rate), "the turn is steady"); + var next = player.Rotation; + Assert.Equal(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w), 4); + } +} From 4d64966d0499ce4e9204315c5a86190c4e974d3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 18:41:29 +0200 Subject: [PATCH 2/6] Sim speed: the drawn turn goes the way the step turned; the history test fires both ships The first verification showed the rotation sense wrong and no shot resolving in 30 s of Duel; both tests now pin what they claim. Unverified at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 2 +- tests/Aetheria.Shared.Tests/SimClockTests.cs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index da4e7d94a..3cd1a4ea1 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -20,7 +20,7 @@ public static class DrawAhead var u = float3(q.x, q.y, q.z); var up = float3(0, 1, 0); up += 2 * (q.w * cross(u, up) + cross(u, cross(u, up))); - var half = -ship.TurnRate * lead * .5f; + var half = ship.TurnRate * lead * .5f; var s = sin(half); var t = new quaternion(up.x * s, up.y * s, up.z * s, cos(half)); // Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here). diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index 0fdc55d81..11cbc7a30 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -125,6 +125,8 @@ public sealed partial class RunStartTests _items.Random = new CultMath.Random(0xC0FFEE); launched.staged.Player.MovementDirection = float2(0, 1); launched.staged.Player.Turn = .5f; + foreach (var ship in launched.arena.Entities.OfType()) + ship.SetTarget(launched.arena.Entities.OfType().First(other => other != ship)); return (launched.arena, launched.staged.Player); } @@ -161,6 +163,12 @@ public sealed partial class RunStartTests for (var frame = 0; history.Count < HistorySteps; frame++) clock.Advance(frames[frame % frames.Length], dt => { + // Both ships open fire every 90th step, on the step count, so the fight is the same fight whatever + // the frames were. + if (history.Count % 90 == 0) + foreach (var ship in zone.Entities.OfType()) + foreach (var weapon in ship.GetBehaviors()) + FireControl.Fire(weapon, weapon.Item, ship); zone.Update(dt); history.Add(Snapshot(zone, shots)); }); @@ -175,7 +183,7 @@ public sealed partial class RunStartTests var first = History(LaunchDuel().arena, new[] { ClockFrame }); var second = History(LaunchDuel().arena, new[] { ClockFrame }); Assert.Equal(first, second); - Assert.Contains(first, line => !line.EndsWith("shots ")); + Assert.Contains(first, line => !line.EndsWith("|shots ")); // shots were fired and resolved } [Fact] @@ -202,7 +210,7 @@ public sealed partial class RunStartTests var before = player.Direction; arena.Update(ClockFrame); var angle = SignedTurn(before, player.Direction); - Assert.Equal(abs(angle), abs(player.TurnRate * ClockFrame), 5); + Assert.InRange(abs(abs(angle) - abs(player.TurnRate * ClockFrame)), 0f, 1e-6f); // A positive turn demand turns clockwise, and TurnRate is signed the way the step turned Direction. if (abs(player.TurnRate) > 1e-4f) { @@ -257,6 +265,9 @@ public sealed partial class RunStartTests arena.Update(ClockFrame); Assert.True(abs(player.TurnRate - rate) < 1e-3f * abs(rate), "the turn is steady"); var next = player.Rotation; - Assert.Equal(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w), 4); + var error = 2 * acos(min(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w))); + var turn = abs(rate * ClockFrame); + Assert.True(turn > .005f, "the step turns the ship enough to tell its sense"); + Assert.True(error < .2f * turn, $"drawn one step ahead is where the sim puts the pose next (off by {error} of a {turn} turn)"); } } From 38f7d73823843eca414a34d514269c2bde96db0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:16:10 +0200 Subject: [PATCH 3/6] Sim speed: Entity.TurnRate's comment names no presenter Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index a1eb14c49..da315d873 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -29,7 +29,7 @@ public abstract class Entity public float2 Velocity; // Rad per sim second, signed as the step turns Direction: the sum of this step's rotation actuators' applied // angle over the step. Runtime only, zeroed at the start of Update, so a staged, loaded or wormhole write to - // Direction is never a turn. Read only by DrawAhead. + // Direction is never a turn. Read only by presenters that draw ahead of the step. public float TurnRate; // Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change From 090b6d18dc9b8af4c092ee701365cce0f0bcbbad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 21:00:16 +0200 Subject: [PATCH 4/6] Sim speed: tests for the survivors Stryker found (thruster turn, tilted draw, exact step) DrawAhead.Rotation delegates to Turned(pose, angle) so the turn is testable on a tilted pose, where the flat Duel poses make the quaternion product's off-axis terms vanish. Unverified at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/DrawAhead.cs | 8 +-- tests/Aetheria.Shared.Tests/SimClockTests.cs | 51 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 3cd1a4ea1..52c92fee8 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -14,13 +14,15 @@ public static class DrawAhead entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; // The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds. - public static quaternion Rotation(Ship ship, float lead) + public static quaternion Rotation(Ship ship, float lead) => Turned(ship.Rotation, ship.TurnRate * lead); + + // q turned about its own up axis by the angle, in the sense Direction turns for a positive TurnRate. + public static quaternion Turned(quaternion q, float angle) { - var q = ship.Rotation; var u = float3(q.x, q.y, q.z); var up = float3(0, 1, 0); up += 2 * (q.w * cross(u, up) + cross(u, cross(u, up))); - var half = ship.TurnRate * lead * .5f; + var half = angle * .5f; var s = sin(half); var t = new quaternion(up.x * s, up.y * s, up.z * s, cos(half)); // Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here). diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index 11cbc7a30..bc5bdd539 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -58,6 +58,11 @@ public sealed class SimClockTests Assert.InRange(elapsed, 10.0, 10.1); Assert.InRange(clock.Steps, Math.Round(elapsed * 60) - 1, Math.Round(elapsed * 60) + 1); + // A frame that pays for exactly one step runs it (binary-exact seconds, so nothing rounds). + var exact = new SimClock(.25f, 4); + Assert.Equal(1, exact.Advance(.25f, _ => { })); + Assert.Equal(0f, exact.Lead); + var slow = new SimClock(1f / 60f, 30); for (var frame = 0; frame < 600; frame++) slow.Advance(1f / 60, _ => { }); Assert.InRange(slow.Steps, 299, 301); @@ -270,4 +275,50 @@ public sealed partial class RunStartTests Assert.True(turn > .005f, "the step turns the ship enough to tell its sense"); Assert.True(error < .2f * turn, $"drawn one step ahead is where the sim puts the pose next (off by {error} of a {turn} turn)"); } + + // Thrusters turn the same way: a Longinus held in a turn, settled, turns each step by exactly its TurnRate. + [Fact] + public void ThrustersReportTheirTurn() + { + var (_, longinus, _) = EvFleet(); + EvSettle(longinus); + longinus.Turn = 1f; + var turned = 0; + for (var step = 0; step < 120; step++) + { + var before = longinus.Direction; + longinus.Update(ClockFrame); + Assert.InRange(abs(abs(SignedTurn(before, longinus.Direction)) - abs(longinus.TurnRate * ClockFrame)), 0f, 1e-6f); + if (abs(longinus.TurnRate) > 1e-4f) + { + turned++; + Assert.True(longinus.TurnRate > 0 == dot(longinus.Direction, before.Rotate(ItemRotation.Clockwise)) > 0); + } + } + Assert.True(turned > 30, "the thrusters turned the ship"); + } + + // The draw-ahead turn on a pose that is not flat: the up axis stays the pose's up, and the forward axis swings about + // it by the angle (Rodrigues' rotation, written out apart from DrawAhead's quaternion product). + [Fact] + public void TheDrawnTurnIsAboutThePosesOwnUp() + { + var up = normalize(float3(.3f, .8f, -.2f)); + var pose = quaternion.LookRotation(normalize(cross(float3(1, 0, .4f), up)), up); + const float angle = .6f; + var turned = DrawAhead.Turned(pose, angle); + + float3 Axis(quaternion q, float3 v) + { + var u = float3(q.x, q.y, q.z); + return v + 2 * (q.w * cross(u, v) + cross(u, cross(u, v))); + } + float3 Close(float3 a, float3 b) => float3(abs(a.x - b.x), abs(a.y - b.y), abs(a.z - b.z)); + var forward = Axis(pose, float3(0, 0, 1)); + var rodrigues = forward * cos(angle) + cross(Axis(pose, float3(0, 1, 0)), forward) * sin(angle); + var upBefore = Axis(pose, float3(0, 1, 0)); + var tolerance = float3(1e-5f, 1e-5f, 1e-5f); + Assert.True(all(Close(Axis(turned, float3(0, 1, 0)), upBefore) < tolerance), "the pose's up does not move"); + Assert.True(all(Close(Axis(turned, float3(0, 0, 1)), rodrigues) < tolerance), "the forward axis swings about it by the angle"); + } } From cff4e0ffa7599e98d691586e1f5d871b4d2dfcac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 21:01:27 +0200 Subject: [PATCH 5/6] Sim speed: the exact-step frame stays under the hitch clamp Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SimClockTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index bc5bdd539..3a8f72fbd 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -59,8 +59,8 @@ public sealed class SimClockTests Assert.InRange(clock.Steps, Math.Round(elapsed * 60) - 1, Math.Round(elapsed * 60) + 1); // A frame that pays for exactly one step runs it (binary-exact seconds, so nothing rounds). - var exact = new SimClock(.25f, 4); - Assert.Equal(1, exact.Advance(.25f, _ => { })); + var exact = new SimClock(.25f, 16); + Assert.Equal(1, exact.Advance(.0625f, _ => { })); Assert.Equal(0f, exact.Lead); var slow = new SimClock(1f / 60f, 30); From 257a8a4795062b5c54c5fb5ff0fb9570323ca408 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 23:39:30 +0200 Subject: [PATCH 6/6] Sim speed fix: scripted poses are not drawn ahead; a step cannot reset its clock; DrawAhead.Time deleted DrawAhead leaves a ship in a wormhole animation where the step put it. The clock is one static instance; a zone entered inside a step ends that frame's stepping and the clock resets between frames (SimClock.Reset throws inside Advance). DrawAhead.Time had no consumer. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 12 +++++-- Assets/Scripts/ServerShared/DrawAhead.cs | 12 ++++--- Assets/Scripts/ServerShared/SimClock.cs | 24 +++++++++++--- tests/Aetheria.Shared.Tests/SimClockTests.cs | 34 +++++++++++++++++++- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 0476acde4..97c559d35 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -229,7 +229,8 @@ public class ActionGameManager : MonoBehaviour public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } - public static SimClock Clock { get; private set; } + public static SimClock Clock { get; } = new SimClock(1f / 60f); + private bool _zoneEntered; private readonly (float2 direction, string name)[] _directions = { (float2(0, 1), "Front"), @@ -727,7 +728,7 @@ public class ActionGameManager : MonoBehaviour galaxyZone.Contents = new Zone(ItemManager, Settings.PlanetSettings, galaxyZone.PackedContents, galaxyZone, CurrentGalaxy); } Zone = galaxyZone.Contents; - Clock = new SimClock(1f / 60f); + _zoneEntered = true; // the clock forgets its lead between frames, never inside a step PlayMusic(MusicType.Overworld); Zone.Log = s => Debug.Log($"Zone: {s}"); @@ -1328,7 +1329,12 @@ public class ActionGameManager : MonoBehaviour } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. - Clock.Advance(_paused ? 0f : Time.deltaTime, Zone.Update); + // A zone entered inside a step (a wormhole) ends this frame's stepping: the rest of its steps are the old zone's. + void EnterNewZone() { if (_zoneEntered) { Clock.Reset(); _zoneEntered = false; } } + EnterNewZone(); + var steppedZone = Zone; + Clock.Advance(_paused ? 0f : Time.deltaTime, delta => { if (Zone == steppedZone) steppedZone.Update(delta); }); + EnterNewZone(); } private static string ResultLabel(ShotResult result) => result switch diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 52c92fee8..d26bf91b4 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -10,11 +10,16 @@ using static CultMath.math; // one, a previous state or a velocity of its own. public static class DrawAhead { + // A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate, + // so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step. public static float3 Position(Entity entity, float lead) => - entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; + entity is Ship { WormholeAnimationInProgress: true } + ? entity.Position + : entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead; // The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds. - public static quaternion Rotation(Ship ship, float lead) => Turned(ship.Rotation, ship.TurnRate * lead); + public static quaternion Rotation(Ship ship, float lead) => + ship.WormholeAnimationInProgress ? ship.Rotation : Turned(ship.Rotation, ship.TurnRate * lead); // q turned about its own up axis by the angle, in the sense Direction turns for a positive TurnRate. public static quaternion Turned(quaternion q, float angle) @@ -32,7 +37,4 @@ public static class DrawAhead t.w * q.z + t.x * q.y - t.y * q.x + t.z * q.w, t.w * q.w - t.x * q.x - t.y * q.y - t.z * q.z)); } - - // Zone time for a presenter that evaluates a sim function of time (FireControl.RoundAt). - public static float Time(Zone zone, float lead) => zone.Time + lead; } diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs index 15e132ac0..6d00c46d0 100644 --- a/Assets/Scripts/ServerShared/SimClock.cs +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -16,6 +16,7 @@ public sealed class SimClock private readonly double _period; private double _accumulator; + private bool _advancing; public SimClock(float step, float stepsPerRealSecond = 60) { @@ -36,19 +37,32 @@ public sealed class SimClock // Sim seconds since the last step, 0 <= Lead < Step. public float Lead => (float) (_accumulator * StepsPerRealSecond * Step); + // Forgets the real time paid toward the next step (a new zone starts between steps). A step cannot do this to the + // clock that is running it. + public void Reset() + { + if (_advancing) throw new InvalidOperationException("A step cannot reset the clock that is running it."); + _accumulator = 0; + } + // Feeds real seconds (negative counts as none, one frame counts at most MaxFrameSeconds) and runs the whole // steps they pay for. Returns how many ran. public int Advance(float realSeconds, Action step) { _accumulator += Math.Min(Math.Max(realSeconds, 0f), MaxFrameSeconds); var ran = 0; - while (_accumulator >= _period) + _advancing = true; + try { - _accumulator -= _period; - step(Step); - Steps++; - ran++; + while (_accumulator >= _period) + { + _accumulator -= _period; + step(Step); + Steps++; + ran++; + } } + finally { _advancing = false; } return ran; } } diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index 3a8f72fbd..db79647bb 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -77,6 +77,18 @@ public sealed class SimClockTests Assert.Equal(6, clock.Steps); } + [Fact] + public void AStepCannotResetItsClock() + { + var clock = new SimClock(1f / 60f); + Assert.Throws(() => clock.Advance(1f / 60f, _ => clock.Reset())); + clock.Advance(.005f, _ => { }); + Assert.True(clock.Lead > 0); + clock.Reset(); + Assert.Equal(0f, clock.Lead); + Assert.Equal(1, clock.Advance(1f / 60f, _ => { })); + } + [Fact] public void PauseFeedsNothing() { @@ -252,10 +264,30 @@ public sealed partial class RunStartTests Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6); Assert.Equal(player.Position.y, ahead.y); Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6); - Assert.Equal(arena.Time + lead, DrawAhead.Time(arena, lead)); Assert.Equal(before, Snapshot(arena, new List())); } + // A pose the step scripts (the wormhole animation) is drawn where the step put it, at any lead. + [Fact] + public void ScriptedPosesAreNotDrawnAhead() + { + var (arena, player) = LaunchDuel(); + for (var step = 0; step < 120; step++) arena.Update(ClockFrame); + player.EnterWormhole(player.Position.xz + float2(50, 0)); + arena.Update(ClockFrame); + Assert.True(player.WormholeAnimationInProgress); + Assert.True(length(player.Velocity) > .01f, "the ship carries speed into the animation"); + var position = DrawAhead.Position(player, ClockFrame); + Assert.Equal(player.Position.x, position.x); + Assert.Equal(player.Position.y, position.y); + Assert.Equal(player.Position.z, position.z); + var rotation = DrawAhead.Rotation(player, ClockFrame); + Assert.Equal(player.Rotation.x, rotation.x); + Assert.Equal(player.Rotation.y, rotation.y); + Assert.Equal(player.Rotation.z, rotation.z); + Assert.Equal(player.Rotation.w, rotation.w); + } + // The turn a presenter draws ahead is the turn the next step makes: a ship held in a steady turn, drawn one whole // step ahead of the pose it was last given, is where the sim puts that pose next. [Fact]