Move Ymir simulation to SoA CultCache state

This commit is contained in:
Meta Vens 2026-06-17 03:52:13 +01:00
commit 0fec5ad37a
9 changed files with 476 additions and 101 deletions

Binary file not shown.

View file

@ -36,6 +36,11 @@ The MVP is deliberately small:
- radial fields for Aetheria-style gravity wells
- semi-implicit Euler integration
- circle collision detection and simple impulse response
- SoA world buffers as the simulation authority
- CultCache MessagePack persistence for `gamecult.ymir.world_state.v0`, using
the directory store to avoid rewriting cold records
- CultMath `BatchMath` SIMD kernels for radial field acceleration and
semi-implicit Euler integration
- deterministic contact events
- CLI smoke commands
- local HTTP daemon with `/simulate/step`

View file

@ -10,11 +10,16 @@ and future Verse surfaces.
The MVP has one pure core and one daemon:
- `Ymir.Core` receives a `YmirWorld` and a fixed `deltaTime`.
- The core computes radial field acceleration, integrates dynamic bodies, tests
circle overlaps, resolves simple collision response, and emits contact events.
- `Ymir.Core` receives a `YmirSoAWorld` and a fixed `deltaTime`.
- Compatibility callers may still submit a `YmirWorld`, but it is immediately
lowered into SoA buffers before simulation.
- The core computes radial field acceleration with CultMath `BatchMath`, uses
vectorized semi-implicit Euler integration over SoA arrays, tests circle
overlaps, resolves simple collision response, and emits contact events.
- `ymir-daemon` publishes provider/operator state and exposes the core through a
small HTTP compatibility surface.
- `YmirWorldStateDocument` persists the same SoA world as
`gamecult.ymir.world_state.v0` through CultCache MessagePack.
## Owner
@ -23,8 +28,9 @@ physics state and which contacts occurred.
## Inputs
- body id, position, velocity, radius, mass, restitution, static flag
- radial field id, position, strength, radius
- SoA body columns: id, position x/y, velocity x/y, radius, mass, dynamic mask,
restitution
- SoA radial field columns: id, position x/y, strength, radius
- fixed `deltaTime`
## Outputs
@ -35,6 +41,7 @@ physics state and which contacts occurred.
## Derived State
- `YmirWorld` DTO records are compatibility views over `YmirSoAWorld`.
- HTTP health, provider advertisement, operator state, and Eve surface are
derived compatibility views.
- Unity GameObjects are derived render/adapter state.

View file

@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
</ItemGroup>

View file

@ -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<ContactEvent>();
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<RadialField> fields, float deltaTime)
private static void ResolveCircleContacts(YmirSoAWorld world, List<ContactEvent> 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<PhysicsBody> bodies, List<ContactEvent> 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);
}
}
}

View file

@ -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<ContactEvent> Contacts);

View file

@ -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<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}")))
.ConfigureAwait(false);
await cache.FlushAsync().ConfigureAwait(false);
}
}
public static async Task<YmirSoAWorld?> LoadWorldAsync(string path, string worldId)
{
using var cache = await CultCacheMessagePack.OpenAsync(
path,
new CultCacheOpenOptions { UseDirectoryStore = true }).ConfigureAwait(false);
return cache.Get<YmirWorldStateDocument>(new CultRecordKey($"ymir:world:{worldId}"))?.ToSoA();
}
}

View file

@ -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 <path>
serve [--port 8877]
""");

View file

@ -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();