From 1ef9baf55ce238dcd37b56ba8f1e6d50285e1843 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 28 Sep 2026 12:34:20 -0400 Subject: [PATCH 1/5] Add SIMD heightfield math and zone idle throttling Server-side performance work, split out from the Linux/containerization branch to keep that PR scoped to platform support and test automation. - SimdMath (src/Perpetuum/Simd): SIMD-accelerated heightfield math - HeightfieldMetadata + CompactPassabilityMask: per-tile height and passability metadata maintained from terrain updates - LineOfSight: heightfield precheck before the tile walk - Zone: idle throttling when a zone has no active work - TerrainUpdateMonitor: dirty-chunk tracking - compose: GC adaptation env for the server container - unit tests: SimdMath, CompactPassabilityMask, HeightfieldMetadata, ZoneHeightfield, ZoneIdleThrottling --- compose.yml | 2 + .../Unit/CompactPassabilityMaskTests.cs | 58 +++ .../Unit/HeightfieldMetadataTests.cs | 264 ++++++++++++ src/Perpetuum.Tests/Unit/SimdMathTests.cs | 170 ++++++++ .../Unit/ZoneHeightfieldTests.cs | 185 +++++++++ .../Unit/ZoneIdleThrottlingTests.cs | 110 +++++ src/Perpetuum/Simd/SimdMath.cs | 375 ++++++++++++++++++ src/Perpetuum/Zones/IZone.cs | 7 + src/Perpetuum/Zones/LineOfSight.cs | 79 ++++ .../Zones/NpcSystem/Flocks/NormalFlock.cs | 1 + .../Zones/Terrains/CompactPassabilityMask.cs | 79 ++++ .../Zones/Terrains/HeightfieldMetadata.cs | 245 ++++++++++++ .../Zones/Terrains/TerrainUpdateMonitor.cs | 12 + src/Perpetuum/Zones/Zone.cs | 58 ++- 14 files changed, 1643 insertions(+), 2 deletions(-) create mode 100644 src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs create mode 100644 src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs create mode 100644 src/Perpetuum.Tests/Unit/SimdMathTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs create mode 100644 src/Perpetuum/Simd/SimdMath.cs create mode 100644 src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs create mode 100644 src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs diff --git a/compose.yml b/compose.yml index fc1d9da2..aed90f50 100644 --- a/compose.yml +++ b/compose.yml @@ -104,6 +104,8 @@ services: environment: GameRoot: ${GAME_ROOT} DistributedTransactions: ${DISTRIBUTED_TRANSACTIONS} + # .NET 8 DATAS: dynamically scales GC heaps to reduce idle memory usage + DOTNET_GCDynamicAdaptationMode: 1 depends_on: migration: condition: service_completed_successfully diff --git a/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs new file mode 100644 index 00000000..da74d810 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs @@ -0,0 +1,58 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class CompactPassabilityMaskTests + { + [Fact] + public void Bitmask_get_set_and_bounds() + { + var mask = new CompactPassabilityMask(64, 64); + + // Default is false (0) + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(10, 10)); + + // Set some bits + mask.SetWalkable(0, 0, true); + mask.SetWalkable(15, 20, true); + mask.SetWalkable(31, 31, true); + mask.SetWalkable(32, 31, true); // cross 32-bit boundary + mask.SetWalkable(63, 63, true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(31, 31)); + Assert.True(mask.IsWalkable(32, 31)); + Assert.True(mask.IsWalkable(63, 63)); + + // Unset a bit + mask.SetWalkable(15, 20, false); + Assert.False(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(0, 0)); + + // Out of bounds + Assert.False(mask.IsWalkable(-1, 0)); + Assert.False(mask.IsWalkable(0, -1)); + Assert.False(mask.IsWalkable(64, 0)); + Assert.False(mask.IsWalkable(0, 64)); + } + + [Fact] + public void SetAll_fills_entire_grid() + { + var mask = new CompactPassabilityMask(100, 100); + mask.SetAll(true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(50, 50)); + Assert.True(mask.IsWalkable(99, 99)); + + mask.SetAll(false); + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(50, 50)); + Assert.False(mask.IsWalkable(99, 99)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs new file mode 100644 index 00000000..e80c2d90 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs @@ -0,0 +1,264 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class HeightfieldMetadataTests + { + [Fact] + public void Chunk_bounds_and_ray_above_chunk_queries() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + + // Fill a chunk (0,0 to 15,15) with height 10.0 (raw: 10 * 32 = 320) + for (int y = 0; y < 16; y++) + { + for (int x = 0; x < 16; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + // Fill another chunk (16,0 to 31,15) with height 50.0 (raw: 50 * 32 = 1600) + for (int y = 0; y < 16; y++) + { + for (int x = 16; x < 32; x++) + { + altLayer[x, y] = (ushort)(50 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + + Assert.Equal(4, metadata.ChunksX); + Assert.Equal(4, metadata.ChunksY); + + // Chunk (0,0) has max height 10.0 + metadata.GetChunkBounds(0, 0, out float min0, out float max0); + Assert.Equal(10.0f, min0); + Assert.Equal(10.0f, max0); + + // Chunk (1,0) has max height 50.0 + metadata.GetChunkBounds(1, 0, out float min1, out float max1); + Assert.Equal(50.0f, min1); + Assert.Equal(50.0f, max1); + + // Ray at Z=20 is strictly above chunk (0,0), but NOT above chunk (1,0) + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 20.0f)); + Assert.False(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 20.0f)); + + // Ray at Z=60 is above both + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 60.0f)); + Assert.True(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 60.0f)); + } + + [Fact] + public void Coordinates_mapping() + { + var metadata = new HeightfieldMetadata(2048, 2048, chunkSize: 16); + Assert.Equal(128, metadata.ChunksX); + Assert.Equal(128, metadata.ChunksY); + + metadata.GetChunkCoordinates(0, 0, out int cx0, out int cy0); + Assert.Equal(0, cx0); + Assert.Equal(0, cy0); + + metadata.GetChunkCoordinates(15, 15, out int cx1, out int cy1); + Assert.Equal(0, cx1); + Assert.Equal(0, cy1); + + metadata.GetChunkCoordinates(16, 32, out int cx2, out int cy2); + Assert.Equal(1, cx2); + Assert.Equal(2, cy2); + + metadata.GetChunkCoordinates(2047, 2047, out int cx3, out int cy3); + Assert.Equal(127, cx3); + Assert.Equal(127, cy3); + } + + [Fact] + public void Mark_dirty_tile_flags_only_its_chunk_and_deduplicates() + { + var metadata = new HeightfieldMetadata(64, 64, chunkSize: 16); + + Assert.False(metadata.HasDirtyChunks); + + metadata.MarkDirtyTile(5, 5); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Same chunk again: no double count. + metadata.MarkDirtyTile(15, 15); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Different chunk. + metadata.MarkDirtyTile(20, 20); + Assert.Equal(2, metadata.DirtyChunkCount); + + // Out of bounds: ignored. + metadata.MarkDirtyTile(-1, 0); + metadata.MarkDirtyTile(64, 0); + metadata.MarkDirtyTile(0, -5); + metadata.MarkDirtyTile(0, 100); + Assert.Equal(2, metadata.DirtyChunkCount); + } + + [Fact] + public void Mark_dirty_area_covers_exactly_the_intersecting_chunks() + { + var metadata = new HeightfieldMetadata(64, 64, chunkSize: 16); + + // Area spanning chunks (0,0), (1,0), (0,1), (1,1) and clipping chunk (2,0)/(0,2). + metadata.MarkDirtyArea(new Area(10, 10, 37, 37)); + Assert.Equal(9, metadata.DirtyChunkCount); + + // Fully outside: ignored. + metadata.MarkDirtyArea(new Area(100, 100, 110, 110)); + metadata.MarkDirtyArea(new Area(-50, -50, -10, -10)); + Assert.Equal(9, metadata.DirtyChunkCount); + + // Negative area that still intersects the map: clamped, no out-of-range chunks. + var clamped = new HeightfieldMetadata(64, 64, chunkSize: 16); + clamped.MarkDirtyArea(new Area(-30, -30, 5, 5)); + Assert.Equal(1, clamped.DirtyChunkCount); + + // Fully outside (all negative): ignored. + clamped.MarkDirtyArea(new Area(-30, -30, -1, -1)); + Assert.Equal(1, clamped.DirtyChunkCount); + + // Area beyond the edge: clamped to the last chunk row/column. + var edge = new HeightfieldMetadata(64, 64, chunkSize: 16); + edge.MarkDirtyArea(new Area(60, 60, 200, 200)); + Assert.Equal(1, edge.DirtyChunkCount); + edge.GetChunkBounds(3, 3, out float min, out float max); + Assert.Equal(0f, min); + } + + [Fact] + public void Recompute_dirty_rebakes_only_flagged_chunks_and_clears_the_flags() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + Assert.False(metadata.HasDirtyChunks); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + + // Raise one tile in chunk (0,0) and mark it. + altLayer[5, 5] = (ushort)(100 * 32); + metadata.MarkDirtyTile(5, 5); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Until the re-bake the chunk bound is stale. + metadata.GetChunkBounds(0, 0, out _, out float staleMax); + Assert.Equal(10f, staleMax); + + int rebaked = metadata.RecomputeDirty(altLayer); + Assert.Equal(1, rebaked); + Assert.False(metadata.HasDirtyChunks); + + metadata.GetChunkBounds(0, 0, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(100f, max); + + // Untouched chunks keep their bounds and nothing is re-baked on a second drain. + metadata.GetChunkBounds(1, 0, out _, out float otherMax); + Assert.Equal(10f, otherMax); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + } + + [Fact] + public void Plant_blocking_height_is_picked_up_by_dirty_recompute() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var blockLayer = new Layer(LayerType.Blocks, 64, 64); + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, blockLayer, chunkSize: 16); + + metadata.GetChunkBounds(2, 2, out _, out float before); + Assert.Equal(10f, before); + + // A plant grows on tile (40, 40) (chunk (2,2)) with blocking height 15, the way + // NatureCube commits it: a single SetArea over the scanned cube. + blockLayer[40, 40] = new BlockingInfo(BlockingFlags.Plant, 15); + metadata.MarkDirtyArea(new Area(32, 32, 47, 47)); + Assert.Equal(1, metadata.DirtyChunkCount); + + metadata.RecomputeDirty(altLayer, blockLayer); + + metadata.GetChunkBounds(2, 2, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(25f, max); + + // The plant wilts: the same area is re-scanned with no blocking height. + blockLayer[40, 40] = BlockingInfo.None; + metadata.MarkDirtyArea(new Area(32, 32, 47, 47)); + metadata.RecomputeDirty(altLayer, blockLayer); + + metadata.GetChunkBounds(2, 2, out _, out float after); + Assert.Equal(10f, after); + } + + [Fact] + public void Global_max_height_tracks_full_and_incremental_rebakes() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + Assert.Equal(10f, metadata.GlobalMaxHeight); + + // Raise one tile, re-bake incrementally: the global max follows it up... + altLayer[5, 5] = (ushort)(100 * 32); + metadata.MarkDirtyTile(5, 5); + metadata.RecomputeDirty(altLayer); + Assert.Equal(100f, metadata.GlobalMaxHeight); + + // ...and back down when the tile is levelled again. + altLayer[5, 5] = (ushort)(10 * 32); + metadata.MarkDirtyTile(5, 5); + metadata.RecomputeDirty(altLayer); + Assert.Equal(10f, metadata.GlobalMaxHeight); + } + + [Fact] + public void Recompute_all_clears_dirty_flags() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + + metadata.MarkDirtyTile(0, 0); + metadata.MarkDirtyTile(63, 63); + Assert.Equal(2, metadata.DirtyChunkCount); + + metadata.RecomputeAll(altLayer); + + Assert.False(metadata.HasDirtyChunks); + Assert.Equal(0, metadata.DirtyChunkCount); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/SimdMathTests.cs b/src/Perpetuum.Tests/Unit/SimdMathTests.cs new file mode 100644 index 00000000..5ebad70d --- /dev/null +++ b/src/Perpetuum.Tests/Unit/SimdMathTests.cs @@ -0,0 +1,170 @@ +using Perpetuum.Simd; +using System; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class SimdMathTests + { + [Fact] + public void CalculateSquaredDistances2D_matches_scalar_math() + { + int count = 67; // Non-multiple of 16, 8, and 4 + float srcX = 150.5f; + float srcY = 200.25f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(42); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 1000.0); + targetYs[i] = (float)(rnd.NextDouble() * 1000.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedDistSq[i] = (dx * dx) + (dy * dy); + } + + SimdMath.CalculateSquaredDistances2D(srcX, srcY, targetXs, targetYs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void CalculateSquaredDistances3D_matches_scalar_with_z_scaling() + { + int count = 45; + float srcX = 50.0f; + float srcY = 75.0f; + float srcZ = 120.0f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(123); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 500.0); + targetYs[i] = (float)(rnd.NextDouble() * 500.0); + targetZs[i] = (float)(rnd.NextDouble() * 200.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + + SimdMath.CalculateSquaredDistances3D(srcX, srcY, srcZ, targetXs, targetYs, targetZs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void FilterPointsInRange2D_filters_correctly() + { + int count = 50; + float srcX = 100.0f; + float srcY = 100.0f; + float range = 25.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(999); + for (int i = 0; i < count; i++) + { + // Place some points inside range (e.g. within 25) and some outside + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 60.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 60.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + + SimdMath.FilterPointsInRange2D(srcX, srcY, range, targetXs, targetYs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void FilterPositionsInRange3D_filters_correctly() + { + int count = 64; + float srcX = 200.0f; + float srcY = 200.0f; + float srcZ = 50.0f; + float range = 30.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(777); + for (int i = 0; i < count; i++) + { + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetZs[i] = srcZ + (float)((rnd.NextDouble() - 0.5) * 100.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + + SimdMath.FilterPositionsInRange3D(srcX, srcY, srcZ, range, targetXs, targetYs, targetZs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void EdgeCases_empty_and_small_arrays() + { + // Empty + SimdMath.CalculateSquaredDistances2D(0, 0, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + SimdMath.FilterPointsInRange2D(0, 0, 10, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + + // 1 element + float[] x1 = [10.0f]; + float[] y1 = [20.0f]; + float[] d1 = new float[1]; + SimdMath.CalculateSquaredDistances2D(0, 0, x1, y1, d1); + Assert.Equal(500.0f, d1[0]); + + // 3 elements (below Vector128 width of 4) + float[] x3 = [1.0f, 2.0f, 3.0f]; + float[] y3 = [0.0f, 0.0f, 0.0f]; + float[] d3 = new float[3]; + SimdMath.CalculateSquaredDistances2D(0, 0, x3, y3, d3); + Assert.Equal(1.0f, d3[0]); + Assert.Equal(4.0f, d3[1]); + Assert.Equal(9.0f, d3[2]); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs b/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs new file mode 100644 index 00000000..9f42d545 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs @@ -0,0 +1,185 @@ +using NSubstitute; +using Perpetuum.EntityFramework; +using Perpetuum.Groups.Gangs; +using Perpetuum.Services.Sessions; +using Perpetuum.Units; +using Perpetuum.Zones; +using Perpetuum.Zones.Effects.ZoneEffects; +using Perpetuum.Zones.Terrains; +using System; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + /// + /// Pins the production wiring of HeightfieldMetadata: the Zone bakes the metadata when the + /// Terrain is assigned, terrain mutations funnelled through TerrainUpdateMonitor mark chunks + /// dirty, the zone tick re-bakes them, and the LOS coarse pre-check stays conservative + /// (never reports "no hit" for a ray the per-tile loop would have blocked). + /// + public class ZoneHeightfieldTests + { + private const int MapSize = 64; + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(50); + + [Fact] + public void Assigning_the_terrain_bakes_the_heightfield() + { + TestZone zone = CreateZone(out _, out _); + + HeightfieldMetadata heightfield = zone.Heightfield; + Assert.NotNull(heightfield); + Assert.False(heightfield.HasDirtyChunks); + Assert.Equal(10f, heightfield.GlobalMaxHeight); + + heightfield.GetChunkBounds(0, 0, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(10f, max); + } + + [Fact] + public void Plant_growth_marks_the_chunk_dirty_and_the_zone_tick_rebakes_it() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + // The way NatureCube commits a grown plant: a layer write observed by the monitor. + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + HeightfieldMetadata heightfield = zone.Heightfield; + Assert.True(heightfield.HasDirtyChunks); + Assert.Equal(1, heightfield.DirtyChunkCount); + + // Until the re-bake the chunk bound is stale and must not be trusted. + heightfield.GetChunkBounds(2, 2, out _, out float staleMax); + Assert.Equal(10f, staleMax); + + zone.Update(Tick); + + Assert.False(heightfield.HasDirtyChunks); + heightfield.GetChunkBounds(2, 2, out _, out float max); + Assert.Equal(25f, max); + Assert.Equal(25f, heightfield.GlobalMaxHeight); + } + + [Fact] + public void Los_ray_flying_above_the_highest_terrain_returns_none() + { + TestZone zone = CreateZoneWithGrownPlant(); + + // Flies at Z = 50, well above the rebaked max of 25 (+ smoothing margin). + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 49)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 49), false); + + Assert.False(result.hit); + } + + [Fact] + public void Los_ray_at_plant_height_still_hits_after_the_rebake() + { + // Guards the coarse pre-check against over-optimism: the ray at Z = 20 is below the + // grown plant's blocking height (25) and must be reported as a hit by the per-tile + // loop even though most crossed chunks are far lower. + TestZone zone = CreateZoneWithGrownPlant(); + + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 19)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 19), false); + + Assert.True(result.hit); + Assert.True(result.blockingFlags.HasFlag(BlockingFlags.Plant)); + } + + [Fact] + public void Los_coarse_precheck_is_skipped_while_chunks_are_dirty() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + // No zone.Update: chunk (2,2) is still dirty and the global max is still the pre-growth + // value, so the pre-check must bail out and the exact per-tile loop must run. + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 49)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 49), false); + + Assert.False(result.hit); + } + + private static TestZone CreateZoneWithGrownPlant() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + zone.Update(Tick); // re-bakes the dirty chunk + return zone; + } + + private static TestZone CreateZone(out AltitudeLayer altitude, out Layer blocks) + { + altitude = new AltitudeLayer(new ushort[MapSize * MapSize], MapSize, MapSize); + for (int y = 0; y < MapSize; y++) + { + for (int x = 0; x < MapSize; x++) + { + altitude[x, y] = (ushort)(10 * 32); // flat at 10 + } + } + + blocks = new Layer(LayerType.Blocks, MapSize, MapSize); + + Terrain terrain = new Terrain + { + Altitude = altitude, + Blocks = blocks, + Slope = new SlopeLayer(altitude) + }; + + TestZone zone = new TestZone(); + zone.Configuration = ZoneConfiguration.None; + zone.MiningLogHandler = new MiningLogHandler(zone); + zone.HarvestLogHandler = new HarvestLogHandler(zone); + zone.ZoneEffectHandler = Substitute.For(); + zone.Terrain = terrain; + + return zone; + } + + private static Perpetuum.Units.Unit CreateShooter(Position position) + { + // A bare unit: PositionWithHeight is CurrentPosition + Height, and the default height + // of an entity without components is 1.0 (ComputeHeight + 1), so the given Z is +1. + var unit = new ShooterUnit { Eid = 1 }; + unit.ED = EntityDefault.None; + unit.CurrentPosition = position; + return unit; + } + + private sealed class TestZone : Zone + { + public TestZone() + : base(Substitute.For(), Substitute.For()) + { + } + } + + private sealed class ShooterUnit : Perpetuum.Units.Unit + { + protected override void OnUpdate(TimeSpan time) + { + } + + public override string InfoString => "shooter-unit"; + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs new file mode 100644 index 00000000..174f04a4 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs @@ -0,0 +1,110 @@ +using NSubstitute; +using Perpetuum.Groups.Gangs; +using Perpetuum.Services.Sessions; +using Perpetuum.Units; +using Perpetuum.Zones; +using Perpetuum.Zones.Effects.ZoneEffects; +using Perpetuum.Zones.Terrains; +using System; +using System.Collections.Generic; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + /// + /// Covers the idle throttle in Zone.Update: when no players are in the zone, units are + /// updated once per idle second and must receive the accumulated elapsed time, otherwise + /// every elapsed/timer-driven system inside a unit (cooldowns, movement, recharge, AI) + /// runs at a fraction of real speed. + /// + public class ZoneIdleThrottlingTests + { + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(200); + + [Fact] + public void Idle_zone_updates_units_once_per_idle_second_with_accumulated_elapsed_time() + { + TestZone zone = CreateZone(); + RecordingUnit unit = new RecordingUnit { Eid = 1 }; + unit.AddToZone(zone, new Position(0, 0, 0)); + + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Single(unit.UpdatedTimes); + Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[0]); + + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Equal(2, unit.UpdatedTimes.Count); + Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[1]); + } + + [Fact] + public void Idle_zone_simulates_real_time_for_units() + { + // Regression: units used to receive the raw tick (~200ms) once per second, + // so an idle zone simulated at ~20% of real speed. + TestZone zone = CreateZone(); + RecordingUnit unit = new RecordingUnit { Eid = 1 }; + unit.AddToZone(zone, new Position(0, 0, 0)); + + for (int i = 0; i < 10; i++) + { + zone.Update(Tick); + } + + TimeSpan total = TimeSpan.Zero; + foreach (TimeSpan t in unit.UpdatedTimes) + { + total += t; + } + + Assert.Equal(2, unit.UpdatedTimes.Count); + Assert.Equal(TimeSpan.FromSeconds(2), total); + } + + private static TestZone CreateZone() + { + TestZone zone = new TestZone(); + zone.Configuration = ZoneConfiguration.None; + zone.MiningLogHandler = new MiningLogHandler(zone); + zone.HarvestLogHandler = new HarvestLogHandler(zone); + zone.ZoneEffectHandler = Substitute.For(); + + // AddToZone -> FixZ reads the terrain altitude + ITerrain terrain = Substitute.For(); + terrain.Altitude.Returns(new AltitudeLayer(new ushort[64 * 64], 64, 64)); + zone.Terrain = terrain; + + return zone; + } + + private sealed class TestZone : Zone + { + public TestZone() + : base(Substitute.For(), Substitute.For()) + { + } + } + + private sealed class RecordingUnit : Perpetuum.Units.Unit + { + public List UpdatedTimes { get; } = new List(); + + protected override void OnUpdate(TimeSpan time) + { + // Deliberately does not call base: the recording unit is not a real + // game unit and must not run recharge/effects/broadcast machinery. + UpdatedTimes.Add(time); + } + + public override string InfoString => "recording-unit"; + } + } +} diff --git a/src/Perpetuum/Simd/SimdMath.cs b/src/Perpetuum/Simd/SimdMath.cs new file mode 100644 index 00000000..756ec45d --- /dev/null +++ b/src/Perpetuum/Simd/SimdMath.cs @@ -0,0 +1,375 @@ +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + +namespace Perpetuum.Simd +{ + /// + /// SIMD-accelerated math operations with AVX-512, AVX2, SSE2 and scalar fallbacks for .NET 8. + /// + public static class SimdMath + { + public static bool IsAvx512Supported => Vector512.IsHardwareAccelerated && Avx512F.IsSupported; + public static bool IsAvx2Supported => Vector256.IsHardwareAccelerated && Avx2.IsSupported; + public static bool IsVector128Supported => Vector128.IsHardwareAccelerated; + + /// + /// Calculates squared 2D distances from a source point (srcX, srcY) to an array of target points. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances2D( + float srcX, float srcY, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, destinationDistSq.Length)); + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + destinationDistSq[i] = (dx * dx) + (dy * dy); + } + } + + /// + /// Calculates squared 3D distances with Perpetuum Z-scaling (dz = (targetZ - srcZ) / 4.0). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances3D( + float srcX, float srcY, float srcZ, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, destinationDistSq.Length))); + int i = 0; + + const float zScale = 0.25f; // 1.0 / 4.0 + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + destinationDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + } + + /// + /// Filters target points that are within a specified 2D range from (srcX, srcY). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPointsInRange2D( + float srcX, float srcY, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, inRangeResults.Length)); + float rangeSq = range * range; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + inRangeResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + } + + /// + /// Filters target points that are within a specified 3D range with Perpetuum Z-scaling. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPositionsInRange3D( + float srcX, float srcY, float srcZ, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, inRangeResults.Length))); + float rangeSq = range * range; + const float zScale = 0.25f; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + inRangeResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + } + } +} diff --git a/src/Perpetuum/Zones/IZone.cs b/src/Perpetuum/Zones/IZone.cs index f3a0cd6e..0f4ef4f9 100644 --- a/src/Perpetuum/Zones/IZone.cs +++ b/src/Perpetuum/Zones/IZone.cs @@ -42,6 +42,13 @@ public interface IZone ITerrain Terrain { get; } + /// + /// Chunk bounding metadata over the terrain altitude and blocking layers, baked when the + /// terrain is assigned and kept fresh by the zone update loop. Null when the zone has no + /// terrain; consumers must treat dirty chunks as untrusted (see HeightfieldMetadata). + /// + [CanBeNull] + HeightfieldMetadata Heightfield { get; } CorporationHandler CorporationHandler { get; } IPlantHandler PlantHandler { get; } IBeamService Beams { get; } diff --git a/src/Perpetuum/Zones/LineOfSight.cs b/src/Perpetuum/Zones/LineOfSight.cs index d0131bc2..4750034d 100644 --- a/src/Perpetuum/Zones/LineOfSight.cs +++ b/src/Perpetuum/Zones/LineOfSight.cs @@ -112,6 +112,11 @@ private static LOSResult IsInLineOfSight(IZone zone, Vector3 origin, Vector3 dir return losResult; } + if (RayPassesAboveAllCrossedChunks(zone, origin, direction, distance)) + { + return LOSResult.None; + } + var lastAltitude = zone.Terrain.Altitude.GetAltitudeAsDouble(origin) + 2; var lx = (int) origin.X; @@ -165,6 +170,80 @@ private static LOSResult IsInLineOfSight(IZone zone, Vector3 origin, Vector3 dir return LOSResult.None; } + /// + /// Coarse pre-check: walks the chunk grid the ray crosses (Amanatides & Woo) and reports + /// whether every crossed chunk is provably below the ray, so the per-tile loop below can be + /// skipped entirely. The per-tile check can raise a tile's effective blocking height above + /// its raw (altitude + block) by at most 0.1 * (highest terrain in the zone + 2) through + /// neighbor-tile smoothing (see GetAltitude), so a chunk is only trusted when the ray's + /// lowest point clears its max height by that margin. Ballistic arcs only add height, so + /// the linear min Z bounds the whole ray. Skipped entirely while any chunk is dirty, when + /// the bounds may be stale in either direction. + /// + private static bool RayPassesAboveAllCrossedChunks(IZone zone, Vector3 origin, Vector3 direction, float distance) + { + var heightfield = zone.Heightfield; + if (heightfield == null || heightfield.HasDirtyChunks) + { + return false; + } + + float endZ = origin.Z + (float)(direction.Z * distance); + float minZ = Math.Min(origin.Z, endZ); + float clearance = minZ - (float)(0.1 * (heightfield.GlobalMaxHeight + 2.0)); + + int stepX = direction.X >= 0 ? 1 : -1; + int stepY = direction.Y >= 0 ? 1 : -1; + int size = heightfield.ChunkSize; + + int chunkX = (int) (origin.X / size); + int chunkY = (int) (origin.Y / size); + float tMaxX = NextChunkBoundary(origin.X, direction.X, chunkX, size, stepX); + float tMaxY = NextChunkBoundary(origin.Y, direction.Y, chunkY, size, stepY); + float tDeltaX = direction.X != 0 ? size / Math.Abs(direction.X) : float.PositiveInfinity; + float tDeltaY = direction.Y != 0 ? size / Math.Abs(direction.Y) : float.PositiveInfinity; + + while (true) + { + if (!heightfield.CanRayPassAboveChunk(chunkX, chunkY, clearance)) + { + return false; + } + + if (tMaxX < tMaxY) + { + chunkX += stepX; + if (tMaxX > distance) + { + return true; + } + + tMaxX += tDeltaX; + } + else + { + chunkY += stepY; + if (tMaxY > distance) + { + return true; + } + + tMaxY += tDeltaY; + } + } + } + + private static float NextChunkBoundary(float coordinate, float direction, int chunk, int size, int step) + { + if (direction == 0) + { + return float.PositiveInfinity; + } + + float boundary = step > 0 ? (chunk + 1) * (float) size - coordinate : chunk * (float) size - coordinate; + return boundary / direction; + } + private static double GetAltitude(IZone zone,Vector3 position,ref double lastAltitude) { var altitude = zone.Terrain.Altitude.GetAltitudeAsDouble(position); diff --git a/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs index 8c7be994..dce8c825 100644 --- a/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs +++ b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using Perpetuum.Timers; using Perpetuum.Units; using Perpetuum.Zones.NpcSystem.Presences; diff --git a/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs new file mode 100644 index 00000000..e9bdc537 --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// A high-performance 1-bit per tile passability bitmask. + /// Provides cache-efficient (512 KB per 2048x2048 zone) walkability queries. + /// + public class CompactPassabilityMask + { + public int Width { get; } + public int Height { get; } + + private readonly uint[] _bits; + + public CompactPassabilityMask(int width, int height) + { + Width = width; + Height = height; + int totalBits = width * height; + _bits = new uint[(totalBits + 31) / 32]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsWalkable(int x, int y) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return false; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + return (_bits[arrayIndex] & (1u << bitOffset)) != 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetWalkable(int x, int y, bool walkable) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + if (walkable) + { + _bits[arrayIndex] |= (1u << bitOffset); + } + else + { + _bits[arrayIndex] &= ~(1u << bitOffset); + } + } + + public void SetAll(bool walkable) + { + uint value = walkable ? uint.MaxValue : 0u; + Array.Fill(_bits, value); + } + + public static CompactPassabilityMask ExtractFrom(ILayer blockingLayer, SlopeLayer slopeLayer, double slopeThreshold = 4.0) + { + var mask = new CompactPassabilityMask(blockingLayer.Width, blockingLayer.Height); + for (int y = 0; y < blockingLayer.Height; y++) + { + for (int x = 0; x < blockingLayer.Width; x++) + { + bool blocked = blockingLayer.GetValue(x, y).Height > 0; + bool slopeOk = slopeLayer.CheckSlope(x, y, slopeThreshold); + mask.SetWalkable(x, y, !blocked && slopeOk); + } + } + return mask; + } + } +} diff --git a/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs new file mode 100644 index 00000000..e0f43c9d --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// Pre-extracted hierarchical chunk bounding metadata from terrain layers (altitude and blocking) + /// to accelerate spatial queries, Line-of-Sight (LOS) raycasting, and obstacle checks. + /// + /// Production wiring: the Zone bakes the metadata from the terrain when the Terrain is assigned + /// and exposes it as IZone.Heightfield. TerrainUpdateMonitor marks chunks dirty as the altitude + /// or blocking layers mutate (plant growth, terraforming, PBS construction, environment + /// placement), and Zone.Update drains the dirty set via RecomputeDirty before the unit update. + /// + /// Chunk bounds are incremental: mutations to the altitude or blocking layers mark the affected + /// chunks dirty via MarkDirtyTile/MarkDirtyArea, and RecomputeDirty re-bakes only those chunks. + /// The compact per-chunk min/max arrays are never rebuilt in full except via RecomputeAll. + /// + /// Threading: marking and re-baking run on the zone tick thread; consumers (LineOfSight) read + /// the bounds lock-free from any thread, the same benign-race assumption the terrain layers + /// themselves already live under. While HasDirtyChunks is true, bounds may be stale in either + /// direction and consumers must not trust them for early-outs. + /// + public class HeightfieldMetadata + { + public const int DefaultChunkSize = 16; + + public int Width { get; } + public int Height { get; } + public int ChunkSize { get; } + public int ChunksX { get; } + public int ChunksY { get; } + + private readonly float[] _minHeights; + private readonly float[] _maxHeights; + + private readonly BitArray _dirtyChunks; + private int _dirtyCount; + + /// + /// Highest (altitude + blocking) height over the whole map, in altitude units. + /// Maintained by RecomputeAll and RecomputeDirty; 0 for a freshly constructed instance. + /// + public float GlobalMaxHeight { get; private set; } + + public HeightfieldMetadata(int width, int height, int chunkSize = DefaultChunkSize) + { + Width = width; + Height = height; + ChunkSize = Math.Max(1, chunkSize); + ChunksX = (width + ChunkSize - 1) / ChunkSize; + ChunksY = (height + ChunkSize - 1) / ChunkSize; + + _minHeights = new float[ChunksX * ChunksY]; + _maxHeights = new float[ChunksX * ChunksY]; + _dirtyChunks = new BitArray(ChunksX * ChunksY); + } + + public bool HasDirtyChunks => _dirtyCount > 0; + + public int DirtyChunkCount => _dirtyCount; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void MarkDirtyTile(int tileX, int tileY) + { + if (tileX < 0 || tileX >= Width || tileY < 0 || tileY >= Height) + return; + + MarkDirtyChunk(tileX / ChunkSize, tileY / ChunkSize); + } + + public void MarkDirtyArea(Area area) + { + if (area.X2 < 0 || area.Y2 < 0 || area.X1 >= Width || area.Y1 >= Height) + return; + + int x2 = Math.Min(area.X2, Width - 1); + int y2 = Math.Min(area.Y2, Height - 1); + + int cy1 = Math.Max(0, area.Y1 / ChunkSize); + int cx1 = Math.Max(0, area.X1 / ChunkSize); + + for (int cy = cy1; cy <= y2 / ChunkSize; cy++) + { + for (int cx = cx1; cx <= x2 / ChunkSize; cx++) + { + MarkDirtyChunk(cx, cy); + } + } + } + + private void MarkDirtyChunk(int chunkX, int chunkY) + { + int idx = GetChunkIndex(chunkX, chunkY); + if (!_dirtyChunks[idx]) + { + _dirtyChunks[idx] = true; + _dirtyCount++; + } + } + + /// + /// Re-bakes every dirty chunk and clears the dirty flags. Returns the number of chunks + /// re-baked, so callers can skip the scan entirely when nothing is dirty. + /// + public int RecomputeDirty(AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + if (_dirtyCount == 0) + return 0; + + int recomputed = 0; + for (int cy = 0; cy < ChunksY; cy++) + { + for (int cx = 0; cx < ChunksX; cx++) + { + int idx = GetChunkIndex(cx, cy); + if (!_dirtyChunks[idx]) + continue; + + RecomputeChunk(cx, cy, altitudeLayer, blockingLayer); + _dirtyChunks[idx] = false; + _dirtyCount--; + recomputed++; + } + } + + GlobalMaxHeight = ComputeGlobalMax(); + + return recomputed; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetChunkIndex(int chunkX, int chunkY) => (chunkY * ChunksX) + chunkX; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkCoordinates(int tileX, int tileY, out int chunkX, out int chunkY) + { + chunkX = Math.Clamp(tileX / ChunkSize, 0, ChunksX - 1); + chunkY = Math.Clamp(tileY / ChunkSize, 0, ChunksY - 1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkBounds(int chunkX, int chunkY, out float minH, out float maxH) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + { + minH = float.MinValue; + maxH = float.MaxValue; + return; + } + + int idx = GetChunkIndex(chunkX, chunkY); + minH = _minHeights[idx]; + maxH = _maxHeights[idx]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanRayPassAboveChunk(int chunkX, int chunkY, float rayMinZ) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + return false; + + int idx = GetChunkIndex(chunkX, chunkY); + return rayMinZ > _maxHeights[idx]; + } + + /// + /// Extracts and bakes chunk min/max metadata from an AltitudeLayer and optional Blocking Layer. + /// + public static HeightfieldMetadata ExtractFrom(AltitudeLayer altitudeLayer, ILayer blockingLayer = null, int chunkSize = DefaultChunkSize) + { + var metadata = new HeightfieldMetadata(altitudeLayer.Width, altitudeLayer.Height, chunkSize); + metadata.RecomputeAll(altitudeLayer, blockingLayer); + return metadata; + } + + public void RecomputeAll(AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + for (int cy = 0; cy < ChunksY; cy++) + { + for (int cx = 0; cx < ChunksX; cx++) + { + RecomputeChunk(cx, cy, altitudeLayer, blockingLayer); + } + } + + _dirtyChunks.SetAll(false); + _dirtyCount = 0; + GlobalMaxHeight = ComputeGlobalMax(); + } + + private float ComputeGlobalMax() + { + float max = 0; + foreach (float h in _maxHeights) + { + if (h > max) + { + max = h; + } + } + + return max; + } + + public void RecomputeChunk(int chunkX, int chunkY, AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + int startX = chunkX * ChunkSize; + int startY = chunkY * ChunkSize; + int endX = Math.Min(startX + ChunkSize, Width); + int endY = Math.Min(startY + ChunkSize, Height); + + float min = float.MaxValue; + float max = float.MinValue; + + for (int y = startY; y < endY; y++) + { + for (int x = startX; x < endX; x++) + { + float alt = (float)altitudeLayer.GetAltitudeAsDouble(x, y); + float blockHeight = 0; + if (blockingLayer != null) + { + blockHeight = blockingLayer.GetValue(x, y).Height; + } + + float totalHeight = alt + blockHeight; + if (totalHeight < min) min = totalHeight; + if (totalHeight > max) max = totalHeight; + } + } + + if (min > max) + { + min = 0; + max = 0; + } + + int idx = GetChunkIndex(chunkX, chunkY); + _minHeights[idx] = min; + _maxHeights[idx] = max; + } + } +} diff --git a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs index 1d8ce202..ed81ae37 100644 --- a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs +++ b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs @@ -171,12 +171,24 @@ private void Notify() private void OnAreaUpdated(LayerType layerType, Area area) { + // Altitude and blocking heights feed the zone's heightfield chunk bounds; any mutation + // to them (plant growth, terraforming, PBS construction) invalidates the affected chunks. + if (layerType == LayerType.Altitude || layerType == LayerType.Blocks) + { + _zone.Heightfield?.MarkDirtyArea(area); + } + var info = new AreaUpdateInfo(layerType, area); AddUpdateInfo(info); } private void OnTileUpdated(LayerType layerType, int x, int y) { + if (layerType == LayerType.Altitude || layerType == LayerType.Blocks) + { + _zone.Heightfield?.MarkDirtyTile(x, y); + } + var info = new TileUpdateInfo(layerType, new SKPointI(x,y)); AddUpdateInfo(info); } diff --git a/src/Perpetuum/Zones/Zone.cs b/src/Perpetuum/Zones/Zone.cs index 9cdc7474..a6760cbd 100644 --- a/src/Perpetuum/Zones/Zone.cs +++ b/src/Perpetuum/Zones/Zone.cs @@ -50,7 +50,25 @@ public abstract class Zone : Threading.Process.Process, IZone public ZoneConfiguration Configuration { get; set; } - public ITerrain Terrain { get; set; } + private ITerrain _terrain; + + public ITerrain Terrain + { + get => _terrain; + set + { + _terrain = value; + + // Baked once at assignment (the zone factory populates all layers before this runs); + // later mutations are tracked incrementally by TerrainUpdateMonitor and drained in Update. + Heightfield = value?.Altitude != null + ? HeightfieldMetadata.ExtractFrom(value.Altitude, value.Blocks) + : null; + } + } + + [CanBeNull] + public HeightfieldMetadata Heightfield { get; private set; } public CorporationHandler CorporationHandler { get; set; } public IPlantHandler PlantHandler { get; set; } public IBeamService Beams { get; set; } @@ -331,6 +349,7 @@ public Player GetPlayer(long eid) } private readonly ShiftedConsumerTimer _updateUnitsTimer = new ShiftedConsumerTimer(500); + private readonly IntervalTimer _idleUpdateTimer = new IntervalTimer(1000); private Action _updateProfiler; @@ -347,12 +366,35 @@ private void MeasureUpdate(TimeSpan time) public override void Update(TimeSpan time) { + UpdateHeightfield(); UpdatePlayerPresence(time); UpdateSessions(time); + // Throttle unit physics, AI, and visibility processing when no players are in the zone. + // Units only receive an update on the throttle tick, so they are handed the + // accumulated elapsed time: all unit systems are timer/elapsed driven + // (cooldowns, movement integration, recharge, AI cycles), so passing the full + // elapsed keeps them running at real speed. + TimeSpan unitTime = time; + if (_players.IsEmpty) + { + _idleUpdateTimer.Update(time); + if (!_idleUpdateTimer.Passed) + { + RiftManager?.Update(time); + RelicManager?.Update(time); + MiningLogHandler.Update(time); + HarvestLogHandler.Update(time); + return; + } + + unitTime = _idleUpdateTimer.Elapsed; + _idleUpdateTimer.Reset(); + } + _updateUnitsTimer.Update(time).IsPassed(ProcessUpdatedUnits); - UpdateUnits(time); + UpdateUnits(unitTime); RiftManager?.Update(time); RelicManager?.Update(time); @@ -361,6 +403,18 @@ public override void Update(TimeSpan time) MeasureUpdate(time); } + private void UpdateHeightfield() + { + HeightfieldMetadata heightfield = Heightfield; + ITerrain terrain = Terrain; + if (heightfield == null || terrain == null || terrain.Altitude == null || !heightfield.HasDirtyChunks) + { + return; + } + + heightfield.RecomputeDirty(terrain.Altitude, terrain.Blocks); + } + /// /// Checks for the presence of players on the field. /// From 217b96186c723d8f4785fc464794974a9b778e75 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 28 Sep 2026 13:22:21 -0400 Subject: [PATCH 2/5] Fix flaky PlayerUpdateLockTests monitor ownership across awaits The test called Monitor.Enter and then awaited while holding the lock; continuations reschedule onto the thread pool, so Monitor.Exit could run on a different thread and throw SynchronizationLockException (observed in CI). A dedicated holder thread now owns the monitor for the observation window; the test body awaits as before. --- .../Unit/PlayerUpdateLockTests.cs | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/Perpetuum.Tests/Unit/PlayerUpdateLockTests.cs b/src/Perpetuum.Tests/Unit/PlayerUpdateLockTests.cs index 6284d29f..9ce2b800 100644 --- a/src/Perpetuum.Tests/Unit/PlayerUpdateLockTests.cs +++ b/src/Perpetuum.Tests/Unit/PlayerUpdateLockTests.cs @@ -74,37 +74,49 @@ public async Task A_tick_cannot_update_the_player_while_the_entry_lock_is_held() Task updateTask = null; CancellationToken cancellationToken = TestContext.Current.CancellationToken; - // Manual monitor instead of a lock statement: the entry lock has to be held - // across the awaits below. - Monitor.Enter(player.UpdateLock); - try - { - updateTask = Task.Run(() => - { - updateThreadId = Environment.CurrentManagedThreadId; - player.Update(TimeSpan.FromMilliseconds(50)); - }, cancellationToken); - - // Wait until the tick thread has actually started, so the assertion below - // cannot pass vacuously. - await WaitUntilAsync(() => updateThreadId != 0, TimeSpan.FromSeconds(5), - "the tick thread never started", cancellationToken); - - // The update body is in-memory and takes a small fraction of this window; - // a player update completing inside it was not blocked by the entry lock. - await Task.Delay(300, cancellationToken); - - if (updateTask.IsCompleted) - { - await updateTask; // rethrows if the update faulted - Assert.Fail("Player.Update completed while the entry lock was held; OnUpdate is not serialized under UpdateLock"); - } - } - finally + // The entry lock has to be held across the observation window, but + // Monitor.Enter/Exit must run on one thread while the test body is full of + // awaits (which reschedule onto the thread pool). A dedicated holder thread + // therefore owns the monitor for the duration of the window. + using SemaphoreSlim lockHeld = new(0, 1); + using SemaphoreSlim releaseLock = new(0, 1); + + Task holderTask = Task.Run(() => { + Monitor.Enter(player.UpdateLock); + lockHeld.Release(); + releaseLock.Wait(cancellationToken); Monitor.Exit(player.UpdateLock); + }, cancellationToken); + + await lockHeld.WaitAsync(cancellationToken); + + updateTask = Task.Run(() => + { + updateThreadId = Environment.CurrentManagedThreadId; + player.Update(TimeSpan.FromMilliseconds(50)); + }, cancellationToken); + + // Wait until the tick thread has actually started, so the assertion below + // cannot pass vacuously. + await WaitUntilAsync(() => updateThreadId != 0, TimeSpan.FromSeconds(5), + "the tick thread never started", cancellationToken); + + // The update body is in-memory and takes a small fraction of this window; + // a player update completing inside it was not blocked by the entry lock. + await Task.Delay(300, cancellationToken); + + if (updateTask.IsCompleted) + { + await updateTask; // rethrows if the update faulted } + Assert.False(updateTask.IsCompleted, + "Player.Update completed while the entry lock was held; OnUpdate is not serialized under UpdateLock"); + + releaseLock.Release(); + await holderTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + await updateTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); } finally From b8b8081384452a7e1bd8968ee8b7506cece8a5d7 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 28 Sep 2026 12:34:20 -0400 Subject: [PATCH 3/5] Add SIMD heightfield math and zone idle throttling Server-side performance work, split out from the Linux/containerization branch to keep that PR scoped to platform support and test automation. - SimdMath (src/Perpetuum/Simd): SIMD-accelerated heightfield math - HeightfieldMetadata + CompactPassabilityMask: per-tile height and passability metadata maintained from terrain updates - LineOfSight: heightfield precheck before the tile walk - Zone: idle throttling when a zone has no active work - TerrainUpdateMonitor: dirty-chunk tracking - compose: GC adaptation env for the server container - unit tests: SimdMath, CompactPassabilityMask, HeightfieldMetadata, ZoneHeightfield, ZoneIdleThrottling --- compose.yml | 2 + .../Unit/CompactPassabilityMaskTests.cs | 58 +++ .../Unit/HeightfieldMetadataTests.cs | 264 ++++++++++++ src/Perpetuum.Tests/Unit/SimdMathTests.cs | 170 ++++++++ .../Unit/ZoneHeightfieldTests.cs | 185 +++++++++ .../Unit/ZoneIdleThrottlingTests.cs | 110 +++++ src/Perpetuum/Simd/SimdMath.cs | 375 ++++++++++++++++++ src/Perpetuum/Zones/IZone.cs | 7 + src/Perpetuum/Zones/LineOfSight.cs | 79 ++++ .../Zones/NpcSystem/Flocks/NormalFlock.cs | 1 + .../Zones/Terrains/CompactPassabilityMask.cs | 79 ++++ .../Zones/Terrains/HeightfieldMetadata.cs | 245 ++++++++++++ .../Zones/Terrains/TerrainUpdateMonitor.cs | 12 + src/Perpetuum/Zones/Zone.cs | 58 ++- 14 files changed, 1643 insertions(+), 2 deletions(-) create mode 100644 src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs create mode 100644 src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs create mode 100644 src/Perpetuum.Tests/Unit/SimdMathTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs create mode 100644 src/Perpetuum/Simd/SimdMath.cs create mode 100644 src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs create mode 100644 src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs diff --git a/compose.yml b/compose.yml index fc1d9da2..aed90f50 100644 --- a/compose.yml +++ b/compose.yml @@ -104,6 +104,8 @@ services: environment: GameRoot: ${GAME_ROOT} DistributedTransactions: ${DISTRIBUTED_TRANSACTIONS} + # .NET 8 DATAS: dynamically scales GC heaps to reduce idle memory usage + DOTNET_GCDynamicAdaptationMode: 1 depends_on: migration: condition: service_completed_successfully diff --git a/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs new file mode 100644 index 00000000..da74d810 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs @@ -0,0 +1,58 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class CompactPassabilityMaskTests + { + [Fact] + public void Bitmask_get_set_and_bounds() + { + var mask = new CompactPassabilityMask(64, 64); + + // Default is false (0) + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(10, 10)); + + // Set some bits + mask.SetWalkable(0, 0, true); + mask.SetWalkable(15, 20, true); + mask.SetWalkable(31, 31, true); + mask.SetWalkable(32, 31, true); // cross 32-bit boundary + mask.SetWalkable(63, 63, true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(31, 31)); + Assert.True(mask.IsWalkable(32, 31)); + Assert.True(mask.IsWalkable(63, 63)); + + // Unset a bit + mask.SetWalkable(15, 20, false); + Assert.False(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(0, 0)); + + // Out of bounds + Assert.False(mask.IsWalkable(-1, 0)); + Assert.False(mask.IsWalkable(0, -1)); + Assert.False(mask.IsWalkable(64, 0)); + Assert.False(mask.IsWalkable(0, 64)); + } + + [Fact] + public void SetAll_fills_entire_grid() + { + var mask = new CompactPassabilityMask(100, 100); + mask.SetAll(true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(50, 50)); + Assert.True(mask.IsWalkable(99, 99)); + + mask.SetAll(false); + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(50, 50)); + Assert.False(mask.IsWalkable(99, 99)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs new file mode 100644 index 00000000..e80c2d90 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs @@ -0,0 +1,264 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class HeightfieldMetadataTests + { + [Fact] + public void Chunk_bounds_and_ray_above_chunk_queries() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + + // Fill a chunk (0,0 to 15,15) with height 10.0 (raw: 10 * 32 = 320) + for (int y = 0; y < 16; y++) + { + for (int x = 0; x < 16; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + // Fill another chunk (16,0 to 31,15) with height 50.0 (raw: 50 * 32 = 1600) + for (int y = 0; y < 16; y++) + { + for (int x = 16; x < 32; x++) + { + altLayer[x, y] = (ushort)(50 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + + Assert.Equal(4, metadata.ChunksX); + Assert.Equal(4, metadata.ChunksY); + + // Chunk (0,0) has max height 10.0 + metadata.GetChunkBounds(0, 0, out float min0, out float max0); + Assert.Equal(10.0f, min0); + Assert.Equal(10.0f, max0); + + // Chunk (1,0) has max height 50.0 + metadata.GetChunkBounds(1, 0, out float min1, out float max1); + Assert.Equal(50.0f, min1); + Assert.Equal(50.0f, max1); + + // Ray at Z=20 is strictly above chunk (0,0), but NOT above chunk (1,0) + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 20.0f)); + Assert.False(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 20.0f)); + + // Ray at Z=60 is above both + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 60.0f)); + Assert.True(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 60.0f)); + } + + [Fact] + public void Coordinates_mapping() + { + var metadata = new HeightfieldMetadata(2048, 2048, chunkSize: 16); + Assert.Equal(128, metadata.ChunksX); + Assert.Equal(128, metadata.ChunksY); + + metadata.GetChunkCoordinates(0, 0, out int cx0, out int cy0); + Assert.Equal(0, cx0); + Assert.Equal(0, cy0); + + metadata.GetChunkCoordinates(15, 15, out int cx1, out int cy1); + Assert.Equal(0, cx1); + Assert.Equal(0, cy1); + + metadata.GetChunkCoordinates(16, 32, out int cx2, out int cy2); + Assert.Equal(1, cx2); + Assert.Equal(2, cy2); + + metadata.GetChunkCoordinates(2047, 2047, out int cx3, out int cy3); + Assert.Equal(127, cx3); + Assert.Equal(127, cy3); + } + + [Fact] + public void Mark_dirty_tile_flags_only_its_chunk_and_deduplicates() + { + var metadata = new HeightfieldMetadata(64, 64, chunkSize: 16); + + Assert.False(metadata.HasDirtyChunks); + + metadata.MarkDirtyTile(5, 5); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Same chunk again: no double count. + metadata.MarkDirtyTile(15, 15); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Different chunk. + metadata.MarkDirtyTile(20, 20); + Assert.Equal(2, metadata.DirtyChunkCount); + + // Out of bounds: ignored. + metadata.MarkDirtyTile(-1, 0); + metadata.MarkDirtyTile(64, 0); + metadata.MarkDirtyTile(0, -5); + metadata.MarkDirtyTile(0, 100); + Assert.Equal(2, metadata.DirtyChunkCount); + } + + [Fact] + public void Mark_dirty_area_covers_exactly_the_intersecting_chunks() + { + var metadata = new HeightfieldMetadata(64, 64, chunkSize: 16); + + // Area spanning chunks (0,0), (1,0), (0,1), (1,1) and clipping chunk (2,0)/(0,2). + metadata.MarkDirtyArea(new Area(10, 10, 37, 37)); + Assert.Equal(9, metadata.DirtyChunkCount); + + // Fully outside: ignored. + metadata.MarkDirtyArea(new Area(100, 100, 110, 110)); + metadata.MarkDirtyArea(new Area(-50, -50, -10, -10)); + Assert.Equal(9, metadata.DirtyChunkCount); + + // Negative area that still intersects the map: clamped, no out-of-range chunks. + var clamped = new HeightfieldMetadata(64, 64, chunkSize: 16); + clamped.MarkDirtyArea(new Area(-30, -30, 5, 5)); + Assert.Equal(1, clamped.DirtyChunkCount); + + // Fully outside (all negative): ignored. + clamped.MarkDirtyArea(new Area(-30, -30, -1, -1)); + Assert.Equal(1, clamped.DirtyChunkCount); + + // Area beyond the edge: clamped to the last chunk row/column. + var edge = new HeightfieldMetadata(64, 64, chunkSize: 16); + edge.MarkDirtyArea(new Area(60, 60, 200, 200)); + Assert.Equal(1, edge.DirtyChunkCount); + edge.GetChunkBounds(3, 3, out float min, out float max); + Assert.Equal(0f, min); + } + + [Fact] + public void Recompute_dirty_rebakes_only_flagged_chunks_and_clears_the_flags() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + Assert.False(metadata.HasDirtyChunks); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + + // Raise one tile in chunk (0,0) and mark it. + altLayer[5, 5] = (ushort)(100 * 32); + metadata.MarkDirtyTile(5, 5); + Assert.Equal(1, metadata.DirtyChunkCount); + + // Until the re-bake the chunk bound is stale. + metadata.GetChunkBounds(0, 0, out _, out float staleMax); + Assert.Equal(10f, staleMax); + + int rebaked = metadata.RecomputeDirty(altLayer); + Assert.Equal(1, rebaked); + Assert.False(metadata.HasDirtyChunks); + + metadata.GetChunkBounds(0, 0, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(100f, max); + + // Untouched chunks keep their bounds and nothing is re-baked on a second drain. + metadata.GetChunkBounds(1, 0, out _, out float otherMax); + Assert.Equal(10f, otherMax); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + } + + [Fact] + public void Plant_blocking_height_is_picked_up_by_dirty_recompute() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var blockLayer = new Layer(LayerType.Blocks, 64, 64); + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, blockLayer, chunkSize: 16); + + metadata.GetChunkBounds(2, 2, out _, out float before); + Assert.Equal(10f, before); + + // A plant grows on tile (40, 40) (chunk (2,2)) with blocking height 15, the way + // NatureCube commits it: a single SetArea over the scanned cube. + blockLayer[40, 40] = new BlockingInfo(BlockingFlags.Plant, 15); + metadata.MarkDirtyArea(new Area(32, 32, 47, 47)); + Assert.Equal(1, metadata.DirtyChunkCount); + + metadata.RecomputeDirty(altLayer, blockLayer); + + metadata.GetChunkBounds(2, 2, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(25f, max); + + // The plant wilts: the same area is re-scanned with no blocking height. + blockLayer[40, 40] = BlockingInfo.None; + metadata.MarkDirtyArea(new Area(32, 32, 47, 47)); + metadata.RecomputeDirty(altLayer, blockLayer); + + metadata.GetChunkBounds(2, 2, out _, out float after); + Assert.Equal(10f, after); + } + + [Fact] + public void Global_max_height_tracks_full_and_incremental_rebakes() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + Assert.Equal(10f, metadata.GlobalMaxHeight); + + // Raise one tile, re-bake incrementally: the global max follows it up... + altLayer[5, 5] = (ushort)(100 * 32); + metadata.MarkDirtyTile(5, 5); + metadata.RecomputeDirty(altLayer); + Assert.Equal(100f, metadata.GlobalMaxHeight); + + // ...and back down when the tile is levelled again. + altLayer[5, 5] = (ushort)(10 * 32); + metadata.MarkDirtyTile(5, 5); + metadata.RecomputeDirty(altLayer); + Assert.Equal(10f, metadata.GlobalMaxHeight); + } + + [Fact] + public void Recompute_all_clears_dirty_flags() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + + metadata.MarkDirtyTile(0, 0); + metadata.MarkDirtyTile(63, 63); + Assert.Equal(2, metadata.DirtyChunkCount); + + metadata.RecomputeAll(altLayer); + + Assert.False(metadata.HasDirtyChunks); + Assert.Equal(0, metadata.DirtyChunkCount); + Assert.Equal(0, metadata.RecomputeDirty(altLayer)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/SimdMathTests.cs b/src/Perpetuum.Tests/Unit/SimdMathTests.cs new file mode 100644 index 00000000..5ebad70d --- /dev/null +++ b/src/Perpetuum.Tests/Unit/SimdMathTests.cs @@ -0,0 +1,170 @@ +using Perpetuum.Simd; +using System; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class SimdMathTests + { + [Fact] + public void CalculateSquaredDistances2D_matches_scalar_math() + { + int count = 67; // Non-multiple of 16, 8, and 4 + float srcX = 150.5f; + float srcY = 200.25f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(42); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 1000.0); + targetYs[i] = (float)(rnd.NextDouble() * 1000.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedDistSq[i] = (dx * dx) + (dy * dy); + } + + SimdMath.CalculateSquaredDistances2D(srcX, srcY, targetXs, targetYs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void CalculateSquaredDistances3D_matches_scalar_with_z_scaling() + { + int count = 45; + float srcX = 50.0f; + float srcY = 75.0f; + float srcZ = 120.0f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(123); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 500.0); + targetYs[i] = (float)(rnd.NextDouble() * 500.0); + targetZs[i] = (float)(rnd.NextDouble() * 200.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + + SimdMath.CalculateSquaredDistances3D(srcX, srcY, srcZ, targetXs, targetYs, targetZs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void FilterPointsInRange2D_filters_correctly() + { + int count = 50; + float srcX = 100.0f; + float srcY = 100.0f; + float range = 25.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(999); + for (int i = 0; i < count; i++) + { + // Place some points inside range (e.g. within 25) and some outside + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 60.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 60.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + + SimdMath.FilterPointsInRange2D(srcX, srcY, range, targetXs, targetYs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void FilterPositionsInRange3D_filters_correctly() + { + int count = 64; + float srcX = 200.0f; + float srcY = 200.0f; + float srcZ = 50.0f; + float range = 30.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(777); + for (int i = 0; i < count; i++) + { + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetZs[i] = srcZ + (float)((rnd.NextDouble() - 0.5) * 100.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + + SimdMath.FilterPositionsInRange3D(srcX, srcY, srcZ, range, targetXs, targetYs, targetZs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void EdgeCases_empty_and_small_arrays() + { + // Empty + SimdMath.CalculateSquaredDistances2D(0, 0, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + SimdMath.FilterPointsInRange2D(0, 0, 10, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + + // 1 element + float[] x1 = [10.0f]; + float[] y1 = [20.0f]; + float[] d1 = new float[1]; + SimdMath.CalculateSquaredDistances2D(0, 0, x1, y1, d1); + Assert.Equal(500.0f, d1[0]); + + // 3 elements (below Vector128 width of 4) + float[] x3 = [1.0f, 2.0f, 3.0f]; + float[] y3 = [0.0f, 0.0f, 0.0f]; + float[] d3 = new float[3]; + SimdMath.CalculateSquaredDistances2D(0, 0, x3, y3, d3); + Assert.Equal(1.0f, d3[0]); + Assert.Equal(4.0f, d3[1]); + Assert.Equal(9.0f, d3[2]); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs b/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs new file mode 100644 index 00000000..9f42d545 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ZoneHeightfieldTests.cs @@ -0,0 +1,185 @@ +using NSubstitute; +using Perpetuum.EntityFramework; +using Perpetuum.Groups.Gangs; +using Perpetuum.Services.Sessions; +using Perpetuum.Units; +using Perpetuum.Zones; +using Perpetuum.Zones.Effects.ZoneEffects; +using Perpetuum.Zones.Terrains; +using System; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + /// + /// Pins the production wiring of HeightfieldMetadata: the Zone bakes the metadata when the + /// Terrain is assigned, terrain mutations funnelled through TerrainUpdateMonitor mark chunks + /// dirty, the zone tick re-bakes them, and the LOS coarse pre-check stays conservative + /// (never reports "no hit" for a ray the per-tile loop would have blocked). + /// + public class ZoneHeightfieldTests + { + private const int MapSize = 64; + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(50); + + [Fact] + public void Assigning_the_terrain_bakes_the_heightfield() + { + TestZone zone = CreateZone(out _, out _); + + HeightfieldMetadata heightfield = zone.Heightfield; + Assert.NotNull(heightfield); + Assert.False(heightfield.HasDirtyChunks); + Assert.Equal(10f, heightfield.GlobalMaxHeight); + + heightfield.GetChunkBounds(0, 0, out float min, out float max); + Assert.Equal(10f, min); + Assert.Equal(10f, max); + } + + [Fact] + public void Plant_growth_marks_the_chunk_dirty_and_the_zone_tick_rebakes_it() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + // The way NatureCube commits a grown plant: a layer write observed by the monitor. + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + HeightfieldMetadata heightfield = zone.Heightfield; + Assert.True(heightfield.HasDirtyChunks); + Assert.Equal(1, heightfield.DirtyChunkCount); + + // Until the re-bake the chunk bound is stale and must not be trusted. + heightfield.GetChunkBounds(2, 2, out _, out float staleMax); + Assert.Equal(10f, staleMax); + + zone.Update(Tick); + + Assert.False(heightfield.HasDirtyChunks); + heightfield.GetChunkBounds(2, 2, out _, out float max); + Assert.Equal(25f, max); + Assert.Equal(25f, heightfield.GlobalMaxHeight); + } + + [Fact] + public void Los_ray_flying_above_the_highest_terrain_returns_none() + { + TestZone zone = CreateZoneWithGrownPlant(); + + // Flies at Z = 50, well above the rebaked max of 25 (+ smoothing margin). + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 49)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 49), false); + + Assert.False(result.hit); + } + + [Fact] + public void Los_ray_at_plant_height_still_hits_after_the_rebake() + { + // Guards the coarse pre-check against over-optimism: the ray at Z = 20 is below the + // grown plant's blocking height (25) and must be reported as a hit by the per-tile + // loop even though most crossed chunks are far lower. + TestZone zone = CreateZoneWithGrownPlant(); + + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 19)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 19), false); + + Assert.True(result.hit); + Assert.True(result.blockingFlags.HasFlag(BlockingFlags.Plant)); + } + + [Fact] + public void Los_coarse_precheck_is_skipped_while_chunks_are_dirty() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + // No zone.Update: chunk (2,2) is still dirty and the global max is still the pre-growth + // value, so the pre-check must bail out and the exact per-tile loop must run. + Perpetuum.Units.Unit shooter = CreateShooter(new Position(10, 33, 49)); + + LOSResult result = zone.IsInLineOfSight(shooter, new Position(55, 33, 49), false); + + Assert.False(result.hit); + } + + private static TestZone CreateZoneWithGrownPlant() + { + TestZone zone = CreateZone(out _, out Layer blocks); + + using (new TerrainUpdateMonitor(zone)) + { + blocks[33, 33] = new BlockingInfo(BlockingFlags.Plant, 15); + } + + zone.Update(Tick); // re-bakes the dirty chunk + return zone; + } + + private static TestZone CreateZone(out AltitudeLayer altitude, out Layer blocks) + { + altitude = new AltitudeLayer(new ushort[MapSize * MapSize], MapSize, MapSize); + for (int y = 0; y < MapSize; y++) + { + for (int x = 0; x < MapSize; x++) + { + altitude[x, y] = (ushort)(10 * 32); // flat at 10 + } + } + + blocks = new Layer(LayerType.Blocks, MapSize, MapSize); + + Terrain terrain = new Terrain + { + Altitude = altitude, + Blocks = blocks, + Slope = new SlopeLayer(altitude) + }; + + TestZone zone = new TestZone(); + zone.Configuration = ZoneConfiguration.None; + zone.MiningLogHandler = new MiningLogHandler(zone); + zone.HarvestLogHandler = new HarvestLogHandler(zone); + zone.ZoneEffectHandler = Substitute.For(); + zone.Terrain = terrain; + + return zone; + } + + private static Perpetuum.Units.Unit CreateShooter(Position position) + { + // A bare unit: PositionWithHeight is CurrentPosition + Height, and the default height + // of an entity without components is 1.0 (ComputeHeight + 1), so the given Z is +1. + var unit = new ShooterUnit { Eid = 1 }; + unit.ED = EntityDefault.None; + unit.CurrentPosition = position; + return unit; + } + + private sealed class TestZone : Zone + { + public TestZone() + : base(Substitute.For(), Substitute.For()) + { + } + } + + private sealed class ShooterUnit : Perpetuum.Units.Unit + { + protected override void OnUpdate(TimeSpan time) + { + } + + public override string InfoString => "shooter-unit"; + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs new file mode 100644 index 00000000..174f04a4 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs @@ -0,0 +1,110 @@ +using NSubstitute; +using Perpetuum.Groups.Gangs; +using Perpetuum.Services.Sessions; +using Perpetuum.Units; +using Perpetuum.Zones; +using Perpetuum.Zones.Effects.ZoneEffects; +using Perpetuum.Zones.Terrains; +using System; +using System.Collections.Generic; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + /// + /// Covers the idle throttle in Zone.Update: when no players are in the zone, units are + /// updated once per idle second and must receive the accumulated elapsed time, otherwise + /// every elapsed/timer-driven system inside a unit (cooldowns, movement, recharge, AI) + /// runs at a fraction of real speed. + /// + public class ZoneIdleThrottlingTests + { + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(200); + + [Fact] + public void Idle_zone_updates_units_once_per_idle_second_with_accumulated_elapsed_time() + { + TestZone zone = CreateZone(); + RecordingUnit unit = new RecordingUnit { Eid = 1 }; + unit.AddToZone(zone, new Position(0, 0, 0)); + + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Single(unit.UpdatedTimes); + Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[0]); + + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Equal(2, unit.UpdatedTimes.Count); + Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[1]); + } + + [Fact] + public void Idle_zone_simulates_real_time_for_units() + { + // Regression: units used to receive the raw tick (~200ms) once per second, + // so an idle zone simulated at ~20% of real speed. + TestZone zone = CreateZone(); + RecordingUnit unit = new RecordingUnit { Eid = 1 }; + unit.AddToZone(zone, new Position(0, 0, 0)); + + for (int i = 0; i < 10; i++) + { + zone.Update(Tick); + } + + TimeSpan total = TimeSpan.Zero; + foreach (TimeSpan t in unit.UpdatedTimes) + { + total += t; + } + + Assert.Equal(2, unit.UpdatedTimes.Count); + Assert.Equal(TimeSpan.FromSeconds(2), total); + } + + private static TestZone CreateZone() + { + TestZone zone = new TestZone(); + zone.Configuration = ZoneConfiguration.None; + zone.MiningLogHandler = new MiningLogHandler(zone); + zone.HarvestLogHandler = new HarvestLogHandler(zone); + zone.ZoneEffectHandler = Substitute.For(); + + // AddToZone -> FixZ reads the terrain altitude + ITerrain terrain = Substitute.For(); + terrain.Altitude.Returns(new AltitudeLayer(new ushort[64 * 64], 64, 64)); + zone.Terrain = terrain; + + return zone; + } + + private sealed class TestZone : Zone + { + public TestZone() + : base(Substitute.For(), Substitute.For()) + { + } + } + + private sealed class RecordingUnit : Perpetuum.Units.Unit + { + public List UpdatedTimes { get; } = new List(); + + protected override void OnUpdate(TimeSpan time) + { + // Deliberately does not call base: the recording unit is not a real + // game unit and must not run recharge/effects/broadcast machinery. + UpdatedTimes.Add(time); + } + + public override string InfoString => "recording-unit"; + } + } +} diff --git a/src/Perpetuum/Simd/SimdMath.cs b/src/Perpetuum/Simd/SimdMath.cs new file mode 100644 index 00000000..756ec45d --- /dev/null +++ b/src/Perpetuum/Simd/SimdMath.cs @@ -0,0 +1,375 @@ +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + +namespace Perpetuum.Simd +{ + /// + /// SIMD-accelerated math operations with AVX-512, AVX2, SSE2 and scalar fallbacks for .NET 8. + /// + public static class SimdMath + { + public static bool IsAvx512Supported => Vector512.IsHardwareAccelerated && Avx512F.IsSupported; + public static bool IsAvx2Supported => Vector256.IsHardwareAccelerated && Avx2.IsSupported; + public static bool IsVector128Supported => Vector128.IsHardwareAccelerated; + + /// + /// Calculates squared 2D distances from a source point (srcX, srcY) to an array of target points. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances2D( + float srcX, float srcY, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, destinationDistSq.Length)); + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + destinationDistSq[i] = (dx * dx) + (dy * dy); + } + } + + /// + /// Calculates squared 3D distances with Perpetuum Z-scaling (dz = (targetZ - srcZ) / 4.0). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances3D( + float srcX, float srcY, float srcZ, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, destinationDistSq.Length))); + int i = 0; + + const float zScale = 0.25f; // 1.0 / 4.0 + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + destinationDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + } + + /// + /// Filters target points that are within a specified 2D range from (srcX, srcY). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPointsInRange2D( + float srcX, float srcY, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, inRangeResults.Length)); + float rangeSq = range * range; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + inRangeResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + } + + /// + /// Filters target points that are within a specified 3D range with Perpetuum Z-scaling. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPositionsInRange3D( + float srcX, float srcY, float srcZ, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, inRangeResults.Length))); + float rangeSq = range * range; + const float zScale = 0.25f; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + inRangeResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + } + } +} diff --git a/src/Perpetuum/Zones/IZone.cs b/src/Perpetuum/Zones/IZone.cs index f3a0cd6e..0f4ef4f9 100644 --- a/src/Perpetuum/Zones/IZone.cs +++ b/src/Perpetuum/Zones/IZone.cs @@ -42,6 +42,13 @@ public interface IZone ITerrain Terrain { get; } + /// + /// Chunk bounding metadata over the terrain altitude and blocking layers, baked when the + /// terrain is assigned and kept fresh by the zone update loop. Null when the zone has no + /// terrain; consumers must treat dirty chunks as untrusted (see HeightfieldMetadata). + /// + [CanBeNull] + HeightfieldMetadata Heightfield { get; } CorporationHandler CorporationHandler { get; } IPlantHandler PlantHandler { get; } IBeamService Beams { get; } diff --git a/src/Perpetuum/Zones/LineOfSight.cs b/src/Perpetuum/Zones/LineOfSight.cs index d0131bc2..4750034d 100644 --- a/src/Perpetuum/Zones/LineOfSight.cs +++ b/src/Perpetuum/Zones/LineOfSight.cs @@ -112,6 +112,11 @@ private static LOSResult IsInLineOfSight(IZone zone, Vector3 origin, Vector3 dir return losResult; } + if (RayPassesAboveAllCrossedChunks(zone, origin, direction, distance)) + { + return LOSResult.None; + } + var lastAltitude = zone.Terrain.Altitude.GetAltitudeAsDouble(origin) + 2; var lx = (int) origin.X; @@ -165,6 +170,80 @@ private static LOSResult IsInLineOfSight(IZone zone, Vector3 origin, Vector3 dir return LOSResult.None; } + /// + /// Coarse pre-check: walks the chunk grid the ray crosses (Amanatides & Woo) and reports + /// whether every crossed chunk is provably below the ray, so the per-tile loop below can be + /// skipped entirely. The per-tile check can raise a tile's effective blocking height above + /// its raw (altitude + block) by at most 0.1 * (highest terrain in the zone + 2) through + /// neighbor-tile smoothing (see GetAltitude), so a chunk is only trusted when the ray's + /// lowest point clears its max height by that margin. Ballistic arcs only add height, so + /// the linear min Z bounds the whole ray. Skipped entirely while any chunk is dirty, when + /// the bounds may be stale in either direction. + /// + private static bool RayPassesAboveAllCrossedChunks(IZone zone, Vector3 origin, Vector3 direction, float distance) + { + var heightfield = zone.Heightfield; + if (heightfield == null || heightfield.HasDirtyChunks) + { + return false; + } + + float endZ = origin.Z + (float)(direction.Z * distance); + float minZ = Math.Min(origin.Z, endZ); + float clearance = minZ - (float)(0.1 * (heightfield.GlobalMaxHeight + 2.0)); + + int stepX = direction.X >= 0 ? 1 : -1; + int stepY = direction.Y >= 0 ? 1 : -1; + int size = heightfield.ChunkSize; + + int chunkX = (int) (origin.X / size); + int chunkY = (int) (origin.Y / size); + float tMaxX = NextChunkBoundary(origin.X, direction.X, chunkX, size, stepX); + float tMaxY = NextChunkBoundary(origin.Y, direction.Y, chunkY, size, stepY); + float tDeltaX = direction.X != 0 ? size / Math.Abs(direction.X) : float.PositiveInfinity; + float tDeltaY = direction.Y != 0 ? size / Math.Abs(direction.Y) : float.PositiveInfinity; + + while (true) + { + if (!heightfield.CanRayPassAboveChunk(chunkX, chunkY, clearance)) + { + return false; + } + + if (tMaxX < tMaxY) + { + chunkX += stepX; + if (tMaxX > distance) + { + return true; + } + + tMaxX += tDeltaX; + } + else + { + chunkY += stepY; + if (tMaxY > distance) + { + return true; + } + + tMaxY += tDeltaY; + } + } + } + + private static float NextChunkBoundary(float coordinate, float direction, int chunk, int size, int step) + { + if (direction == 0) + { + return float.PositiveInfinity; + } + + float boundary = step > 0 ? (chunk + 1) * (float) size - coordinate : chunk * (float) size - coordinate; + return boundary / direction; + } + private static double GetAltitude(IZone zone,Vector3 position,ref double lastAltitude) { var altitude = zone.Terrain.Altitude.GetAltitudeAsDouble(position); diff --git a/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs index 8c7be994..dce8c825 100644 --- a/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs +++ b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using Perpetuum.Timers; using Perpetuum.Units; using Perpetuum.Zones.NpcSystem.Presences; diff --git a/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs new file mode 100644 index 00000000..e9bdc537 --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// A high-performance 1-bit per tile passability bitmask. + /// Provides cache-efficient (512 KB per 2048x2048 zone) walkability queries. + /// + public class CompactPassabilityMask + { + public int Width { get; } + public int Height { get; } + + private readonly uint[] _bits; + + public CompactPassabilityMask(int width, int height) + { + Width = width; + Height = height; + int totalBits = width * height; + _bits = new uint[(totalBits + 31) / 32]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsWalkable(int x, int y) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return false; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + return (_bits[arrayIndex] & (1u << bitOffset)) != 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetWalkable(int x, int y, bool walkable) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + if (walkable) + { + _bits[arrayIndex] |= (1u << bitOffset); + } + else + { + _bits[arrayIndex] &= ~(1u << bitOffset); + } + } + + public void SetAll(bool walkable) + { + uint value = walkable ? uint.MaxValue : 0u; + Array.Fill(_bits, value); + } + + public static CompactPassabilityMask ExtractFrom(ILayer blockingLayer, SlopeLayer slopeLayer, double slopeThreshold = 4.0) + { + var mask = new CompactPassabilityMask(blockingLayer.Width, blockingLayer.Height); + for (int y = 0; y < blockingLayer.Height; y++) + { + for (int x = 0; x < blockingLayer.Width; x++) + { + bool blocked = blockingLayer.GetValue(x, y).Height > 0; + bool slopeOk = slopeLayer.CheckSlope(x, y, slopeThreshold); + mask.SetWalkable(x, y, !blocked && slopeOk); + } + } + return mask; + } + } +} diff --git a/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs new file mode 100644 index 00000000..e0f43c9d --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// Pre-extracted hierarchical chunk bounding metadata from terrain layers (altitude and blocking) + /// to accelerate spatial queries, Line-of-Sight (LOS) raycasting, and obstacle checks. + /// + /// Production wiring: the Zone bakes the metadata from the terrain when the Terrain is assigned + /// and exposes it as IZone.Heightfield. TerrainUpdateMonitor marks chunks dirty as the altitude + /// or blocking layers mutate (plant growth, terraforming, PBS construction, environment + /// placement), and Zone.Update drains the dirty set via RecomputeDirty before the unit update. + /// + /// Chunk bounds are incremental: mutations to the altitude or blocking layers mark the affected + /// chunks dirty via MarkDirtyTile/MarkDirtyArea, and RecomputeDirty re-bakes only those chunks. + /// The compact per-chunk min/max arrays are never rebuilt in full except via RecomputeAll. + /// + /// Threading: marking and re-baking run on the zone tick thread; consumers (LineOfSight) read + /// the bounds lock-free from any thread, the same benign-race assumption the terrain layers + /// themselves already live under. While HasDirtyChunks is true, bounds may be stale in either + /// direction and consumers must not trust them for early-outs. + /// + public class HeightfieldMetadata + { + public const int DefaultChunkSize = 16; + + public int Width { get; } + public int Height { get; } + public int ChunkSize { get; } + public int ChunksX { get; } + public int ChunksY { get; } + + private readonly float[] _minHeights; + private readonly float[] _maxHeights; + + private readonly BitArray _dirtyChunks; + private int _dirtyCount; + + /// + /// Highest (altitude + blocking) height over the whole map, in altitude units. + /// Maintained by RecomputeAll and RecomputeDirty; 0 for a freshly constructed instance. + /// + public float GlobalMaxHeight { get; private set; } + + public HeightfieldMetadata(int width, int height, int chunkSize = DefaultChunkSize) + { + Width = width; + Height = height; + ChunkSize = Math.Max(1, chunkSize); + ChunksX = (width + ChunkSize - 1) / ChunkSize; + ChunksY = (height + ChunkSize - 1) / ChunkSize; + + _minHeights = new float[ChunksX * ChunksY]; + _maxHeights = new float[ChunksX * ChunksY]; + _dirtyChunks = new BitArray(ChunksX * ChunksY); + } + + public bool HasDirtyChunks => _dirtyCount > 0; + + public int DirtyChunkCount => _dirtyCount; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void MarkDirtyTile(int tileX, int tileY) + { + if (tileX < 0 || tileX >= Width || tileY < 0 || tileY >= Height) + return; + + MarkDirtyChunk(tileX / ChunkSize, tileY / ChunkSize); + } + + public void MarkDirtyArea(Area area) + { + if (area.X2 < 0 || area.Y2 < 0 || area.X1 >= Width || area.Y1 >= Height) + return; + + int x2 = Math.Min(area.X2, Width - 1); + int y2 = Math.Min(area.Y2, Height - 1); + + int cy1 = Math.Max(0, area.Y1 / ChunkSize); + int cx1 = Math.Max(0, area.X1 / ChunkSize); + + for (int cy = cy1; cy <= y2 / ChunkSize; cy++) + { + for (int cx = cx1; cx <= x2 / ChunkSize; cx++) + { + MarkDirtyChunk(cx, cy); + } + } + } + + private void MarkDirtyChunk(int chunkX, int chunkY) + { + int idx = GetChunkIndex(chunkX, chunkY); + if (!_dirtyChunks[idx]) + { + _dirtyChunks[idx] = true; + _dirtyCount++; + } + } + + /// + /// Re-bakes every dirty chunk and clears the dirty flags. Returns the number of chunks + /// re-baked, so callers can skip the scan entirely when nothing is dirty. + /// + public int RecomputeDirty(AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + if (_dirtyCount == 0) + return 0; + + int recomputed = 0; + for (int cy = 0; cy < ChunksY; cy++) + { + for (int cx = 0; cx < ChunksX; cx++) + { + int idx = GetChunkIndex(cx, cy); + if (!_dirtyChunks[idx]) + continue; + + RecomputeChunk(cx, cy, altitudeLayer, blockingLayer); + _dirtyChunks[idx] = false; + _dirtyCount--; + recomputed++; + } + } + + GlobalMaxHeight = ComputeGlobalMax(); + + return recomputed; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetChunkIndex(int chunkX, int chunkY) => (chunkY * ChunksX) + chunkX; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkCoordinates(int tileX, int tileY, out int chunkX, out int chunkY) + { + chunkX = Math.Clamp(tileX / ChunkSize, 0, ChunksX - 1); + chunkY = Math.Clamp(tileY / ChunkSize, 0, ChunksY - 1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkBounds(int chunkX, int chunkY, out float minH, out float maxH) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + { + minH = float.MinValue; + maxH = float.MaxValue; + return; + } + + int idx = GetChunkIndex(chunkX, chunkY); + minH = _minHeights[idx]; + maxH = _maxHeights[idx]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanRayPassAboveChunk(int chunkX, int chunkY, float rayMinZ) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + return false; + + int idx = GetChunkIndex(chunkX, chunkY); + return rayMinZ > _maxHeights[idx]; + } + + /// + /// Extracts and bakes chunk min/max metadata from an AltitudeLayer and optional Blocking Layer. + /// + public static HeightfieldMetadata ExtractFrom(AltitudeLayer altitudeLayer, ILayer blockingLayer = null, int chunkSize = DefaultChunkSize) + { + var metadata = new HeightfieldMetadata(altitudeLayer.Width, altitudeLayer.Height, chunkSize); + metadata.RecomputeAll(altitudeLayer, blockingLayer); + return metadata; + } + + public void RecomputeAll(AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + for (int cy = 0; cy < ChunksY; cy++) + { + for (int cx = 0; cx < ChunksX; cx++) + { + RecomputeChunk(cx, cy, altitudeLayer, blockingLayer); + } + } + + _dirtyChunks.SetAll(false); + _dirtyCount = 0; + GlobalMaxHeight = ComputeGlobalMax(); + } + + private float ComputeGlobalMax() + { + float max = 0; + foreach (float h in _maxHeights) + { + if (h > max) + { + max = h; + } + } + + return max; + } + + public void RecomputeChunk(int chunkX, int chunkY, AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + int startX = chunkX * ChunkSize; + int startY = chunkY * ChunkSize; + int endX = Math.Min(startX + ChunkSize, Width); + int endY = Math.Min(startY + ChunkSize, Height); + + float min = float.MaxValue; + float max = float.MinValue; + + for (int y = startY; y < endY; y++) + { + for (int x = startX; x < endX; x++) + { + float alt = (float)altitudeLayer.GetAltitudeAsDouble(x, y); + float blockHeight = 0; + if (blockingLayer != null) + { + blockHeight = blockingLayer.GetValue(x, y).Height; + } + + float totalHeight = alt + blockHeight; + if (totalHeight < min) min = totalHeight; + if (totalHeight > max) max = totalHeight; + } + } + + if (min > max) + { + min = 0; + max = 0; + } + + int idx = GetChunkIndex(chunkX, chunkY); + _minHeights[idx] = min; + _maxHeights[idx] = max; + } + } +} diff --git a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs index 1d8ce202..ed81ae37 100644 --- a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs +++ b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs @@ -171,12 +171,24 @@ private void Notify() private void OnAreaUpdated(LayerType layerType, Area area) { + // Altitude and blocking heights feed the zone's heightfield chunk bounds; any mutation + // to them (plant growth, terraforming, PBS construction) invalidates the affected chunks. + if (layerType == LayerType.Altitude || layerType == LayerType.Blocks) + { + _zone.Heightfield?.MarkDirtyArea(area); + } + var info = new AreaUpdateInfo(layerType, area); AddUpdateInfo(info); } private void OnTileUpdated(LayerType layerType, int x, int y) { + if (layerType == LayerType.Altitude || layerType == LayerType.Blocks) + { + _zone.Heightfield?.MarkDirtyTile(x, y); + } + var info = new TileUpdateInfo(layerType, new SKPointI(x,y)); AddUpdateInfo(info); } diff --git a/src/Perpetuum/Zones/Zone.cs b/src/Perpetuum/Zones/Zone.cs index 9cdc7474..a6760cbd 100644 --- a/src/Perpetuum/Zones/Zone.cs +++ b/src/Perpetuum/Zones/Zone.cs @@ -50,7 +50,25 @@ public abstract class Zone : Threading.Process.Process, IZone public ZoneConfiguration Configuration { get; set; } - public ITerrain Terrain { get; set; } + private ITerrain _terrain; + + public ITerrain Terrain + { + get => _terrain; + set + { + _terrain = value; + + // Baked once at assignment (the zone factory populates all layers before this runs); + // later mutations are tracked incrementally by TerrainUpdateMonitor and drained in Update. + Heightfield = value?.Altitude != null + ? HeightfieldMetadata.ExtractFrom(value.Altitude, value.Blocks) + : null; + } + } + + [CanBeNull] + public HeightfieldMetadata Heightfield { get; private set; } public CorporationHandler CorporationHandler { get; set; } public IPlantHandler PlantHandler { get; set; } public IBeamService Beams { get; set; } @@ -331,6 +349,7 @@ public Player GetPlayer(long eid) } private readonly ShiftedConsumerTimer _updateUnitsTimer = new ShiftedConsumerTimer(500); + private readonly IntervalTimer _idleUpdateTimer = new IntervalTimer(1000); private Action _updateProfiler; @@ -347,12 +366,35 @@ private void MeasureUpdate(TimeSpan time) public override void Update(TimeSpan time) { + UpdateHeightfield(); UpdatePlayerPresence(time); UpdateSessions(time); + // Throttle unit physics, AI, and visibility processing when no players are in the zone. + // Units only receive an update on the throttle tick, so they are handed the + // accumulated elapsed time: all unit systems are timer/elapsed driven + // (cooldowns, movement integration, recharge, AI cycles), so passing the full + // elapsed keeps them running at real speed. + TimeSpan unitTime = time; + if (_players.IsEmpty) + { + _idleUpdateTimer.Update(time); + if (!_idleUpdateTimer.Passed) + { + RiftManager?.Update(time); + RelicManager?.Update(time); + MiningLogHandler.Update(time); + HarvestLogHandler.Update(time); + return; + } + + unitTime = _idleUpdateTimer.Elapsed; + _idleUpdateTimer.Reset(); + } + _updateUnitsTimer.Update(time).IsPassed(ProcessUpdatedUnits); - UpdateUnits(time); + UpdateUnits(unitTime); RiftManager?.Update(time); RelicManager?.Update(time); @@ -361,6 +403,18 @@ public override void Update(TimeSpan time) MeasureUpdate(time); } + private void UpdateHeightfield() + { + HeightfieldMetadata heightfield = Heightfield; + ITerrain terrain = Terrain; + if (heightfield == null || terrain == null || terrain.Altitude == null || !heightfield.HasDirtyChunks) + { + return; + } + + heightfield.RecomputeDirty(terrain.Altitude, terrain.Blocks); + } + /// /// Checks for the presence of players on the field. /// From deb8092ca9e8d486bba65729697facf4e81c43eb Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 28 Sep 2026 14:48:15 -0400 Subject: [PATCH 4/5] Add zoneIdleThrottleSet admin command for the idle throttle Runtime on/off switch for the zone idle throttle so its CPU savings can be measured on a live server without a restart. - ZoneIdleThrottling: volatile on/off flag (default on), read on the zone tick thread, written by the command handler - Zone.Update: idle throttling applies only while the flag is set - zoneIdleThrottleSet: GameAdmin channel command (state 1 = on, 0 = off), registered next to the other channel commands, replies with the resulting state - test: throttle off updates units every tick at full tick time, re-enabling resumes once-per-idle-second with accumulated elapsed --- .../Modules/RequestHandlersModule.cs | 1 + .../AdminTools/ZoneIdleThrottleSet.cs | 22 +++++++++++ .../Unit/ZoneIdleThrottlingTests.cs | 37 +++++++++++++++++++ src/Perpetuum/Commands.cs | 11 ++++++ src/Perpetuum/Zones/Zone.cs | 5 ++- src/Perpetuum/Zones/ZoneIdleThrottling.cs | 19 ++++++++++ 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs create mode 100644 src/Perpetuum/Zones/ZoneIdleThrottling.cs diff --git a/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs b/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs index 0d559605..34fd1af4 100644 --- a/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs +++ b/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs @@ -230,6 +230,7 @@ protected override void Load(ContainerBuilder builder) _ = RegisterRequestHandler(builder, Commands.ExtensionResetCharacter); _ = RegisterRequestHandler(builder, Commands.ExtensionFreeLockedEp); _ = RegisterRequestHandler(builder, Commands.ExtensionFreeAllLockedEpCommand); // For GameAdmin Channel Command + _ = RegisterRequestHandler(builder, Commands.ZoneIdleThrottleSet); // For GameAdmin Channel Command _ = RegisterRequestHandler(builder, Commands.ExtensionGive); _ = RegisterRequestHandler(builder, Commands.ExtensionReset); _ = RegisterRequestHandler(builder, Commands.ExtensionRevert); diff --git a/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs b/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs new file mode 100644 index 00000000..6408cbef --- /dev/null +++ b/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs @@ -0,0 +1,22 @@ +using Perpetuum.Host.Requests; +using Perpetuum.Zones; + +namespace Perpetuum.RequestHandlers.AdminTools +{ + /// + /// GameAdmin channel command: switches the zone idle throttle on (state 1) or off + /// (state 0) on the running server, so its CPU savings can be measured without a restart. + /// + public class ZoneIdleThrottleSet : IRequestHandler + { + public void HandleRequest(IRequest request) + { + int state = request.Data.GetOrDefault(k.state, 1); + ZoneIdleThrottling.Enabled = state != 0; + + Message.Builder.FromRequest(request) + .SetData(k.state, ZoneIdleThrottling.Enabled ? 1 : 0) + .Send(); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs index 174f04a4..cbcb2ede 100644 --- a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs +++ b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs @@ -45,6 +45,43 @@ public void Idle_zone_updates_units_once_per_idle_second_with_accumulated_elapse Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[1]); } + [Fact] + public void Disabling_the_throttle_updates_units_every_tick_and_reenabling_restores_it() + { + TestZone zone = CreateZone(); + RecordingUnit unit = new RecordingUnit { Eid = 1 }; + unit.AddToZone(zone, new Position(0, 0, 0)); + + try + { + ZoneIdleThrottling.Enabled = false; + + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Equal(5, unit.UpdatedTimes.Count); + Assert.All(unit.UpdatedTimes, t => Assert.Equal(Tick, t)); + + ZoneIdleThrottling.Enabled = true; + + // The idle timer was not running while the throttle was off, so the first + // full idle second after re-enabling produces exactly one throttled update. + for (int i = 0; i < 5; i++) + { + zone.Update(Tick); + } + + Assert.Equal(6, unit.UpdatedTimes.Count); + Assert.Equal(TimeSpan.FromSeconds(1), unit.UpdatedTimes[5]); + } + finally + { + ZoneIdleThrottling.Enabled = true; + } + } + [Fact] public void Idle_zone_simulates_real_time_for_units() { diff --git a/src/Perpetuum/Commands.cs b/src/Perpetuum/Commands.cs index 3a2229f6..3351bc3e 100644 --- a/src/Perpetuum/Commands.cs +++ b/src/Perpetuum/Commands.cs @@ -3536,6 +3536,17 @@ public static Command GetCommandByText(string commandText) } }; + //GameAdmin Command — runtime on/off for the zone idle throttle (state: 1 = on, 0 = off) + public static readonly Command ZoneIdleThrottleSet = new() + { + Text = "zoneIdleThrottleSet", + AccessLevel = AccessLevel.gameAdmin, + Arguments = + { + new Argument(k.state) + } + }; + //GameAdmin Command public static readonly Command EPBonusSet = new() { diff --git a/src/Perpetuum/Zones/Zone.cs b/src/Perpetuum/Zones/Zone.cs index a6760cbd..6794a0a9 100644 --- a/src/Perpetuum/Zones/Zone.cs +++ b/src/Perpetuum/Zones/Zone.cs @@ -374,9 +374,10 @@ public override void Update(TimeSpan time) // Units only receive an update on the throttle tick, so they are handed the // accumulated elapsed time: all unit systems are timer/elapsed driven // (cooldowns, movement integration, recharge, AI cycles), so passing the full - // elapsed keeps them running at real speed. + // elapsed keeps them running at real speed. The throttle can be switched off at + // runtime (zoneIdleThrottleSet) to measure its CPU savings without a restart. TimeSpan unitTime = time; - if (_players.IsEmpty) + if (_players.IsEmpty && ZoneIdleThrottling.Enabled) { _idleUpdateTimer.Update(time); if (!_idleUpdateTimer.Passed) diff --git a/src/Perpetuum/Zones/ZoneIdleThrottling.cs b/src/Perpetuum/Zones/ZoneIdleThrottling.cs new file mode 100644 index 00000000..353a8b0f --- /dev/null +++ b/src/Perpetuum/Zones/ZoneIdleThrottling.cs @@ -0,0 +1,19 @@ +namespace Perpetuum.Zones +{ + /// + /// Runtime switch for the zone idle throttle (see Zone.Update). Read on the zone tick + /// thread and written by the zoneIdleThrottleSet admin command on a request thread, so a + /// plain volatile flag is the whole synchronization a cross-cutting on/off value needs — + /// no service lifetime, and no Zone constructor change to plumb it. + /// + public static class ZoneIdleThrottling + { + private static volatile bool _enabled = true; + + public static bool Enabled + { + get => _enabled; + set => _enabled = value; + } + } +} From 20198010897e179d5932cf975d23fecbb44cb094 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 28 Sep 2026 15:18:44 -0400 Subject: [PATCH 5/5] Wire the idle throttle as a #idlethrottle admin chat command The vanilla client reaches admin channel commands through the chat command router (messages starting with # in a secured channel), not as raw request commands. Replace the zoneIdleThrottleSet request command and handler with a [ChatCommand("IdleThrottle")] in AdminCommandHandlers, matching how the other channel commands work. - #idlethrottle[,true|false] in a #secure'd channel toggles ZoneIdleThrottling on the live server; replies with the new state - tests: command parsing/application and router gating (command does not run in an unsecured channel); both flag-mutating test classes moved into the serial PerpetuumStaticsCollection --- .../Modules/RequestHandlersModule.cs | 1 - .../AdminTools/ZoneIdleThrottleSet.cs | 22 --- .../Unit/IdleThrottleAdminCommandTests.cs | 133 ++++++++++++++++++ .../Unit/ZoneIdleThrottlingTests.cs | 5 +- src/Perpetuum/Commands.cs | 10 -- .../ChatCommands/AdminCommandHandlers.cs | 15 ++ 6 files changed, 152 insertions(+), 34 deletions(-) delete mode 100644 src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs create mode 100644 src/Perpetuum.Tests/Unit/IdleThrottleAdminCommandTests.cs diff --git a/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs b/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs index 34fd1af4..0d559605 100644 --- a/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs +++ b/src/Perpetuum.Bootstrapper/Modules/RequestHandlersModule.cs @@ -230,7 +230,6 @@ protected override void Load(ContainerBuilder builder) _ = RegisterRequestHandler(builder, Commands.ExtensionResetCharacter); _ = RegisterRequestHandler(builder, Commands.ExtensionFreeLockedEp); _ = RegisterRequestHandler(builder, Commands.ExtensionFreeAllLockedEpCommand); // For GameAdmin Channel Command - _ = RegisterRequestHandler(builder, Commands.ZoneIdleThrottleSet); // For GameAdmin Channel Command _ = RegisterRequestHandler(builder, Commands.ExtensionGive); _ = RegisterRequestHandler(builder, Commands.ExtensionReset); _ = RegisterRequestHandler(builder, Commands.ExtensionRevert); diff --git a/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs b/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs deleted file mode 100644 index 6408cbef..00000000 --- a/src/Perpetuum.RequestHandlers/AdminTools/ZoneIdleThrottleSet.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Perpetuum.Host.Requests; -using Perpetuum.Zones; - -namespace Perpetuum.RequestHandlers.AdminTools -{ - /// - /// GameAdmin channel command: switches the zone idle throttle on (state 1) or off - /// (state 0) on the running server, so its CPU savings can be measured without a restart. - /// - public class ZoneIdleThrottleSet : IRequestHandler - { - public void HandleRequest(IRequest request) - { - int state = request.Data.GetOrDefault(k.state, 1); - ZoneIdleThrottling.Enabled = state != 0; - - Message.Builder.FromRequest(request) - .SetData(k.state, ZoneIdleThrottling.Enabled ? 1 : 0) - .Send(); - } - } -} diff --git a/src/Perpetuum.Tests/Unit/IdleThrottleAdminCommandTests.cs b/src/Perpetuum.Tests/Unit/IdleThrottleAdminCommandTests.cs new file mode 100644 index 00000000..2a15848e --- /dev/null +++ b/src/Perpetuum.Tests/Unit/IdleThrottleAdminCommandTests.cs @@ -0,0 +1,133 @@ +using NSubstitute; +using Perpetuum; +using Perpetuum.Accounting; +using Perpetuum.Accounting.Characters; +using Perpetuum.Host.Requests; +using Perpetuum.Services.Channels; +using Perpetuum.Services.Channels.ChatCommands; +using Perpetuum.Services.Sessions; +using Perpetuum.Tests.Fakes.Data; +using Perpetuum.Tests.Infrastructure; +using Perpetuum.Zones; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + /// + /// Covers the #idlethrottle admin chat command path that the vanilla client uses: + /// argument parsing and flag application in AdminCommandHandlers.IdleThrottle, and the + /// router gating (admin sender, secured channel) that the flag change depends on. + /// The flag-to-zone effect is covered by ZoneIdleThrottlingTests. In the statics + /// collection because it mutates the process-wide ZoneIdleThrottling flag, shared with + /// ZoneIdleThrottlingTests. + /// + [Collection(PerpetuumStaticsCollection.Name)] + public class IdleThrottleAdminCommandTests + { + public IdleThrottleAdminCommandTests() + { + // The channel reply path builds a message; unit tests have no bootstrap. + Message.MessageBuilderFactory = () => new MessageBuilder(null, Substitute.For(), null); + } + + [Fact] + public void Idle_throttle_command_toggles_the_flag() + { + Character admin = CreateAdmin(); + + try + { + RunCommand(admin, "#idlethrottle,false"); + Assert.False(ZoneIdleThrottling.Enabled); + + RunCommand(admin, "#idlethrottle,true"); + Assert.True(ZoneIdleThrottling.Enabled); + + RunCommand(admin, "#idlethrottle"); + Assert.True(ZoneIdleThrottling.Enabled); + + ZoneIdleThrottling.Enabled = false; + RunCommand(admin, "#idlethrottle,notabool"); + Assert.False(ZoneIdleThrottling.Enabled, "an unparseable argument must leave the flag untouched"); + } + finally + { + ZoneIdleThrottling.Enabled = true; + } + } + + [Fact] + public void Router_runs_the_command_only_in_a_secured_channel() + { + Character admin = CreateAdmin(); + + FakeDb fakeDb = FakeDb.Install(); + fakeDb.WhenNonQuery("adminCommandLog", 1); + + ISessionManager sessionManager = Substitute.For(); + IChannelManager channelManager = Substitute.For(); + Channel channel = new Channel(ChannelType.Public, "idle-test", Substitute.For()); + AdminCommandRouter router = new(new GlobalConfiguration(), sessionManager); + + try + { + ZoneIdleThrottling.Enabled = true; + router.TryParseAdminCommand(admin, "#idlethrottle,false", Substitute.For(), channel, channelManager); + Assert.True(ZoneIdleThrottling.Enabled, "the command must not run in an unsecured channel"); + + channel.SetAdmin(true); // what #secure does + router.TryParseAdminCommand(admin, "#idlethrottle,false", Substitute.For(), channel, channelManager); + Assert.False(ZoneIdleThrottling.Enabled); + } + finally + { + ZoneIdleThrottling.Enabled = true; + } + } + + private static Character CreateAdmin() + { + // Character.None is referenced by the channel send path, which resolves it + // through the bootstrap-time factory that unit tests have to provide. + Character.CharacterFactory = _ => new Character(); + + IAccountManager accountManager = Substitute.For(); + IAccountRepository repository = Substitute.For(); + accountManager.Repository.Returns(repository); + repository.GetAccessLevel(Arg.Any()).Returns(AccessLevel.admin); + + // Id 0 keeps GetCachedAccountId off the database. + return new Character( + 0, + accountManager, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null!, + null!); + } + + private static void RunCommand(Character admin, string text) + { + IRequest request = Substitute.For(); + Channel channel = new Channel(ChannelType.Admin, "idle-test", Substitute.For()); + + AdminCommandData data = AdminCommandData.Create( + admin, + text.Split(','), + request, + channel, + Substitute.For(), + Substitute.For(), + false); + + AdminCommandHandlers.IdleThrottle(data); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs index cbcb2ede..0e6ac3ee 100644 --- a/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs +++ b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs @@ -5,6 +5,7 @@ using Perpetuum.Zones; using Perpetuum.Zones.Effects.ZoneEffects; using Perpetuum.Zones.Terrains; +using Perpetuum.Tests.Infrastructure; using System; using System.Collections.Generic; using Xunit; @@ -15,8 +16,10 @@ namespace Perpetuum.Tests.Unit /// Covers the idle throttle in Zone.Update: when no players are in the zone, units are /// updated once per idle second and must receive the accumulated elapsed time, otherwise /// every elapsed/timer-driven system inside a unit (cooldowns, movement, recharge, AI) - /// runs at a fraction of real speed. + /// runs at a fraction of real speed. In the statics collection because it mutates the + /// process-wide ZoneIdleThrottling flag, shared with IdleThrottleAdminCommandTests. /// + [Collection(PerpetuumStaticsCollection.Name)] public class ZoneIdleThrottlingTests { private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(200); diff --git a/src/Perpetuum/Commands.cs b/src/Perpetuum/Commands.cs index 3351bc3e..2400cdb6 100644 --- a/src/Perpetuum/Commands.cs +++ b/src/Perpetuum/Commands.cs @@ -3536,16 +3536,6 @@ public static Command GetCommandByText(string commandText) } }; - //GameAdmin Command — runtime on/off for the zone idle throttle (state: 1 = on, 0 = off) - public static readonly Command ZoneIdleThrottleSet = new() - { - Text = "zoneIdleThrottleSet", - AccessLevel = AccessLevel.gameAdmin, - Arguments = - { - new Argument(k.state) - } - }; //GameAdmin Command public static readonly Command EPBonusSet = new() diff --git a/src/Perpetuum/Services/Channels/ChatCommands/AdminCommandHandlers.cs b/src/Perpetuum/Services/Channels/ChatCommands/AdminCommandHandlers.cs index 7a87d2bc..47afc977 100644 --- a/src/Perpetuum/Services/Channels/ChatCommands/AdminCommandHandlers.cs +++ b/src/Perpetuum/Services/Channels/ChatCommands/AdminCommandHandlers.cs @@ -169,6 +169,21 @@ public static void UnSecure(AdminCommandData data) data.Channel.SetAdmin(false); data.Channel.SendMessageToAll(data.SessionManager, data.Sender, "Channel is now public."); } + [ChatCommand("IdleThrottle")] + public static void IdleThrottle(AdminCommandData data) + { + // #idlethrottle[,true|false] — runtime on/off for the zone idle throttle so its + // CPU savings can be measured on a live server without a restart. + bool enabled = true; + if (data.Command.Args.Length > 0 && !bool.TryParse(data.Command.Args[0], out enabled)) + { + SendMessageToAll(data, "Usage: #idlethrottle[,true|false]"); + return; + } + + ZoneIdleThrottling.Enabled = enabled; + SendMessageToAll(data, $"Zone idle throttling {(enabled ? "enabled" : "disabled")}."); + } [ChatCommand("Shutdown")] public static void Shutdown(AdminCommandData data) {