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