Cut 1: belts are evaluated when asked, not simulated every tick

Zone.Update no longer threads a per-belt Task.Run over a double-buffered
Transforms/OrbitPosition pair. AsteroidBelt.Pose/Size are the one place a
chunk pose is computed, a pure function of zone time; Zone.ChunkPose and
Zone.EvaluateBelt read them at the zone's own time. MiningTool,
ResourceScanner and ZoneRenderer (belt matrices and AsteroidBeltUI) all read
through Zone instead of the deleted per-tick buffers; the renderer owns its
own float4[] buffer, filled fresh every frame.

Deleted: System.Threading/.Tasks usings, BeltUpdates, the wait loop, the
per-belt copy+Task.Run, NearestAsteroid (dead/broken reader), the private
UpdateAsteroidTransforms, and AsteroidBelt's four pose fields plus their
allocations. ~65 lines, matching the cut map's estimate.

miningprobe, densest zone of the operator's save (EAC-2265, 809 asteroids,
8 belts), rebuilt against this cut and rerun 10x each side:

before (branch point, 8bd25f6f/e729e6d6):
  Zone.Update wall 541-3654 us/tick, CPU 120-391 us/tick (7 clean runs)
  3/10 runs crashed: InvalidOperationException from Zone.GetOrbitPosition,
  the documented _updatedOrbits/Orbits data race between the main thread's
  Clear() and a belt Task.Run

after (this commit):
  Zone.Update wall 95-185 us/tick, CPU 99-152 us/tick, in line with the
  31-39 us "no belt work" baseline the map recorded -- belts are free per
  tick
  0/10 races
  ChunkPose has no buffer to go stale: ~0.12 u of parent+asteroid motion is
  visible across one 17 ms tick instead of being one tick behind

tests/Aetheria.Shared.Tests/MiningCut1Tests.cs (new, 260/260 suite green):
  ChunkPoseIsTheOrbitAtZoneTime, ChunkPoseDoesNotDependOnUpdateCadence,
  DamagedChunkShrinksAndABrokenOneHasNoSize -- each expected value is the
  closed-form orbit/size rule reimplemented in the test, never a call to
  AsteroidBelt.Pose/Size.

Negative grep (Zone.cs, "Zone Display", ServerShared/Behaviors) for
Task\.Run|System\.Threading|NewTransforms|\.Transforms\b|BeltUpdates|
NearestAsteroid|\.OrbitPosition\b|NewOrbitPosition: one hit, the commented
line in MineAsteroid that Cut 2 deletes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-25 01:27:16 +02:00
commit 22a54ccbaf
5 changed files with 266 additions and 77 deletions

View file

@ -56,10 +56,11 @@ public class MiningTool : Behavior
public override bool Execute(float dt)
{
Range = Evaluate(_data.Range);
var belt = Entity.Zone.AsteroidBelts[AsteroidBelt];
if (AsteroidBelt.IsSet() &&
Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid) &&
length(Entity.Position.xz - belt.Transforms[Asteroid].xy) - belt.Transforms[Asteroid].w < Range)
if (!AsteroidBelt.IsSet() || !Entity.Zone.AsteroidExists(AsteroidBelt, Asteroid))
return false;
var pose = Entity.Zone.ChunkPose(AsteroidBelt, Asteroid);
if (length(Entity.Position.xz - pose.xy) - pose.w < Range)
{
Entity.Zone.MineAsteroid(
Entity,

View file

@ -77,7 +77,7 @@ public class ResourceScanner : Behavior, IAlwaysUpdatedBehavior
{
if(Asteroid > -1 &&
Asteroid < beltData.Asteroids.Length &&
length(Entity.Position.xz - Entity.Zone.AsteroidBelts[ScanTarget].Transforms[Asteroid].xy) < Range)
length(Entity.Position.xz - Entity.Zone.ChunkPose(ScanTarget, Asteroid).xy) < Range)
{
_scanTime += dt;
if (_scanTime > ScanDuration)

View file

@ -6,8 +6,6 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using UniRx;
using CultMath;
@ -43,8 +41,6 @@ public class Zone
private int _nextShotId;
public int NextShotId() => ++_nextShotId;
private List<Task> BeltUpdates = new List<Task>();
public float Time
{
get => (float) _time;
@ -150,9 +146,6 @@ public class Zone
{
_time += deltaTime;
_updatedOrbits.Clear();
foreach (var t in BeltUpdates)
t.Wait();
BeltUpdates.Clear();
foreach (var orbit in Orbits)
{
orbit.Value.PreviousPosition = orbit.Value.Position;
@ -160,13 +153,6 @@ public class Zone
orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime;
}
foreach (var belt in AsteroidBelts)
{
Array.Copy(belt.Value.NewTransforms, belt.Value.Transforms, belt.Value.Transforms.Length);
belt.Value.OrbitPosition = belt.Value.NewOrbitPosition;
BeltUpdates.Add(Task.Run(() => UpdateAsteroidTransforms(belt.Key)));
}
foreach(var agent in Agents)
agent.Update(deltaTime);
@ -223,55 +209,22 @@ public class Zone
return float2.zero;
}
public int NearestAsteroid(CultRecordKey planetDataID, float2 position)
{
var beltData = Planets[planetDataID] as AsteroidBeltData;
var asteroidPositions = AsteroidBelts[planetDataID].Transforms;
int nearest = 0;
float nearestDistance = Single.MaxValue;
for (int i = 0; i < beltData.Asteroids.Length; i++)
{
var dist = lengthsq(asteroidPositions[i].xz - position);
if (AsteroidExists(planetDataID, i) && dist < nearestDistance)
{
nearest = i;
nearestDistance = dist;
}
}
return nearest;
}
public bool AsteroidExists(CultRecordKey planetDataID, int asteroid) => ((AsteroidBeltData) Planets[planetDataID]).Asteroids.Length > asteroid && asteroid >= 0;
private void UpdateAsteroidTransforms(CultRecordKey planetDataID)
// Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored
// pose survives a tick; the sim and the renderer both read it fresh through here.
public float4 ChunkPose(CultRecordKey belt, int index)
{
var beltData = Planets[planetDataID] as AsteroidBeltData;
var beltData = Planets[belt] as AsteroidBeltData;
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings);
}
var belt = AsteroidBelts[planetDataID];
var orbitData = Orbits[beltData.Orbit.Key].Data;
belt.NewOrbitPosition = GetOrbitPosition(orbitData.Parent.Key);
for (var i = 0; i < beltData.Asteroids.Length; i++)
{
float size;
if(belt.RespawnTimers.ContainsKey(i)) size = 0;
else if (belt.Damage.ContainsKey(i))
{
var asteroidHitpoints = Settings.AsteroidHitpoints.Evaluate(beltData.Asteroids[i].Size);
var damage = (asteroidHitpoints - belt.Damage[i]) / asteroidHitpoints;
size = Settings.AsteroidSize.Evaluate(damage * beltData.Asteroids[i].Size);
}
else size = Settings.AsteroidSize.Evaluate(beltData.Asteroids[i].Size);
var rot = (float) (_time * beltData.Asteroids[i].RotationSpeed % (PI * 2));
var pos = OrbitData.Evaluate((float) frac(_time / Settings.OrbitPeriod.Evaluate(beltData.Asteroids[i].Distance) +
beltData.Asteroids[i].Phase)) * beltData.Asteroids[i].Distance + belt.NewOrbitPosition;
//belt.NewPositions[i] = float3(pos.x, GetHeight(pos) + Settings.AsteroidVerticalOffset, pos.y);
belt.NewTransforms[i] = float4(pos.x, pos.y, rot, size);
}
public void EvaluateBelt(CultRecordKey belt, Span<float4> into)
{
var beltData = Planets[belt] as AsteroidBeltData;
var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key);
AsteroidBelts[belt].Evaluate(into, _time, parentPosition, Settings);
}
public OrbitData CreateOrbit(CultRecordKey parent, float2 position)
@ -502,11 +455,7 @@ public class Sun : GasGiant
public class AsteroidBelt
{
public AsteroidBeltData Data;
public float4[] Transforms; // x, y, rotation, scale
public float4[] NewTransforms; // x, y, rotation, scale
public float Radius { get; }
public float2 OrbitPosition;
public float2 NewOrbitPosition;
public Dictionary<int, float> RespawnTimers = new Dictionary<int, float>();
public Dictionary<int, float> Damage = new Dictionary<int, float>();
public Dictionary<(Entity, int), float> MiningAccumulator = new Dictionary<(Entity, int), float>();
@ -514,10 +463,38 @@ public class AsteroidBelt
public AsteroidBelt(AsteroidBeltData data)
{
Data = data;
Transforms = new float4[data.Asteroids.Length];
NewTransforms = new float4[data.Asteroids.Length];
Radius = data.Asteroids.Max(a => a.Distance);
}
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
// The only place a chunk pose is computed; no per-tick copy survives it.
public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings)
{
var asteroid = Data.Asteroids[index];
var rot = (float) (time * asteroid.RotationSpeed % (PI * 2));
var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) +
asteroid.Phase)) * asteroid.Distance + parentPosition;
return float4(pos.x, pos.y, rot, Size(index, settings));
}
// Cut 1 (docs/mining-cut.md): the size rule of Zone.cs:259-267, moved unchanged.
public float Size(int index, PlanetSettings settings)
{
if (RespawnTimers.ContainsKey(index)) return 0;
if (Damage.ContainsKey(index))
{
var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size);
var damage = (asteroidHitpoints - Damage[index]) / asteroidHitpoints;
return settings.AsteroidSize.Evaluate(damage * Data.Asteroids[index].Size);
}
return settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size);
}
public void Evaluate(Span<float4> into, double time, float2 parentPosition, PlanetSettings settings)
{
for (var i = 0; i < Data.Asteroids.Length; i++)
into[i] = Pose(i, time, parentPosition, settings);
}
}
public class Orbit

View file

@ -72,6 +72,9 @@ public class ZoneRenderer : MonoBehaviour
private Dictionary<CultRecordKey, AsteroidBeltUI> _beltObjects = new Dictionary<CultRecordKey, AsteroidBeltUI>();
private Dictionary<CultRecordKey, InstancedMesh[]> _beltMeshes = new Dictionary<CultRecordKey, InstancedMesh[]>();
private Dictionary<CultRecordKey, Matrix4x4[][]> _beltMatrices = new Dictionary<CultRecordKey, Matrix4x4[][]>();
// Cut 1 (docs/mining-cut.md): the renderer's own buffer, filled from Zone.EvaluateBelt each frame. No pose
// is stored on AsteroidBelt any more.
private Dictionary<CultRecordKey, float4[]> _beltTransforms = new Dictionary<CultRecordKey, float4[]>();
private float _viewDistance;
//private float _maxDepth;
private float _minimapDistance;
@ -282,6 +285,7 @@ public class ZoneRenderer : MonoBehaviour
_beltObjects.Clear();
_beltMeshes.Clear();
_beltMatrices.Clear();
_beltTransforms.Clear();
_tourPlanets.Clear();
}
}
@ -343,6 +347,7 @@ public class ZoneRenderer : MonoBehaviour
meshes.RemoveAt(Random.Range(0, meshes.Count));
_beltMeshes[key] = meshes.ToArray();
_beltMatrices[key] = new Matrix4x4[meshes.Count][];
_beltTransforms[key] = new float4[beltData.Asteroids.Length];
var count = beltData.Asteroids.Length / meshes.Count;
var remainder = beltData.Asteroids.Length - count * meshes.Count;
for (int i = 0; i < meshes.Count; i++)
@ -446,25 +451,30 @@ public class ZoneRenderer : MonoBehaviour
foreach (var (key, belt) in Zone.AsteroidBelts)
{
var height = Zone.GetHeight(belt.OrbitPosition);
// Cut 1 (docs/mining-cut.md): belts have no stored pose any more. The renderer fills its own buffer
// from Zone.EvaluateBelt, at the current zone time, every frame.
var orbitPosition = Zone.GetOrbitPosition(Zone.Orbits[belt.Data.Orbit.Key].Data.Parent.Key);
var height = Zone.GetHeight(orbitPosition);
var transforms = _beltTransforms[key];
Zone.EvaluateBelt(key, transforms);
if(isVisible(new Bounds(
new Vector3(belt.OrbitPosition.x,height,belt.OrbitPosition.y),
new Vector3(orbitPosition.x,height,orbitPosition.y),
new Vector3(belt.Radius * 2,100,belt.Radius * 2))))
{
var meshes = _beltMeshes[key];
var matrices = _beltMatrices[key];
var count = belt.Transforms.Length / meshes.Length;
var count = transforms.Length / meshes.Length;
for (int i = 0; i < meshes.Length; i++)
{
for (int t = 0; t < matrices[i].Length; t++)
{
var tx = t + i * count;
var transform = belt.Transforms[tx];
var transform = transforms[tx];
matrices[i][t] = Matrix4x4.TRS(new Vector3(transform.x,0,transform.y),
Quaternion.Euler(
cos(transform.z + (float)i / meshes.Length) * 100,
sin(transform.z + (float)i / meshes.Length) * 100,
(float)tx / belt.Transforms.Length * 360),
(float)tx / transforms.Length * 360),
Vector3.one * transform.w);
}
@ -473,7 +483,7 @@ public class ZoneRenderer : MonoBehaviour
}
if(_showAsteroidUI)
_beltObjects[key].Update(belt.Transforms, height);
_beltObjects[key].Update(transforms, height);
}
foreach (var planet in Planets)
@ -659,7 +669,7 @@ public class AsteroidBeltUI
public void Update(float4[] transforms, float height)
{
var parentPosition = _belt.OrbitPosition;
var parentPosition = _zone.GetOrbitPosition(_orbitParent);
for (var i = 0; i < transforms.Length; i++)
{
var transform = transforms[i];

View file

@ -0,0 +1,201 @@
/* 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.IO;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
// Cut 1 (docs/mining-cut.md): belts stop being simulated every tick on a threaded double buffer and become a
// pure function of zone time, evaluated on request. These tests pin that: a chunk's pose is exactly the
// closed-form orbit at the zone's current time (no staleness), it does not depend on how many ticks it took to
// get there (no stored state), and the size rule moved off the deleted per-tick pass unchanged. Every expected
// value below is computed independently, by the closed form written in this file -- never by calling
// AsteroidBelt.Pose/Size, the code under test.
public sealed class MiningCut1Tests : IDisposable
{
private int _fixtureCount;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut1-" + Guid.NewGuid().ToString("N"));
public MiningCut1Tests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private static GameplaySettings GameSettings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
// Authored-shape curves, not defaults: a default ExponentialLerp/ExponentialCurve evaluates to a flat
// constant everywhere, which would hide a broken formula (every asteroid or every damage state would read
// alike). These give period, size and hitpoints real, distinct curvature over the fixture's distance and
// damage range.
private static PlanetSettings Settings() => new PlanetSettings
{
OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f },
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
AsteroidHitpoints = new ExponentialLerp { Minimum = 10f, Maximum = 300f, Exponent = 2f },
AsteroidRespawnTime = new ExponentialLerp { Minimum = 5f, Maximum = 60f, Exponent = 1f }
};
private const int AsteroidCount = 24;
private const float RootDistance = 4000f;
private const float RootPhase = 0.17f;
// A belt of AsteroidCount asteroids at distinct distances, phases, size parameters and rotation speeds,
// orbiting a parent (RootDistance/RootPhase) that is itself moving -- so a test that reads the parent's
// motion through ChunkPose is exercising real recursion, not a fixed point. Each call opens its own
// catalog, so two zones built from this fixture never share cache state.
private (Zone zone, CultRecordKey beltKey, Asteroid[] asteroids, PlanetSettings settings) BuildFixture()
{
var dir = Path.Combine(_root, $"cat{_fixtureCount++}");
Directory.CreateDirectory(dir);
var catalog = Path.Combine(dir, "Aetheria.cc");
var run = Path.Combine(dir, "run.cc");
// OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types:
// they need a run store to write into.
var cache = AetheriaStores.Open(catalog, run, catalogWritable: true);
var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { });
var settings = Settings();
var rootOrbit = new OrbitData { Distance = RootDistance, Phase = RootPhase };
var rootKey = cache.Upsert(rootOrbit).Key;
var beltOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(rootKey), Distance = 900f, Phase = 0.42f };
var beltOrbitKey = cache.Upsert(beltOrbit).Key;
var asteroids = new Asteroid[AsteroidCount];
for (var i = 0; i < AsteroidCount; i++)
{
asteroids[i] = new Asteroid
{
Distance = 120f + i * 11.3f,
Phase = frac(i * 0.6180339887f),
Size = frac(i * 0.37f + 0.1f),
RotationSpeed = 0.15f + i * 0.037f
};
}
var beltData = new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitKey), Asteroids = asteroids };
var beltKey = cache.Upsert(beltData).Key;
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(rootKey), new CultRecordRef<OrbitData>(beltOrbitKey) },
Planets = { new CultRecordRef<BodyData>(beltKey) },
Radius = 5000f,
Mass = 10000f,
Time = 0
};
var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut1", Owner = null }, null);
return (zone, beltKey, asteroids, settings);
}
// The formula of Zone.GetOrbitPosition (unchanged by this cut), reimplemented here so the expected value
// never calls production orbit code.
private static float2 OrbitPosition(double time, float distance, float phase, PlanetSettings settings, float2 fixedPosition = default)
{
var period = settings.OrbitPeriod.Evaluate(distance);
if (period <= .01f) return fixedPosition;
var wrapped = (float) frac(time / period);
return OrbitData.Evaluate(frac(wrapped + phase)) * distance + fixedPosition;
}
// The formula of AsteroidBelt.Pose (Zone.cs:269-271 before this cut), reimplemented independently.
private static float4 ExpectedPose(double time, Asteroid asteroid, float2 parentPosition, PlanetSettings settings, float size)
{
var rot = (float) (time * asteroid.RotationSpeed % (PI * 2));
var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase))
* asteroid.Distance + parentPosition;
return float4(pos.x, pos.y, rot, size);
}
private static void AssertClose(float expected, float actual, float eps, string what)
{
Assert.True(Math.Abs(expected - actual) <= eps, $"{what}: expected {expected}, got {actual} (eps {eps})");
}
[Fact]
public void ChunkPoseIsTheOrbitAtZoneTime()
{
var (zone, beltKey, asteroids, settings) = BuildFixture();
// Uneven dt, so a tick-count-based or double-buffered implementation would disagree with a pure
// function of the accumulated time.
var dts = new[] { 0.13f, 0.07f, 0.31f, 0.02f, 0.5f, 0.11f, 0.19f, 0.08f, 0.44f, 0.03f, 0.27f, 0.09f };
double time = 0;
foreach (var dt in dts)
{
zone.Update(dt);
time += dt;
}
var parentPosition = OrbitPosition(time, RootDistance, RootPhase, settings);
for (var i = 0; i < asteroids.Length; i++)
{
var expected = ExpectedPose(time, asteroids[i], parentPosition, settings, settings.AsteroidSize.Evaluate(asteroids[i].Size));
var actual = zone.ChunkPose(beltKey, i);
AssertClose(expected.x, actual.x, 0.01f, $"asteroid {i} x");
AssertClose(expected.y, actual.y, 0.01f, $"asteroid {i} y");
AssertClose(expected.z, actual.z, 0.001f, $"asteroid {i} rotation");
AssertClose(expected.w, actual.w, 0.001f, $"asteroid {i} size");
}
}
[Fact]
public void ChunkPoseDoesNotDependOnUpdateCadence()
{
var (zoneOneStep, beltKeyA, asteroids, settings) = BuildFixture();
zoneOneStep.Update(1.0f);
var (zoneManySteps, beltKeyB, _, _) = BuildFixture();
for (var i = 0; i < 60; i++)
zoneManySteps.Update(1f / 60f);
for (var i = 0; i < asteroids.Length; i++)
{
var a = zoneOneStep.ChunkPose(beltKeyA, i);
var b = zoneManySteps.ChunkPose(beltKeyB, i);
// Float summation of 60 steps of 1/60 drifts from 1.0 by a few ULPs; the pose is a pure function
// of that (nearly identical) time, not of how many ticks produced it, so a loose position epsilon
// catches a cadence-dependent implementation without chasing float noise.
AssertClose(a.x, b.x, 0.05f, $"asteroid {i} x");
AssertClose(a.y, b.y, 0.05f, $"asteroid {i} y");
AssertClose(a.z, b.z, 0.01f, $"asteroid {i} rotation");
AssertClose(a.w, b.w, 0.0001f, $"asteroid {i} size");
}
}
[Fact]
public void DamagedChunkShrinksAndABrokenOneHasNoSize()
{
var (zone, beltKey, asteroids, settings) = BuildFixture();
var belt = zone.AsteroidBelts[beltKey];
const int undamagedIndex = 5;
const int damagedIndex = 9;
const int brokenIndex = 14;
var undamagedExpected = settings.AsteroidSize.Evaluate(asteroids[undamagedIndex].Size);
Assert.Equal(undamagedExpected, belt.Size(undamagedIndex, settings), 4);
var hp = settings.AsteroidHitpoints.Evaluate(asteroids[damagedIndex].Size);
var damage = hp * 0.6f;
belt.Damage[damagedIndex] = damage;
var damagedExpected = settings.AsteroidSize.Evaluate((hp - damage) / hp * asteroids[damagedIndex].Size);
var damagedActual = belt.Size(damagedIndex, settings);
Assert.Equal(damagedExpected, damagedActual, 4);
Assert.True(damagedActual < settings.AsteroidSize.Evaluate(asteroids[damagedIndex].Size),
"a damaged asteroid must be smaller than its undamaged size");
belt.RespawnTimers[brokenIndex] = settings.AsteroidRespawnTime.Evaluate(asteroids[brokenIndex].Size);
Assert.Equal(0f, belt.Size(brokenIndex, settings));
}
}