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/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/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..0e6ac3ee --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ZoneIdleThrottlingTests.cs @@ -0,0 +1,150 @@ +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 Perpetuum.Tests.Infrastructure; +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. 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); + + [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 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() + { + // 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/Commands.cs b/src/Perpetuum/Commands.cs index 3a2229f6..2400cdb6 100644 --- a/src/Perpetuum/Commands.cs +++ b/src/Perpetuum/Commands.cs @@ -3536,6 +3536,7 @@ public static Command GetCommandByText(string commandText) } }; + //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) { 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..6794a0a9 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,36 @@ 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. The throttle can be switched off at + // runtime (zoneIdleThrottleSet) to measure its CPU savings without a restart. + TimeSpan unitTime = time; + if (_players.IsEmpty && ZoneIdleThrottling.Enabled) + { + _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 +404,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. /// 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; + } + } +}