From 9116946874b23d69fb7b445b9e7dc96484fdda33 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 14:52:10 +0200 Subject: [PATCH 1/8] SimulationDice: fmix32 moves out of FireControl, with the structured per-zone dice stream Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 20 ++--------- Assets/Scripts/ServerShared/SimulationDice.cs | 35 +++++++++++++++++++ .../ServerShared/SimulationDice.cs.meta | 11 ++++++ 3 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 Assets/Scripts/ServerShared/SimulationDice.cs create mode 100644 Assets/Scripts/ServerShared/SimulationDice.cs.meta diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 04e960d33..80d8d4b76 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -667,22 +667,6 @@ public static class FireControl // so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is // gone with the stream it was about, and the test that asserted it became vacuous the same moment; what // replaced it pins the live rule instead (combat touches no shared stream, on any path). - // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, - // xor-shift, multiply, xor-shift. Diffuses a structured seed (here, a zone seed XORed with a small, - // low-bits-only ShotId) across every bit before the first xorshift draw reads it, so two seeds that - // differ only in ShotId's low bits still land on uncorrelated first outputs. Caller-side mixing only - // -- CultMath.Random itself is unchanged (see Commit's own note on why that follow-up is a separate - // CultLib cut). - private static uint MixSeed(uint seed) - { - seed ^= seed >> 16; - seed *= 0x85ebca6bu; - seed ^= seed >> 13; - seed *= 0xc2b2ae35u; - seed ^= seed >> 16; - return seed; - } - // Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is // frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing // (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors: @@ -722,11 +706,11 @@ public static class FireControl // round for NextFloat's first call, and one round does not diffuse a seed that differs from its // neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant // function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone - // creation, not a roll (Soul C1). MixSeed is a standard fmix32 finalizer (MurmurHash3): this is + // creation, not a roll (Soul C1). SimulationDice.Mix is a standard fmix32 finalizer (MurmurHash3): this is // a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and // drawing once is the documented way to get a correlated first output, and a follow-up to give // CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut. - var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); + var random = new Random(SimulationDice.Mix((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); var p = CommitProbability(shot, now, out var sil); var hit = p > 0f && random.NextFloat() < p; diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs new file mode 100644 index 000000000..94d8f8946 --- /dev/null +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -0,0 +1,35 @@ +/* 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 Random = CultMath.Random; + +// Structured simulation seeds: the one place a zone seed, a stream and an ordinal become a generator. Every +// draw is a pure function of its arguments, never of a shared stream, so one roll cannot perturb another. +public static class SimulationDice +{ + public const uint LootStream = 1; + public const uint MineStream = 2; + + // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, + // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, + // low-bits-only id) across every bit before the first xorshift draw reads it, so two seeds that differ + // only in their low bits still land on uncorrelated first outputs. Caller-side mixing only -- + // CultMath.Random itself is unchanged. FireControl.Commit's per-shot seed is built on this verbatim. + public static uint Mix(uint seed) + { + seed ^= seed >> 16; + seed *= 0x85ebca6bu; + seed ^= seed >> 13; + seed *= 0xc2b2ae35u; + seed ^= seed >> 16; + return seed; + } + + // The generator for the `ordinal`th roll of `stream` in the zone identified by `zoneSeed`. + public static Random For(uint zoneSeed, uint stream, uint ordinal) + { + var seed = Mix(Mix(zoneSeed ^ Mix(stream)) ^ ordinal); + return new Random(seed == 0 ? 1u : seed); + } +} diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs.meta b/Assets/Scripts/ServerShared/SimulationDice.cs.meta new file mode 100644 index 000000000..c32cf0aba --- /dev/null +++ b/Assets/Scripts/ServerShared/SimulationDice.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da428be21afa46a991b55ab9b794d192 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 38c47d80edfcbcd8328e3bd2c68bdeb27dc34ad7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 14:52:12 +0200 Subject: [PATCH 2/8] Floating bodies: FloatingBodyId, KinematicBody.Step, Zone.NextBodyId and the three drift settings Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FloatingBodies.cs | 42 +++++ .../ServerShared/FloatingBodies.cs.meta | 11 ++ Assets/Scripts/ServerShared/Settings.cs | 5 + Assets/Scripts/ServerShared/Zone.cs | 4 + .../FloatingBodyTests.cs | 143 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 Assets/Scripts/ServerShared/FloatingBodies.cs create mode 100644 Assets/Scripts/ServerShared/FloatingBodies.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/FloatingBodyTests.cs diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs b/Assets/Scripts/ServerShared/FloatingBodies.cs new file mode 100644 index 000000000..a5d26ae20 --- /dev/null +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs @@ -0,0 +1,42 @@ +/* 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 CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// A free-floating body in a zone: a floating item or a mine. Zone-scoped and never reused within a zone; +// runtime state only, never saved. +public readonly struct FloatingBodyId : IEquatable +{ + public readonly uint Value; + public FloatingBodyId(uint value) => Value = value; + public bool Equals(FloatingBodyId other) => Value == other.Value; + public override bool Equals(object obj) => obj is FloatingBodyId other && Equals(other); + public override int GetHashCode() => (int) Value; + public override string ToString() => Value.ToString(); +} + +// The one drift integrator of every free body in a zone. A launch velocity decays on its own drag; the zone's +// force accumulates into a separate drift velocity that has its own drag, so a body settles to +// force * gravity / drag. Engine-free: the caller supplies dt and the zone force at the body's position. +public struct KinematicBody +{ + public float2 Position; + // The launch velocity, decaying. + public float2 Velocity; + public float2 Drift; + + // What a presenter draws ahead with. + public float2 TotalVelocity => Velocity + Drift; + + public void Step(float dt, float2 force, GameplaySettings s) + { + Velocity *= exp(-s.FloatingBodyLaunchDrag * dt); + Drift += force * s.FloatingBodyGravity * dt; + Drift *= exp(-s.FloatingBodyDrag * dt); + Position += (Velocity + Drift) * dt; + } +} diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs.meta b/Assets/Scripts/ServerShared/FloatingBodies.cs.meta new file mode 100644 index 000000000..b71bf1d0d --- /dev/null +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 274c5573e2634fb58c52dbbf0406ce79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index cc7de7652..9c738b287 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -173,6 +173,11 @@ public class GameplaySettings public float ThermalQualityMin = 2; public float ThermalQualityMax = .25f; public float DefaultShutdownPerformance = .25f; + // Free-floating bodies (floating items, mines): drag on a launch velocity, drag on the force-driven drift, and + // how strongly the zone force accelerates it. + public float FloatingBodyDrag = .05f; + public float FloatingBodyLaunchDrag = .2f; + public float FloatingBodyGravity = 1f; public float SevereHeatstrokeRiskThreshold = .25f; public float WormholeDepth = 1000; public float WormholeExitVelocity = 20; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d3973d92e..84318ab4b 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -81,6 +81,10 @@ public class Zone // of touching a shared stream. public uint CombatSeed { get; } + // The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved. + private uint _lastBodyId; + public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); + public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { _time = pack.Time; diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs new file mode 100644 index 000000000..d35f02a2f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using Random = CultMath.Random; + +// Floating bodies (aetheria-release cut floating-bodies): the drift loot and mines share, the structured +// dice they roll with, and the zone-scoped ids they carry. The body has no consumer yet; these tests are +// its whole contract. +public sealed class FloatingBodyTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-floatingbody-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public FloatingBodyTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings Settings(float drag = .05f, float launchDrag = .2f, float gravity = 1f) => new GameplaySettings + { + FloatingBodyDrag = drag, FloatingBodyLaunchDrag = launchDrag, FloatingBodyGravity = gravity + }; + + private Zone NewZone() + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + return new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + private static KinematicBody Run(KinematicBody b, float seconds, float dt, float2 force, GameplaySettings s) + { + var steps = (int) Math.Round(seconds / dt); + for (var i = 0; i < steps; i++) b.Step(dt, force, s); + return b; + } + + [Fact] + public void LaunchVelocityDecaysExponentially() + { + var s = Settings(); + var start = new KinematicBody { Velocity = new float2(.3f, .4f) }; + foreach (var dt in new[] { 1f / 120f, .1f }) + { + var b = Run(start, 3f, dt, float2.zero, s); + Assert.Equal(.5f * exp(-s.FloatingBodyLaunchDrag * 3f), length(b.Velocity), 4); + } + + var fine = Run(start, 3f, 1f / 120f, float2.zero, s); + var coarse = Run(start, 3f, .1f, float2.zero, s); + Assert.True(length(fine.Position - coarse.Position) < 1e-2f); + // It really moved, along the launch direction. + Assert.True(fine.Position.x > 0f && fine.Position.y > fine.Position.x); + } + + [Fact] + public void DriftFollowsTheForce() + { + var s = Settings(); + var force = new float2(.02f, -.01f); + var b = Run(new KinematicBody(), 600f, .1f, force, s); + Assert.Equal(force.x * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.x, 2); + Assert.Equal(force.y * s.FloatingBodyGravity / s.FloatingBodyDrag, b.Drift.y, 2); + Assert.Equal(b.Velocity + b.Drift, b.TotalVelocity); + + var noGravity = Run(new KinematicBody(), 10f, .1f, force, Settings(gravity: 0f)); + Assert.Equal(float2.zero, noGravity.Drift); + Assert.Equal(float2.zero, noGravity.Position); + + var noForce = Run(new KinematicBody { Velocity = new float2(1f, 0f) }, 2f, .1f, float2.zero, s); + Assert.Equal(float2.zero, noForce.Drift); + Assert.True(noForce.Position.x > 0f && noForce.Position.y == 0f); + } + + [Fact] + public void ShotDiceAreUnchanged() + { + // fmix32 as FireControl.MixSeed wrote it, the function this cut moved. + static uint Reference(uint seed) + { + seed ^= seed >> 16; seed *= 0x85ebca6bu; seed ^= seed >> 13; seed *= 0xc2b2ae35u; seed ^= seed >> 16; + return seed; + } + var inputs = new List { 0u, 0xFFFFFFFFu }; + for (uint i = 1; inputs.Count < 64; i++) inputs.Add(i * 2654435761u); + foreach (var x in inputs) Assert.Equal(Reference(x), SimulationDice.Mix(x)); + Assert.Equal(0u, SimulationDice.Mix(0u)); + Assert.Equal(0x514E28B7u, SimulationDice.Mix(1u)); + } + + [Fact] + public void DiceStreamsAreIndependentAndRepeatable() + { + var loot = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + var mine = SimulationDice.For(1234u, SimulationDice.MineStream, 5u); + Assert.NotEqual(loot.NextUInt(), mine.NextUInt()); + + var a = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + var b = SimulationDice.For(1234u, SimulationDice.LootStream, 5u); + for (var i = 0; i < 8; i++) Assert.Equal(a.NextUInt(), b.NextUInt()); + + Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1235u, SimulationDice.LootStream, 5u).NextUInt()); + Assert.NotEqual(SimulationDice.For(1234u, SimulationDice.LootStream, 5u).NextUInt(), SimulationDice.For(1234u, SimulationDice.LootStream, 6u).NextUInt()); + + // Never seeded 0: the argument set that mixes to zero still rolls. + var zero = SimulationDice.For(0u, 0u, 0u); + Assert.NotEqual(0u, zero.state); + } + + [Fact] + public void BodyIdsAreUniqueInAZone() + { + var zone = NewZone(); + var seen = new HashSet(); + uint last = 0; + for (var i = 0; i < 10000; i++) + { + var id = zone.NextBodyId(); + Assert.True(id.Value > last); + last = id.Value; + Assert.True(seen.Add(id)); + } + Assert.Equal(1u, NewZone().NextBodyId().Value); + Assert.Equal("1", new FloatingBodyId(1).ToString()); + Assert.Equal(new FloatingBodyId(7), new FloatingBodyId(7)); + Assert.NotEqual(new FloatingBodyId(7), new FloatingBodyId(8)); + } +} From 83bf1af106db2b85e982ad6f310615a4b47d98eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 15:03:12 +0200 Subject: [PATCH 3/8] FloatingBodyTests: launch-speed pin sized to the step's position error bound Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/FloatingBodyTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index d35f02a2f..75b3b077e 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -54,11 +54,12 @@ public sealed class FloatingBodyTests : IDisposable public void LaunchVelocityDecaysExponentially() { var s = Settings(); - var start = new KinematicBody { Velocity = new float2(.3f, .4f) }; + // Speed .3: the semi-implicit step's position error is bounded by speed * dt / 2 (about .005 at dt .1), inside the 1e-2 pin. + var start = new KinematicBody { Velocity = new float2(.18f, .24f) }; foreach (var dt in new[] { 1f / 120f, .1f }) { var b = Run(start, 3f, dt, float2.zero, s); - Assert.Equal(.5f * exp(-s.FloatingBodyLaunchDrag * 3f), length(b.Velocity), 4); + Assert.Equal(.3f * exp(-s.FloatingBodyLaunchDrag * 3f), length(b.Velocity), 4); } var fine = Run(start, 3f, 1f / 120f, float2.zero, s); From 5919c319485fc1caf7d1b6b0a4343bc4918d94f4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 15:49:14 +0200 Subject: [PATCH 4/8] FloatingBodyTests: pin one step's position and the zero-seed remap, the two Stryker survivors Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/FloatingBodyTests.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 75b3b077e..a3915edef 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -88,6 +88,21 @@ public sealed class FloatingBodyTests : IDisposable Assert.True(noForce.Position.x > 0f && noForce.Position.y == 0f); } + [Fact] + public void OneStepMovesByLaunchPlusDrift() + { + var s = Settings(); + var b = new KinematicBody { Velocity = new float2(2f, 0f), Drift = new float2(0f, 1f) }; + b.Step(.5f, new float2(.4f, 0f), s); + var velocity = 2f * exp(-s.FloatingBodyLaunchDrag * .5f); + var drift = new float2(.4f * .5f, 1f) * exp(-s.FloatingBodyDrag * .5f); + Assert.Equal(velocity, b.Velocity.x, 4); + Assert.Equal(drift.x, b.Drift.x, 4); + Assert.Equal(drift.y, b.Drift.y, 4); + Assert.Equal((velocity + drift.x) * .5f, b.Position.x, 4); + Assert.Equal(drift.y * .5f, b.Position.y, 4); + } + [Fact] public void ShotDiceAreUnchanged() { @@ -120,7 +135,7 @@ public sealed class FloatingBodyTests : IDisposable // Never seeded 0: the argument set that mixes to zero still rolls. var zero = SimulationDice.For(0u, 0u, 0u); - Assert.NotEqual(0u, zero.state); + Assert.Equal(1u, zero.state); } [Fact] From d62d44cb9930327f180856e049a6ce969af73daa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:32:53 +0200 Subject: [PATCH 5/8] One gravity law in Zone: ships and floating bodies read Zone.GravityAcceleration The law ships felt (normalize(f) * strength * (1/(1-|f|^2) - 1), gated at |f|^2 .001) is now Zone.GravityAcceleration, per second via a 60 Hz reference rate; Ship.Update applies it times delta, so ships push as before at 60 fps. Zone.GetForce's separate law is gone and delegates to it. Floating-body drift at 60 fps now matches the legacy GridObject magnitude. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Ship.cs | 7 +-- Assets/Scripts/ServerShared/Zone.cs | 17 ++++++- .../FloatingBodyTests.cs | 46 +++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index d10f097ca..b257f1cc9 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -299,12 +299,7 @@ public class Ship : Entity var normal = Zone.GetNormal(Position.xz); var force = new float2(normal.x, normal.z); - var forceMagnitude = lengthsq(force); - if (forceMagnitude > .001f) - { - var fa = 1 / (1 - forceMagnitude) - 1; - Velocity += normalize(force) * Zone.Settings.GravityStrength * fa; - } + Velocity += Zone.GravityAcceleration(force, Zone.Settings.GravityStrength) * delta; var shipRight = Direction.Rotate(ItemRotation.Clockwise); var forward = cross(float3(shipRight.x, 0, shipRight.y), normal); Rotation = quaternion.LookRotation(forward, normal); diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 84318ab4b..2e2fe6290 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -439,11 +439,24 @@ public class Zone internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize; internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f; + // The one law of how a gravity well pushes anything: ships, floating items and mines all read it. The shape is + // the one ships have always felt, normalize(f) * strength * (1 / (1 - |f|^2) - 1), gated off below |f|^2 = .001. + // It is an acceleration, per second: the legacy law added that amount once per 60 fps frame with no dt, so + // GravityReferenceRate (60) restates it and `acceleration * dt` gives the legacy push at 60 fps. + public const float GravityReferenceRate = 60f; + + public static float2 GravityAcceleration(float2 normalXz, float strength) + { + var magnitude = lengthsq(normalXz); + if (magnitude <= .001f) return float2.zero; + return normalize(normalXz) * strength * (1 / (1 - magnitude) - 1) * GravityReferenceRate; + } + + // The acceleration the well exerts at `position`. public float2 GetForce(float2 position) { var normal = GetNormal(position); - var f = new float2(normal.x, normal.z); - return f * Settings.GravityStrength * lengthsq(f); + return GravityAcceleration(new float2(normal.x, normal.z), Settings.GravityStrength); } public static float PowerPulse(float x, float exponent) diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index a3915edef..275f5e43c 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -156,4 +156,50 @@ public sealed class FloatingBodyTests : IDisposable Assert.Equal(new FloatingBodyId(7), new FloatingBodyId(7)); Assert.NotEqual(new FloatingBodyId(7), new FloatingBodyId(8)); } + + // The law ships felt at 3035230b, written out here from that tree's Ship.Update (a per-tick velocity add, no dt) + // and not from Zone: the pin must not move when Zone's law does. + private static float2 LegacyPushPerFrame(float2 f, float strength) + { + var m = lengthsq(f); + if (m <= .001f) return float2.zero; + return normalize(f) * strength * (1 / (1 - m) - 1); + } + + [Fact] + public void TheGravityLawPushesShipsAsItDidAtSixtyFps() + { + foreach (var strength in new[] { 1f, 7.5f }) + foreach (var f in new[] { new float2(.1f, 0), new float2(.2f, .1f), new float2(-.3f, .4f), new float2(.5f, -.5f), new float2(0, .7f) }) + { + var legacy = LegacyPushPerFrame(f, strength); + var perFrame = Zone.GravityAcceleration(f, strength) * (1f / 60f); + var tolerance = Math.Max(1e-7f, length(legacy) * 1e-5f); + Assert.InRange(perFrame.x, legacy.x - tolerance, legacy.x + tolerance); + Assert.InRange(perFrame.y, legacy.y - tolerance, legacy.y + tolerance); + } + // Per second: halving dt halves the push, so the law holds at any tick rate. + var a = Zone.GravityAcceleration(new float2(.3f, .1f), 2f); + Assert.Equal(a.x * .5f, (a * (1f / 120f)).x * 60f, 5); + // The gate: |f|^2 at or below .001 pushes nothing. + Assert.Equal(float2.zero, Zone.GravityAcceleration(new float2(.03f, 0), 1f)); + Assert.NotEqual(float2.zero, Zone.GravityAcceleration(new float2(.0317f, 0), 1f)); + } + + [Fact] + public void DriftMatchesLegacyFloatingItemsAtSixtyFps() + { + // After 5 s at 60 fps with GravityStrength 1 and force magnitude |f|^2 = m, the legacy GridObject drift + // position (measured by Soul, 2026-10-07, off 3035230b's GridObject.Update): .05 -> 1.7371, .1 -> 7.0012, + // .3 -> 68.55, .5 -> 231.04. + var s = Settings(); + foreach (var (m, expected) in new[] { (.05f, 1.7371f), (.1f, 7.0012f), (.3f, 68.55f), (.5f, 231.04f) }) + { + var f = new float2(sqrt(m), 0); + var body = Run(new KinematicBody(), 5f, 1f / 60f, Zone.GravityAcceleration(f, 1f), s); + Assert.InRange(body.Position.x, expected * .999f, expected * 1.001f); + Assert.Equal(0f, body.Position.y, 4); + } + } + } From 6c15a33315b6482ca6d2b0e4838e0a9ac78cc50f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:32:55 +0200 Subject: [PATCH 6/8] Tests: pin For's double mix against the single-xor collapse and Commit's | 1u seed bit Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/FireControlCut9Tests.cs | 14 ++++++++++++++ tests/Aetheria.Shared.Tests/FloatingBodyTests.cs | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2ebeb72f1..811fc3a47 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -247,6 +247,20 @@ public sealed class FireControlCut9Tests : IDisposable } } + // Commit's seed is ((CombatSeed * K) ^ ShotId) | 1 before the fmix32: the `| 1` is part of the shot dice that + // 9.1 shipped, so two shot ids that differ only in bit 0 share a seed and so a draw. Mutation: drop the + // `| 1` -- the pairs below then draw different rolls. + [Fact] + public void ShotIdsDifferingOnlyInTheLowBitShareTheirDraw() + { + var e = BuildDieEngagement(TestSettings(), "Cut9-LowBit"); + var four = BisectRoll(e, 4); + Assert.Equal(four, BisectRoll(e, 5), 5); + var six = BisectRoll(e, 6); + Assert.Equal(six, BisectRoll(e, 7), 5); + Assert.NotEqual(four, six, 3); + } + // --------------------------------------------------------------------------------------------------- // 9.2: pOnHull must stay monotonic in Precision over the whole domain. A neutral shooter/target pair // (Accuracy 1, huge Resolution/Tracking, zero weapon Spread), the same isolation convention diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 275f5e43c..3e0780dd3 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -138,6 +138,17 @@ public sealed class FloatingBodyTests : IDisposable Assert.Equal(1u, zero.state); } + [Fact] + public void NeighbouringZonesAndOrdinalsNeverShareADraw() + { + // Folding stream and ordinal into the zone seed with one xor would make (zone, n) and (zone ^ 1, n ^ 1) + // the same seed; the double mix keeps them apart. + for (uint zone = 0; zone < 64; zone++) + for (uint n = 0; n < 16; n++) + Assert.NotEqual(SimulationDice.For(zone, SimulationDice.LootStream, n).NextUInt(), + SimulationDice.For(zone ^ 1u, SimulationDice.LootStream, n ^ 1u).NextUInt()); + } + [Fact] public void BodyIdsAreUniqueInAZone() { From 4dea45bb19fc2665b69d021e1b4f89250d792bac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:43:08 +0200 Subject: [PATCH 7/8] FloatingBodyTests: the legacy drift table is keyed by the normal's length, not its square Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/FloatingBodyTests.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 3e0780dd3..52d330450 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -200,14 +200,13 @@ public sealed class FloatingBodyTests : IDisposable [Fact] public void DriftMatchesLegacyFloatingItemsAtSixtyFps() { - // After 5 s at 60 fps with GravityStrength 1 and force magnitude |f|^2 = m, the legacy GridObject drift - // position (measured by Soul, 2026-10-07, off 3035230b's GridObject.Update): .05 -> 1.7371, .1 -> 7.0012, - // .3 -> 68.55, .5 -> 231.04. + // After 5 s at 60 fps with GravityStrength 1 and a normal of length |f| along x, the legacy GridObject drift + // position (measured by Soul, 2026-10-07, off 3035230b's GridObject.Update): |f| .05 -> 1.7371, .1 -> 7.0012, + // .3 -> 68.55, .5 -> 231.04. The sim steps the same law with exp decay where GridObject used 1 - k dt. var s = Settings(); - foreach (var (m, expected) in new[] { (.05f, 1.7371f), (.1f, 7.0012f), (.3f, 68.55f), (.5f, 231.04f) }) + foreach (var (norm, expected) in new[] { (.05f, 1.7371f), (.1f, 7.0012f), (.3f, 68.55f), (.5f, 231.04f) }) { - var f = new float2(sqrt(m), 0); - var body = Run(new KinematicBody(), 5f, 1f / 60f, Zone.GravityAcceleration(f, 1f), s); + var body = Run(new KinematicBody(), 5f, 1f / 60f, Zone.GravityAcceleration(new float2(norm, 0), 1f), s); Assert.InRange(body.Position.x, expected * .999f, expected * 1.001f); Assert.Equal(0f, body.Position.y, 4); } From 6299627b72add55e20e83e4728fe25f2843d8300 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 18:49:20 +0200 Subject: [PATCH 8/8] FloatingBodyTests: pin Ship.Update's gravity line through a real ship in a real well Ship at +x in an empty zone with a slope: velocity after Update(delta) equals the 3035230b ship law scaled by delta * 60, at 1/60 and 1/30. Fails on `-=` and on a changed delta factor. Co-Authored-By: Claude Sonnet 5.5 --- .../FloatingBodyTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 52d330450..173a86678 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -212,4 +212,56 @@ public sealed class FloatingBodyTests : IDisposable } } + // Ship.Update's own gravity line, through the real ship and a real zone (no planets: the zone's well alone + // gives a slope). The expectation is built from the zone's own normal and the 3035230b ship law, so flipping + // the sign or changing how delta enters the line fails here, not only the Zone function's pin above. + private Ship ShipAt(float x, out Zone zone) + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); + cache.FlushAsync().Wait(); + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var planets = new PlanetSettings { ZoneDepth = 1000, ZoneDepthExponent = 1, GravityStrength = 1 }; + zone = new Zone(items, planets, new ZonePack { Radius = 1000 }, new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var design = cache.GetByName("Skiff"); + var hull = new EquippableItem + { + Data = cache.RefOf(design), Durability = design.Durability, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + var ship = new Ship(items, zone, hull, new EntitySettings()); + zone.Entities.Add(ship); + ship.LookDirection = float3(0, 0, 1); + ship.Activate(); + ship.Position = float3(x, 0, 0); + return ship; + } + + [Fact] + public void AShipInAWellIsPulledTowardItByTheLawPerSecond() + { + foreach (var delta in new[] { 1f / 60f, 1f / 30f }) + { + var ship = ShipAt(400f, out var zone); + var normal = zone.GetNormal(ship.Position.xz); + var push = LegacyPushPerFrame(new float2(normal.x, normal.z), zone.Settings.GravityStrength) * (delta * 60f); + Assert.True(push.x < 0f, "the well must pull a ship at +x toward the centre"); + + ship.Update(delta); + + var tolerance = Math.Max(1e-6f, length(push) * 1e-4f); + Assert.InRange(ship.Velocity.x, push.x - tolerance, push.x + tolerance); + Assert.InRange(ship.Velocity.y, push.y - tolerance, push.y + tolerance); + } + } + }