From 0fec5ad37acbbc5c715d374772527aba54006bcb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 03:52:13 +0100 Subject: [PATCH] Move Ymir simulation to SoA CultCache state --- .voidbot/state/ymir.cc | Bin 2815 -> 2881 bytes README.md | 5 + docs/architecture.md | 17 ++- src/Ymir.Core/Ymir.Core.csproj | 2 + src/Ymir.Core/YmirSimulator.cs | 162 +++++++++------------- src/Ymir.Core/YmirSoAWorld.cs | 174 ++++++++++++++++++++++++ src/Ymir.Core/YmirWorldStateDocument.cs | 130 ++++++++++++++++++ src/ymir-daemon/Program.cs | 29 +++- tests/Ymir.Tests/YmirSimulatorTests.cs | 64 +++++++++ 9 files changed, 479 insertions(+), 104 deletions(-) create mode 100644 src/Ymir.Core/YmirSoAWorld.cs create mode 100644 src/Ymir.Core/YmirWorldStateDocument.cs diff --git a/.voidbot/state/ymir.cc b/.voidbot/state/ymir.cc index 8eaa940248ff0bdf15076db092a3b8b859256f58..b340c88e18b067c6755c145c6c7e867481de9d38 100644 GIT binary patch delta 877 zcmYjP!D>@M6vY^bwQd`sh};ioo@rwfS{I@bK|u>v5WA6^c{h1O-uWQkm)hSFf=-Y zJCAq3kkBT!;56*&TQDewLoyZyDE65)m_WZs-2-$9+(svoa6%OaxQ`+sD78srh_xs( zQqD#nMLI>9&Gx+p?)fCtl`cV-%0i;uSz*?pqZ{D4b0UkffQCTmENAfpnR?k{Nu`BU zmt?#y8fvCkM|m#QDEj0`S@t=5-`C3FR5&(fby@`T(lS$~DOiCi8pu(aQd(LAtHH5_ zfzcS}30k_Q&=_5YGxOLsqo7#xgd7kW#lQO}7Bhk|3CB}?&e-XInLiy$;+aV0Xk>^JlRtoC}xeqqc1wW+&b`*z5cxs$TB6HtQpVcb7SGGDL zX3TS*4`vD&$2=D>k`v-ZNH8b620pl)n@Q_J4)f?j1Z>LaVwAv$jS0>?ns6jhU_1+q zXxs+=#rJaw@=C?rL%{vrorlq9RD$>G5e@CzS07BZ(sK&p&d1Z52bA4Bl~UK$l@pkihtqh}~>nV?OM z9z6)dGIHSp&rHfREJ+w}-o8c|l*Xa7S}Xnyg7K_pw85uO?2X{~Wg zq0E_Jqmx8NOkW05%b(j5`*4crR(4TJ%KBUx&Z^%U$$@Dh@xQZcs8ldW^oUL2v4BuJ z&i=1j$gPYeCd#Tnj*T7l(FDaYrQ1Of$0hQ**+j zCu>A=y|%sV + + diff --git a/src/Ymir.Core/YmirSimulator.cs b/src/Ymir.Core/YmirSimulator.cs index 9dbbd79..df4c6a4 100644 --- a/src/Ymir.Core/YmirSimulator.cs +++ b/src/Ymir.Core/YmirSimulator.cs @@ -9,82 +9,67 @@ public sealed class YmirSimulator private const float MinimumDistance = 0.0001f; public SimulationStepResult Step(SimulationStepRequest request) + { + var result = Step(new SoASimulationStepRequest( + request.DeltaTime, + YmirSoAWorld.FromWorld(request.World))); + + return new SimulationStepResult(result.World.ToWorld(), result.Contacts); + } + + public SoASimulationStepResult Step(SoASimulationStepRequest request) { if (request.DeltaTime <= 0.0f) { throw new ArgumentOutOfRangeException(nameof(request), "DeltaTime must be positive."); } - var bodies = request.World.Bodies - .OrderBy(body => body.Id, StringComparer.Ordinal) - .Select(body => Integrate(body, request.World.Fields, request.DeltaTime)) - .ToList(); + var world = request.World.Clone(); + world.Validate(); + + var accelerationX = new float[world.BodyCount]; + var accelerationY = new float[world.BodyCount]; + for (var i = 0; i < world.FieldCount; i++) + { + BatchMath.AddRadialFalloffAcceleration2D( + world.PositionX, + world.PositionY, + world.FieldPositionX[i], + world.FieldPositionY[i], + world.FieldStrength[i], + world.FieldRadius[i], + accelerationX, + accelerationY); + } + + BatchMath.IntegrateSemiImplicitEuler2D( + request.DeltaTime, + world.DynamicMask, + world.PositionX, + world.PositionY, + world.VelocityX, + world.VelocityY, + accelerationX, + accelerationY); var contacts = new List(); - ResolveCircleContacts(bodies, contacts); + ResolveCircleContacts(world, contacts); - return new SimulationStepResult( - request.World with - { - Time = request.World.Time + request.DeltaTime, - Bodies = bodies - }, + world.Time += request.DeltaTime; + return new SoASimulationStepResult( + world, contacts); } - private static PhysicsBody Integrate(PhysicsBody body, IReadOnlyList fields, float deltaTime) + private static void ResolveCircleContacts(YmirSoAWorld world, List contacts) { - ValidateBody(body); - - if (body.IsStatic) + for (var i = 0; i < world.BodyCount; i++) { - return body; - } - - var position = body.Position.ToCultMath(); - var velocity = body.Velocity.ToCultMath(); - var acceleration = float2(0.0f); - - foreach (var field in fields.OrderBy(field => field.Id, StringComparer.Ordinal)) - { - if (field.Radius <= 0.0f || field.Strength == 0.0f) + for (var j = i + 1; j < world.BodyCount; j++) { - continue; - } - - var offset = field.Position.ToCultMath() - position; - var distance = max(length(offset), MinimumDistance); - if (distance > field.Radius) - { - continue; - } - - var direction = offset / distance; - var falloff = saturate(1.0f - distance / field.Radius); - acceleration += direction * field.Strength * falloff; - } - - velocity += acceleration * deltaTime; - position += velocity * deltaTime; - - return body with - { - Position = Vec2.FromCultMath(position), - Velocity = Vec2.FromCultMath(velocity) - }; - } - - private static void ResolveCircleContacts(List bodies, List contacts) - { - for (var i = 0; i < bodies.Count; i++) - { - for (var j = i + 1; j < bodies.Count; j++) - { - var left = bodies[i]; - var right = bodies[j]; - var delta = right.Position.ToCultMath() - left.Position.ToCultMath(); + var delta = world.PositionAt(j) - world.PositionAt(i); var distance = length(delta); - var combinedRadius = left.Radius + right.Radius; + var combinedRadius = world.Radius[i] + world.Radius[j]; var penetration = combinedRadius - distance; if (penetration <= 0.0f) @@ -93,34 +78,33 @@ public sealed class YmirSimulator } var normal = distance <= MinimumDistance ? float2(1.0f, 0.0f) : delta / distance; - var point = left.Position.ToCultMath() + normal * (left.Radius - penetration * 0.5f); - var relativeVelocity = right.Velocity.ToCultMath() - left.Velocity.ToCultMath(); + var point = world.PositionAt(i) + normal * (world.Radius[i] - penetration * 0.5f); + var relativeVelocity = world.VelocityAt(j) - world.VelocityAt(i); var relativeSpeed = dot(relativeVelocity, normal); contacts.Add(new ContactEvent( - left.Id, - right.Id, + world.BodyIds[i], + world.BodyIds[j], Vec2.FromCultMath(point), Vec2.FromCultMath(normal), penetration, relativeSpeed)); - ResolveImpulse(ref left, ref right, normal, penetration, relativeSpeed); - bodies[i] = left; - bodies[j] = right; + ResolveImpulse(world, i, j, normal, penetration, relativeSpeed); } } } private static void ResolveImpulse( - ref PhysicsBody left, - ref PhysicsBody right, + YmirSoAWorld world, + int left, + int right, float2 normal, float penetration, float relativeSpeed) { - var leftInvMass = left.IsStatic ? 0.0f : 1.0f / max(left.Mass, MinimumMass); - var rightInvMass = right.IsStatic ? 0.0f : 1.0f / max(right.Mass, MinimumMass); + var leftInvMass = world.DynamicMask[left] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[left], MinimumMass); + var rightInvMass = world.DynamicMask[right] <= 0.0f ? 0.0f : 1.0f / max(world.Mass[right], MinimumMass); var invMassSum = leftInvMass + rightInvMass; if (invMassSum <= 0.0f) { @@ -128,14 +112,14 @@ public sealed class YmirSimulator } var correction = normal * (penetration / invMassSum); - if (!left.IsStatic) + if (leftInvMass > 0.0f) { - left = left with { Position = Vec2.FromCultMath(left.Position.ToCultMath() - correction * leftInvMass) }; + world.SetPosition(left, world.PositionAt(left) - correction * leftInvMass); } - if (!right.IsStatic) + if (rightInvMass > 0.0f) { - right = right with { Position = Vec2.FromCultMath(right.Position.ToCultMath() + correction * rightInvMass) }; + world.SetPosition(right, world.PositionAt(right) + correction * rightInvMass); } if (relativeSpeed >= 0.0f) @@ -143,36 +127,18 @@ public sealed class YmirSimulator return; } - var restitution = min(left.Restitution, right.Restitution); + var restitution = min(world.Restitution[left], world.Restitution[right]); var impulseMagnitude = -(1.0f + restitution) * relativeSpeed / invMassSum; var impulse = normal * impulseMagnitude; - if (!left.IsStatic) + if (leftInvMass > 0.0f) { - left = left with { Velocity = Vec2.FromCultMath(left.Velocity.ToCultMath() - impulse * leftInvMass) }; + world.SetVelocity(left, world.VelocityAt(left) - impulse * leftInvMass); } - if (!right.IsStatic) + if (rightInvMass > 0.0f) { - right = right with { Velocity = Vec2.FromCultMath(right.Velocity.ToCultMath() + impulse * rightInvMass) }; - } - } - - private static void ValidateBody(PhysicsBody body) - { - if (string.IsNullOrWhiteSpace(body.Id)) - { - throw new ArgumentException("Physics bodies must have stable ids."); - } - - if (body.Radius <= 0.0f) - { - throw new ArgumentOutOfRangeException(nameof(body), "Body radius must be positive."); - } - - if (!body.IsStatic && body.Mass <= 0.0f) - { - throw new ArgumentOutOfRangeException(nameof(body), "Dynamic body mass must be positive."); + world.SetVelocity(right, world.VelocityAt(right) + impulse * rightInvMass); } } } diff --git a/src/Ymir.Core/YmirSoAWorld.cs b/src/Ymir.Core/YmirSoAWorld.cs new file mode 100644 index 0000000..c312dc9 --- /dev/null +++ b/src/Ymir.Core/YmirSoAWorld.cs @@ -0,0 +1,174 @@ +using CultMath; + +namespace Ymir.Core; + +public sealed class YmirSoAWorld +{ + public float Time { get; set; } + public string[] BodyIds { get; set; } = []; + public float[] PositionX { get; set; } = []; + public float[] PositionY { get; set; } = []; + public float[] VelocityX { get; set; } = []; + public float[] VelocityY { get; set; } = []; + public float[] Radius { get; set; } = []; + public float[] Mass { get; set; } = []; + public float[] DynamicMask { get; set; } = []; + public float[] Restitution { get; set; } = []; + public string[] FieldIds { get; set; } = []; + public float[] FieldPositionX { get; set; } = []; + public float[] FieldPositionY { get; set; } = []; + public float[] FieldStrength { get; set; } = []; + public float[] FieldRadius { get; set; } = []; + + public int BodyCount => BodyIds.Length; + + public int FieldCount => FieldIds.Length; + + public static YmirSoAWorld FromWorld(YmirWorld world) + { + var bodies = world.Bodies.OrderBy(body => body.Id, StringComparer.Ordinal).ToArray(); + var fields = world.Fields.OrderBy(field => field.Id, StringComparer.Ordinal).ToArray(); + + return new YmirSoAWorld + { + Time = world.Time, + BodyIds = bodies.Select(body => body.Id).ToArray(), + PositionX = bodies.Select(body => body.Position.X).ToArray(), + PositionY = bodies.Select(body => body.Position.Y).ToArray(), + VelocityX = bodies.Select(body => body.Velocity.X).ToArray(), + VelocityY = bodies.Select(body => body.Velocity.Y).ToArray(), + Radius = bodies.Select(body => body.Radius).ToArray(), + Mass = bodies.Select(body => body.Mass).ToArray(), + DynamicMask = bodies.Select(body => body.IsStatic ? 0.0f : 1.0f).ToArray(), + Restitution = bodies.Select(body => body.Restitution).ToArray(), + FieldIds = fields.Select(field => field.Id).ToArray(), + FieldPositionX = fields.Select(field => field.Position.X).ToArray(), + FieldPositionY = fields.Select(field => field.Position.Y).ToArray(), + FieldStrength = fields.Select(field => field.Strength).ToArray(), + FieldRadius = fields.Select(field => field.Radius).ToArray() + }; + } + + public YmirWorld ToWorld() => new( + Time, + Enumerable.Range(0, BodyCount) + .Select(i => new PhysicsBody( + BodyIds[i], + new Vec2(PositionX[i], PositionY[i]), + new Vec2(VelocityX[i], VelocityY[i]), + Radius[i], + Mass[i], + DynamicMask[i] <= 0.0f, + Restitution[i])) + .ToArray(), + Enumerable.Range(0, FieldCount) + .Select(i => new RadialField( + FieldIds[i], + new Vec2(FieldPositionX[i], FieldPositionY[i]), + FieldStrength[i], + FieldRadius[i])) + .ToArray()); + + public YmirSoAWorld Clone() => new() + { + Time = Time, + BodyIds = (string[])BodyIds.Clone(), + PositionX = (float[])PositionX.Clone(), + PositionY = (float[])PositionY.Clone(), + VelocityX = (float[])VelocityX.Clone(), + VelocityY = (float[])VelocityY.Clone(), + Radius = (float[])Radius.Clone(), + Mass = (float[])Mass.Clone(), + DynamicMask = (float[])DynamicMask.Clone(), + Restitution = (float[])Restitution.Clone(), + FieldIds = (string[])FieldIds.Clone(), + FieldPositionX = (float[])FieldPositionX.Clone(), + FieldPositionY = (float[])FieldPositionY.Clone(), + FieldStrength = (float[])FieldStrength.Clone(), + FieldRadius = (float[])FieldRadius.Clone() + }; + + public void Validate() + { + ValidateBodyLengths(); + ValidateFieldLengths(); + + for (var i = 0; i < BodyCount; i++) + { + if (string.IsNullOrWhiteSpace(BodyIds[i])) + { + throw new ArgumentException("Ymir bodies must have stable ids."); + } + + if (Radius[i] <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(Radius), "Body radius must be positive."); + } + + if (DynamicMask[i] > 0.0f && Mass[i] <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(Mass), "Dynamic body mass must be positive."); + } + } + } + + public float2 PositionAt(int index) => new(PositionX[index], PositionY[index]); + + public float2 VelocityAt(int index) => new(VelocityX[index], VelocityY[index]); + + public void SetPosition(int index, float2 value) + { + PositionX[index] = value.x; + PositionY[index] = value.y; + } + + public void SetVelocity(int index, float2 value) + { + VelocityX[index] = value.x; + VelocityY[index] = value.y; + } + + private void ValidateBodyLengths() + { + var expected = BodyIds.Length; + foreach (var length in new[] + { + PositionX.Length, + PositionY.Length, + VelocityX.Length, + VelocityY.Length, + Radius.Length, + Mass.Length, + DynamicMask.Length, + Restitution.Length + }) + { + if (length != expected) + { + throw new ArgumentException("Ymir body SoA arrays must have equal length."); + } + } + } + + private void ValidateFieldLengths() + { + var expected = FieldIds.Length; + foreach (var length in new[] + { + FieldPositionX.Length, + FieldPositionY.Length, + FieldStrength.Length, + FieldRadius.Length + }) + { + if (length != expected) + { + throw new ArgumentException("Ymir field SoA arrays must have equal length."); + } + } + } +} + +public sealed record SoASimulationStepRequest(float DeltaTime, YmirSoAWorld World); + +public sealed record SoASimulationStepResult(YmirSoAWorld World, IReadOnlyList Contacts); diff --git a/src/Ymir.Core/YmirWorldStateDocument.cs b/src/Ymir.Core/YmirWorldStateDocument.cs new file mode 100644 index 0000000..4a34c06 --- /dev/null +++ b/src/Ymir.Core/YmirWorldStateDocument.cs @@ -0,0 +1,130 @@ +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +namespace Ymir.Core; + +[CultDocument("gamecult.ymir.world_state", "gamecult.ymir.world_state.v0")] +[MessagePackObject] +public sealed class YmirWorldStateDocument +{ + [Key(0)] + [CultName] + public string WorldId { get; set; } = "default"; + + [Key(1)] + public float Time { get; set; } + + [Key(2)] + public string[] BodyIds { get; set; } = []; + + [Key(3)] + public float[] PositionX { get; set; } = []; + + [Key(4)] + public float[] PositionY { get; set; } = []; + + [Key(5)] + public float[] VelocityX { get; set; } = []; + + [Key(6)] + public float[] VelocityY { get; set; } = []; + + [Key(7)] + public float[] Radius { get; set; } = []; + + [Key(8)] + public float[] Mass { get; set; } = []; + + [Key(9)] + public float[] DynamicMask { get; set; } = []; + + [Key(10)] + public float[] Restitution { get; set; } = []; + + [Key(11)] + public string[] FieldIds { get; set; } = []; + + [Key(12)] + public float[] FieldPositionX { get; set; } = []; + + [Key(13)] + public float[] FieldPositionY { get; set; } = []; + + [Key(14)] + public float[] FieldStrength { get; set; } = []; + + [Key(15)] + public float[] FieldRadius { get; set; } = []; + + public static YmirWorldStateDocument FromSoA(string worldId, YmirSoAWorld world) => new() + { + WorldId = worldId, + Time = world.Time, + BodyIds = (string[])world.BodyIds.Clone(), + PositionX = (float[])world.PositionX.Clone(), + PositionY = (float[])world.PositionY.Clone(), + VelocityX = (float[])world.VelocityX.Clone(), + VelocityY = (float[])world.VelocityY.Clone(), + Radius = (float[])world.Radius.Clone(), + Mass = (float[])world.Mass.Clone(), + DynamicMask = (float[])world.DynamicMask.Clone(), + Restitution = (float[])world.Restitution.Clone(), + FieldIds = (string[])world.FieldIds.Clone(), + FieldPositionX = (float[])world.FieldPositionX.Clone(), + FieldPositionY = (float[])world.FieldPositionY.Clone(), + FieldStrength = (float[])world.FieldStrength.Clone(), + FieldRadius = (float[])world.FieldRadius.Clone() + }; + + public YmirSoAWorld ToSoA() => new() + { + Time = Time, + BodyIds = (string[])BodyIds.Clone(), + PositionX = (float[])PositionX.Clone(), + PositionY = (float[])PositionY.Clone(), + VelocityX = (float[])VelocityX.Clone(), + VelocityY = (float[])VelocityY.Clone(), + Radius = (float[])Radius.Clone(), + Mass = (float[])Mass.Clone(), + DynamicMask = (float[])DynamicMask.Clone(), + Restitution = (float[])Restitution.Clone(), + FieldIds = (string[])FieldIds.Clone(), + FieldPositionX = (float[])FieldPositionX.Clone(), + FieldPositionY = (float[])FieldPositionY.Clone(), + FieldStrength = (float[])FieldStrength.Clone(), + FieldRadius = (float[])FieldRadius.Clone() + }; +} + +public static class YmirCultCacheStore +{ + public static async Task SaveWorldAsync(string path, string worldId, YmirSoAWorld world) + { + var cache = await CultCacheMessagePack.OpenAsync( + path, + new CultCacheOpenOptions + { + UseDirectoryStore = true, + FlushOnDispose = true, + StoreFlushOnDispose = true + }).ConfigureAwait(false); + + using (cache) + { + await cache.UpsertAsync( + YmirWorldStateDocument.FromSoA(worldId, world), + new CultRecordHandle(new CultRecordKey($"ymir:world:{worldId}"))) + .ConfigureAwait(false); + await cache.FlushAsync().ConfigureAwait(false); + } + } + + public static async Task LoadWorldAsync(string path, string worldId) + { + using var cache = await CultCacheMessagePack.OpenAsync( + path, + new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false); + return cache.Get(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA(); + } +} diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs index 053512d..b4beb73 100644 --- a/src/ymir-daemon/Program.cs +++ b/src/ymir-daemon/Program.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; +using CultMath; using Ymir.Core; namespace Ymir.Daemon; @@ -27,6 +28,9 @@ internal static class Program case "step-sample": WriteJson(new YmirSimulator().Step(SampleRequest())); return 0; + case "save-sample": + await SaveSampleAsync(ValueAfter(args, "--path") ?? ".ymir/worlds.cc"); + return 0; case "step": return RunStep(args); case "serve": @@ -143,7 +147,8 @@ internal static class Program "body integration", "field acceleration", "contact generation", - "collision query semantics" + "collision query semantics", + "SoA CultCache world-state persistence" }, doesNotOwn = new[] { @@ -170,6 +175,8 @@ internal static class Program status = "mvp", stateOwner = "Ymir.Core step result; JSON is compatibility witness only", numericSubstrate = "CultMath", + batchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}", + persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v0", updatedAt = DateTimeOffset.UtcNow }; @@ -190,6 +197,8 @@ internal static class Program new { type = "stat", label = "Status", value = "mvp" }, new { type = "stat", label = "Owner", value = "physics simulation truth" }, new { type = "stat", label = "Math", value = "CultMath" }, + new { type = "stat", label = "Batch lanes", value = BatchMath.LaneCount.ToString() }, + new { type = "stat", label = "Persistence", value = "CultCache SoA world_state.v0" }, new { type = "route", label = "Step", value = "POST /simulate/step" } } } @@ -210,6 +219,23 @@ internal static class Program new RadialField("gravity-well", new Vec2(0.0f, 1.0f), 2.0f, 8.0f) })); + private static async Task SaveSampleAsync(string path) + { + var result = new YmirSimulator().Step(new SoASimulationStepRequest( + SampleRequest().DeltaTime, + YmirSoAWorld.FromWorld(SampleRequest().World))); + await YmirCultCacheStore.SaveWorldAsync(path, "sample", result.World); + WriteJson(new + { + ok = true, + path, + record = "ymir:world:sample", + schema = "gamecult.ymir.world_state.v0", + bodyCount = result.World.BodyCount, + fieldCount = result.World.FieldCount + }); + } + private static async Task WriteResponseAsync(HttpListenerContext context, object value) { var json = JsonSerializer.Serialize(value, JsonOptions); @@ -249,6 +275,7 @@ internal static class Program provider operator-state step-sample + save-sample [--path .ymir/worlds.cc] step --request serve [--port 8877] """); diff --git a/tests/Ymir.Tests/YmirSimulatorTests.cs b/tests/Ymir.Tests/YmirSimulatorTests.cs index 1e16272..cfb01fe 100644 --- a/tests/Ymir.Tests/YmirSimulatorTests.cs +++ b/tests/Ymir.Tests/YmirSimulatorTests.cs @@ -75,6 +75,70 @@ public sealed class YmirSimulatorTests Assert.Equal("zeta", contact.BodyB); } + [Fact] + public void SoAStepUsesBufferAsSimulationAuthority() + { + var simulator = new YmirSimulator(); + var world = new YmirSoAWorld + { + BodyIds = ["ship", "anchor"], + PositionX = [0.0f, 10.0f], + PositionY = [0.0f, 0.0f], + VelocityX = [1.0f, 0.0f], + VelocityY = [0.0f, 0.0f], + Radius = [0.5f, 0.5f], + Mass = [1.0f, 1.0f], + DynamicMask = [1.0f, 0.0f], + Restitution = [0.2f, 0.2f], + FieldIds = ["well"], + FieldPositionX = [10.0f], + FieldPositionY = [0.0f], + FieldStrength = [10.0f], + FieldRadius = [20.0f] + }; + + var result = simulator.Step(new SoASimulationStepRequest(1.0f, world)); + + Assert.True(result.World.PositionX[0] > 1.0f); + Assert.Equal(10.0f, result.World.PositionX[1], precision: 5); + Assert.Equal(1.0f, result.World.Time, precision: 5); + } + + [Fact] + public async Task CultCacheStoreRoundTripsSoAWorldState() + { + var path = Path.Combine(Path.GetTempPath(), $"ymir-tests-{Guid.NewGuid():N}.cc"); + var recordsPath = $"{path}.records"; + + try + { + var world = YmirSoAWorld.FromWorld(new YmirWorld( + 2.0f, + new[] { new PhysicsBody("ship", new Vec2(1.0f, 2.0f), new Vec2(3.0f, 4.0f), 0.5f, 1.0f) }, + new[] { new RadialField("well", new Vec2(5.0f, 6.0f), 7.0f, 8.0f) })); + + await YmirCultCacheStore.SaveWorldAsync(path, "test", world); + var loaded = await YmirCultCacheStore.LoadWorldAsync(path, "test"); + + Assert.NotNull(loaded); + Assert.Equal(["ship"], loaded!.BodyIds); + Assert.Equal(1.0f, loaded.PositionX[0], precision: 5); + Assert.Equal(7.0f, loaded.FieldStrength[0], precision: 5); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + + if (Directory.Exists(recordsPath)) + { + Directory.Delete(recordsPath, recursive: true); + } + } + } + private static SimulationStepResult Step(params PhysicsBody[] bodies) { var simulator = new YmirSimulator();