From 598223703f544df110fd53a40cb3f3ce06fee5a6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:54:49 -0800 Subject: [PATCH] refactor: Add BitMask256 utility for 256-bit bitmask operations (#2300) ### Summary - Create BitMask256 struct with scalar operations (benchmarks showed AVX2 vectorization provides no benefit for this size) - Refactor Map.cs to use BitMask256 for full Z range support (-128 to 127) - Remove SectorSpawnCache struct, use BitMask256 directly in manager - Update tests to use BitMask256 directly - Remove unused test assertions for old 64-bit behavior --- .../Tests/Maps/CanFitItemTests.cs | 24 -- .../Tests/Maps/CanSpawnMobileTests.cs | 16 - .../Tests/Maps/CanSpawnMobileTileDataTests.cs | 12 - Projects/Server/Maps/Map.cs | 80 ++-- Projects/Server/Utilities/BitMask256.cs | 396 ++++++++++++++++++ .../Engines/Spawners/SectorSpawnCacheTests.cs | 90 ++-- .../Engines/Spawners/SectorSpawnCache.cs | 125 +----- 7 files changed, 482 insertions(+), 261 deletions(-) create mode 100644 Projects/Server/Utilities/BitMask256.cs diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index dadabb8be..1dbdb1578 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -21,8 +21,6 @@ public class CanFitItemTests Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); } - #region Basic Validation Tests - [Fact] public void CanFitItem_InternalMapReturnsFalse() { @@ -42,10 +40,6 @@ public class CanFitItemTests Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); } - #endregion - - #region Land Surface Tests - private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -72,10 +66,6 @@ public class CanFitItemTests Assert.False(result, "No surface 50 units above land"); } - #endregion - - #region Surface + Impassable Tests (Tables, Furniture) - [SkippableFact] public void CanFitItem_SurfaceImpassableMultiIsValidSurface() { @@ -145,10 +135,6 @@ public class CanFitItemTests } } - #endregion - - #region World Item Surface Tests - [SkippableFact] public void CanFitItem_NonMovableWorldItemSurfaceWorks() { @@ -215,10 +201,6 @@ public class CanFitItemTests } } - #endregion - - #region Blocking Tests - [SkippableFact] public void CanFitItem_ImpassableTileBlocksPlacement() { @@ -278,10 +260,6 @@ public class CanFitItemTests } } - #endregion - - #region Helper Methods and Classes - private TestMulti CreateSurfaceImpassableMulti(Map map, Point3D location, int surfaceZ) { // Use SurfaceImpassableTileId - a tile that has both Surface and Impassable flags @@ -322,6 +300,4 @@ public class CanFitItemTests public override MultiComponentList Components => _components; } - - #endregion } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index b549b98ba..9265e4f8f 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -9,8 +9,6 @@ namespace Server.Tests.Tests.Maps; [Collection("Sequential Server Tests")] public class CanSpawnMobileTests { - #region Basic Validation Tests - [Fact] public void CanSpawnMobile_InternalMapReturnsFalse() { @@ -31,10 +29,6 @@ public class CanSpawnMobileTests Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); } - #endregion - - #region Land Surface Tests - // Use coordinates near Britain which should be walkable land in both test and real data private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -74,10 +68,6 @@ public class CanSpawnMobileTests Assert.False(result, "cantWalk=true should not find land as valid surface"); } - #endregion - - #region Mobile Blocking Tests - [Fact] public void CanSpawnMobile_MobileBlocksSpawn() { @@ -162,10 +152,6 @@ public class CanSpawnMobileTests } } - #endregion - - #region Helper Classes - private class TestMobile : Mobile { public TestMobile() @@ -174,6 +160,4 @@ public class CanSpawnMobileTests Hidden = false; } } - - #endregion } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index 5c6e56dcc..93c23c0af 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -23,8 +23,6 @@ public class CanSpawnMobileTileDataTests Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); } - #region Multi-Based Tests - [SkippableFact] public void CanSpawnMobile_FindsSurfaceOnMulti() { @@ -241,10 +239,6 @@ public class CanSpawnMobileTileDataTests } } - #endregion - - #region Real Map Data Tests - [SkippableFact] public void CanSpawnMobile_MalasBuilding_FindsGroundFloor() { @@ -290,10 +284,6 @@ public class CanSpawnMobileTileDataTests Assert.True(spawnZ <= -40, $"Expected lowest floor around -50, got {spawnZ}"); } - #endregion - - #region Helper Methods and Classes - private TestMulti CreateFloorMulti(Map map, Point3D location, int floorZ) { var multi = new TestMulti(CreateFloorComponents(floorZ)); @@ -367,6 +357,4 @@ public class CanSpawnMobileTileDataTests public override MultiComponentList Components => _components; } - - #endregion } diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index 11eab4a53..8b6878cb1 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -16,7 +16,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Numerics; using System.Runtime.CompilerServices; using Server.Buffers; using Server.Collections; @@ -1152,13 +1151,13 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa return false; } - // Bitmask approach inspired by Item.DropToWorld's m_OpenSlots pattern. + // 256-bit bitmask approach for full Z range support (-128 to 127). // Each bit represents a Z level relative to minZ. // openSlots: bit set = Z level is not blocked // surfaces: bit set = Z level has a valid surface // Final result: lowest set bit in (surfaces & openSlots) - var openSlots = ulong.MaxValue; - ulong surfaces = 0; + var openSlots = BitMask256.AllSet(); + var surfaces = BitMask256.AllClear(); // Track what types of blockers we encounter (for failure reason) var hasNonTransientBlocker = false; @@ -1178,14 +1177,14 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa if (isImpassable && !(canSwim && isWet)) { // Impassable land blocks z in range (lowZ - 16, avgZ) - openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ); + ApplyBlockerMask(ref openSlots, lowZ - 16, avgZ, minZ); hasNonTransientBlocker = true; } // Surface: water for swimmers, passable land for walkers if (avgZ >= minZ && avgZ <= maxZ && (canSwim && isWet || !cantWalk && !isImpassable)) { - surfaces |= 1UL << (avgZ - minZ); + surfaces.SetBit(avgZ - minZ); } } @@ -1202,7 +1201,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa // Exception: water tiles (Impassable | Wet) don't block swimming mobs if ((isSurface || isImpassable) && !(canSwim && isWet)) { - openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ); + ApplyBlockerMask(ref openSlots, tile.Z - 16, tileTop, minZ); hasNonTransientBlocker = true; } @@ -1210,7 +1209,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa if (tileTop >= minZ && tileTop <= maxZ && (canSwim && isWet || !cantWalk && isSurface && !isImpassable)) { - surfaces |= 1UL << (tileTop - minZ); + surfaces.SetBit(tileTop - minZ); } } @@ -1233,7 +1232,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa // Exception: water items (Impassable | Wet) don't block swimming mobs if ((isSurface || isImpassable) && !(canSwim && isWet)) { - openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ); + ApplyBlockerMask(ref openSlots, item.Z - 16, itemTop, minZ); // Movable items are transient, non-movable are permanent if (item.Movable || item.CanDecay()) @@ -1250,7 +1249,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa if (!item.Movable && itemTop >= minZ && itemTop <= maxZ && (canSwim && isWet || !cantWalk && isSurface && !isImpassable)) { - surfaces |= 1UL << (itemTop - minZ); + surfaces.SetBit(itemTop - minZ); } } @@ -1261,18 +1260,20 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa (m.AccessLevel == AccessLevel.Player || !m.Hidden)) { // Mobiles block z in range (m.Z - 16, m.Z + 16) - openSlots &= ~CreateBlockerMask(m.Z - 16, m.Z + 16, minZ); + ApplyBlockerMask(ref openSlots, m.Z - 16, m.Z + 16, minZ); hasTransientBlocker = true; } } - // Find the lowest unblocked surface using bit operations - var validSurfaces = surfaces & openSlots; - if (validSurfaces == 0) + // Find the lowest unblocked surface + var validSurfaces = surfaces.And(in openSlots); + var lowestBit = validSurfaces.LowestSetBit(); + + if (lowestBit < 0) { // Determine failure reason based on what blockers we encountered // If no surfaces existed at all, that's also a non-transient issue (map geometry) - if (hasNonTransientBlocker || surfaces == 0) + if (hasNonTransientBlocker || surfaces.IsEmpty()) { failureReason |= SpawnFailureReason.NonTransientBlocker; } @@ -1285,10 +1286,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa return false; } - // TrailingZeroCount gives the position of the lowest set bit - var lowestBit = BitOperations.TrailingZeroCount(validSurfaces); spawnZ = minZ + lowestBit; - return true; } @@ -1316,10 +1314,10 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa return false; } - // Bitmask approach: find surfaces within Z range, check for blockers. + // 256-bit bitmask approach for full Z range support. // Unlike CanSpawnMobile, Surface+Impassable tiles (tables) are valid surfaces for items. - var openSlots = ulong.MaxValue; - ulong surfaces = 0; + var openSlots = BitMask256.AllSet(); + var surfaces = BitMask256.AllClear(); // 1. Land tile var landTile = Tiles.GetLandTile(x, y); @@ -1333,13 +1331,13 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa // Impassable land blocks if (isImpassable) { - openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ); + ApplyBlockerMask(ref openSlots, lowZ - 16, avgZ, minZ); } // Passable land is a valid surface if (!isImpassable && avgZ >= minZ && avgZ <= maxZ) { - surfaces |= 1UL << (avgZ - minZ); + surfaces.SetBit(avgZ - minZ); } } @@ -1354,13 +1352,13 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa // Blocking: (surface || impassable) tiles block z in range (tile.Z - 16, tileTop) if (isSurface || isImpassable) { - openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ); + ApplyBlockerMask(ref openSlots, tile.Z - 16, tileTop, minZ); } // Surface candidate: Surface flag (including Surface+Impassable like tables) if (isSurface && tileTop >= minZ && tileTop <= maxZ) { - surfaces |= 1UL << (tileTop - minZ); + surfaces.SetBit(tileTop - minZ); } } @@ -1381,50 +1379,46 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa // Blocking: (surface || impassable) items block if (isSurface || isImpassable) { - openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ); + ApplyBlockerMask(ref openSlots, item.Z - 16, itemTop, minZ); } // Surface candidate: non-movable Surface items (including Surface+Impassable) if (!item.Movable && isSurface && itemTop >= minZ && itemTop <= maxZ) { - surfaces |= 1UL << (itemTop - minZ); + surfaces.SetBit(itemTop - minZ); } } - // Find the lowest unblocked surface using bit operations - var validSurfaces = surfaces & openSlots; - if (validSurfaces == 0) + // Find the lowest unblocked surface + var validSurfaces = surfaces.And(in openSlots); + var lowestBit = validSurfaces.LowestSetBit(); + + if (lowestBit < 0) { return false; } - var lowestBit = BitOperations.TrailingZeroCount(validSurfaces); spawnZ = minZ + lowestBit; return true; } /// - /// Creates a bitmask for a blocker range. Blocker blocks Z where blockLow < z < blockHigh. - /// Bits are relative to minZ, clamped to [0, 63]. + /// Clears bits in the mask for Z levels blocked by an object. + /// Converts (blockLow, blockHigh) exclusive world Z range to bit indices relative to minZ. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static ulong CreateBlockerMask(int blockLow, int blockHigh, int minZ) + private static void ApplyBlockerMask(ref BitMask256 mask, int blockLow, int blockHigh, int minZ) { - // Blocked range is (blockLow, blockHigh) exclusive, = [blockLow + 1, blockHigh - 1] inclusive + // Blocked range is (blockLow, blockHigh) exclusive = [blockLow + 1, blockHigh - 1] inclusive var startBit = blockLow - minZ + 1; var endBit = blockHigh - minZ - 1; - if (endBit < 0 || startBit > 63 || startBit > endBit) + if (endBit < 0 || startBit > 255 || startBit > endBit) { - return 0; + return; } - startBit = Math.Max(0, startBit); - endBit = Math.Min(63, endBit); - - var bitCount = endBit - startBit + 1; - var mask = bitCount >= 64 ? ulong.MaxValue : (1UL << bitCount) - 1; - return mask << startBit; + mask.ClearRange(Math.Max(0, startBit), Math.Min(255, endBit)); } private class ZComparer : IComparer diff --git a/Projects/Server/Utilities/BitMask256.cs b/Projects/Server/Utilities/BitMask256.cs new file mode 100644 index 000000000..aed43d4e5 --- /dev/null +++ b/Projects/Server/Utilities/BitMask256.cs @@ -0,0 +1,396 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BitMask256.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics.X86; + +namespace Server; + +/// +/// A 256-bit bitmask stored as 4 ulongs. +/// Useful for tracking 256 discrete states such as: +/// - Z levels in UO (-128 to 127 = 256 values) +/// - Sector positions (16x16 = 256 tiles) +/// Uses BMI2 for efficient bit selection when available. +/// +public struct BitMask256 +{ + public ulong Bits0, Bits1, Bits2, Bits3; + + /// + /// Creates a mask with all 256 bits set. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BitMask256 AllSet() => new() + { + Bits0 = ulong.MaxValue, + Bits1 = ulong.MaxValue, + Bits2 = ulong.MaxValue, + Bits3 = ulong.MaxValue + }; + + /// + /// Creates a mask with all bits cleared (default state). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BitMask256 AllClear() => default; + + /// + /// Sets a single bit at the specified index (0-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetBit(int index) + { + if ((uint)index >= 256) + { + return; + } + + var segment = index >> 6; + var localBit = index & 0x3F; + var mask = 1UL << localBit; + + switch (segment) + { + case 0: Bits0 |= mask; break; + case 1: Bits1 |= mask; break; + case 2: Bits2 |= mask; break; + case 3: Bits3 |= mask; break; + } + } + + /// + /// Clears a single bit at the specified index (0-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearBit(int index) + { + if ((uint)index >= 256) + { + return; + } + + var segment = index >> 6; + var localBit = index & 0x3F; + var mask = 1UL << localBit; + + switch (segment) + { + case 0: Bits0 &= ~mask; break; + case 1: Bits1 &= ~mask; break; + case 2: Bits2 &= ~mask; break; + case 3: Bits3 &= ~mask; break; + } + } + + /// + /// Gets the value of a single bit at the specified index (0-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool GetBit(int index) + { + if ((uint)index >= 256) + { + return false; + } + + var segment = index >> 6; + var localBit = index & 0x3F; + var mask = 1UL << localBit; + + return segment switch + { + 0 => (Bits0 & mask) != 0, + 1 => (Bits1 & mask) != 0, + 2 => (Bits2 & mask) != 0, + 3 => (Bits3 & mask) != 0, + _ => false + }; + } + + /// + /// Sets all bits in the inclusive range [start, end]. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetRange(int start, int end) + { + if (end < 0 || start > 255 || start > end) + { + return; + } + + start = Math.Max(0, start); + end = Math.Min(255, end); + + Bits0 |= CreateSegmentMask(start, end, 0); + Bits1 |= CreateSegmentMask(start, end, 64); + Bits2 |= CreateSegmentMask(start, end, 128); + Bits3 |= CreateSegmentMask(start, end, 192); + } + + /// + /// Clears all bits in the inclusive range [start, end]. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearRange(int start, int end) + { + if (end < 0 || start > 255 || start > end) + { + return; + } + + start = Math.Max(0, start); + end = Math.Min(255, end); + + Bits0 &= ~CreateSegmentMask(start, end, 0); + Bits1 &= ~CreateSegmentMask(start, end, 64); + Bits2 &= ~CreateSegmentMask(start, end, 128); + Bits3 &= ~CreateSegmentMask(start, end, 192); + } + + /// + /// Creates a bitmask for the portion of [start, end] that falls within a 64-bit segment. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong CreateSegmentMask(int start, int end, int segmentOffset) + { + var localStart = start - segmentOffset; + var localEnd = end - segmentOffset; + + localStart = Math.Max(0, localStart); + localEnd = Math.Min(63, localEnd); + + if (localStart > 63 || localEnd < 0 || localStart > localEnd) + { + return 0; + } + + var bitCount = localEnd - localStart + 1; + var mask = bitCount >= 64 ? ulong.MaxValue : (1UL << bitCount) - 1; + return mask << localStart; + } + + /// + /// Returns the bitwise AND of this mask with another. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly BitMask256 And(in BitMask256 other) => new() + { + Bits0 = Bits0 & other.Bits0, + Bits1 = Bits1 & other.Bits1, + Bits2 = Bits2 & other.Bits2, + Bits3 = Bits3 & other.Bits3 + }; + + /// + /// Returns the bitwise OR of this mask with another. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly BitMask256 Or(in BitMask256 other) => new() + { + Bits0 = Bits0 | other.Bits0, + Bits1 = Bits1 | other.Bits1, + Bits2 = Bits2 | other.Bits2, + Bits3 = Bits3 | other.Bits3 + }; + + /// + /// Returns the bitwise XOR of this mask with another. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly BitMask256 Xor(in BitMask256 other) => new() + { + Bits0 = Bits0 ^ other.Bits0, + Bits1 = Bits1 ^ other.Bits1, + Bits2 = Bits2 ^ other.Bits2, + Bits3 = Bits3 ^ other.Bits3 + }; + + /// + /// Returns the bitwise AND-NOT (this & ~other). + /// Clears bits in this mask that are set in the other mask. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly BitMask256 AndNot(in BitMask256 other) => new() + { + Bits0 = Bits0 & ~other.Bits0, + Bits1 = Bits1 & ~other.Bits1, + Bits2 = Bits2 & ~other.Bits2, + Bits3 = Bits3 & ~other.Bits3 + }; + + /// + /// Returns the bitwise NOT of this mask. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly BitMask256 Not() => new() + { + Bits0 = ~Bits0, + Bits1 = ~Bits1, + Bits2 = ~Bits2, + Bits3 = ~Bits3 + }; + + /// + /// Returns the total number of bits set (population count). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int PopCount() => + BitOperations.PopCount(Bits0) + + BitOperations.PopCount(Bits1) + + BitOperations.PopCount(Bits2) + + BitOperations.PopCount(Bits3); + + /// + /// Returns true if no bits are set. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool IsEmpty() => (Bits0 | Bits1 | Bits2 | Bits3) == 0; + + /// + /// Returns true if all 256 bits are set. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool IsFull() => + Bits0 == ulong.MaxValue && + Bits1 == ulong.MaxValue && + Bits2 == ulong.MaxValue && + Bits3 == ulong.MaxValue; + + /// + /// Returns the index of the lowest set bit (0-255), or -1 if no bits are set. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int LowestSetBit() + { + if (Bits0 != 0) + { + return BitOperations.TrailingZeroCount(Bits0); + } + + if (Bits1 != 0) + { + return 64 + BitOperations.TrailingZeroCount(Bits1); + } + + if (Bits2 != 0) + { + return 128 + BitOperations.TrailingZeroCount(Bits2); + } + + if (Bits3 != 0) + { + return 192 + BitOperations.TrailingZeroCount(Bits3); + } + + return -1; + } + + /// + /// Returns the index of the highest set bit (0-255), or -1 if no bits are set. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int HighestSetBit() + { + if (Bits3 != 0) + { + return 255 - BitOperations.LeadingZeroCount(Bits3); + } + + if (Bits2 != 0) + { + return 191 - BitOperations.LeadingZeroCount(Bits2); + } + + if (Bits1 != 0) + { + return 127 - BitOperations.LeadingZeroCount(Bits1); + } + + if (Bits0 != 0) + { + return 63 - BitOperations.LeadingZeroCount(Bits0); + } + + return -1; + } + + /// + /// Returns the index of the Nth set bit (0-indexed), or -1 if fewer than N+1 bits are set. + /// Uses BMI2 PDEP for O(1) performance when available, otherwise O(popcount) fallback. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int GetNthSetBit(int n) + { + if (n < 0) + { + return -1; + } + + var count0 = BitOperations.PopCount(Bits0); + if (n < count0) + { + return GetNthBitInUlong(Bits0, n); + } + + n -= count0; + + var count1 = BitOperations.PopCount(Bits1); + if (n < count1) + { + return 64 + GetNthBitInUlong(Bits1, n); + } + + n -= count1; + + var count2 = BitOperations.PopCount(Bits2); + if (n < count2) + { + return 128 + GetNthBitInUlong(Bits2, n); + } + + n -= count2; + + var count3 = BitOperations.PopCount(Bits3); + if (n < count3) + { + return 192 + GetNthBitInUlong(Bits3, n); + } + + return -1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetNthBitInUlong(ulong bits, int n) + { + // BMI2 PDEP: O(1) - deposits the nth selector bit into the position of the nth set bit + if (Bmi2.X64.IsSupported) + { + var deposited = Bmi2.X64.ParallelBitDeposit(1UL << n, bits); + return BitOperations.TrailingZeroCount(deposited); + } + + // Fallback: O(popcount) - clear n set bits, then find position of next one + while (n > 0 && bits != 0) + { + bits &= bits - 1; + n--; + } + + return bits == 0 ? -1 : BitOperations.TrailingZeroCount(bits); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs index 7f3666fbc..bbf8e2112 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs @@ -5,14 +5,14 @@ using Xunit; namespace UOContent.Tests; -public class SectorSpawnCacheTests +public class BitMask256Tests { [Fact] - public void SectorSpawnCache_InitialState_AllBitsZero() + public void BitMask256_InitialState_AllBitsZero() { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); - Assert.Equal(0, cache.GetCount()); + Assert.Equal(0, mask.PopCount()); } [Theory] @@ -24,48 +24,48 @@ public class SectorSpawnCacheTests [InlineData(191)] [InlineData(192)] [InlineData(255)] - public void SectorSpawnCache_SetBit_SetsCorrectBit(int bitIndex) + public void BitMask256_SetBit_SetsCorrectBit(int bitIndex) { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); - cache.SetBit(bitIndex); + mask.SetBit(bitIndex); - Assert.True(cache.GetBit(bitIndex)); - Assert.Equal(1, cache.GetCount()); + Assert.True(mask.GetBit(bitIndex)); + Assert.Equal(1, mask.PopCount()); } [Fact] - public void SectorSpawnCache_SetMultipleBits_CountsCorrectly() + public void BitMask256_SetMultipleBits_CountsCorrectly() { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); - cache.SetBit(0); - cache.SetBit(64); - cache.SetBit(128); - cache.SetBit(192); + mask.SetBit(0); + mask.SetBit(64); + mask.SetBit(128); + mask.SetBit(192); - Assert.Equal(4, cache.GetCount()); - Assert.True(cache.GetBit(0)); - Assert.True(cache.GetBit(64)); - Assert.True(cache.GetBit(128)); - Assert.True(cache.GetBit(192)); - Assert.False(cache.GetBit(1)); + Assert.Equal(4, mask.PopCount()); + Assert.True(mask.GetBit(0)); + Assert.True(mask.GetBit(64)); + Assert.True(mask.GetBit(128)); + Assert.True(mask.GetBit(192)); + Assert.False(mask.GetBit(1)); } [Fact] - public void SectorSpawnCache_ClearBit_ClearsCorrectBit() + public void BitMask256_ClearBit_ClearsCorrectBit() { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); - cache.SetBit(50); - cache.SetBit(100); - Assert.Equal(2, cache.GetCount()); + mask.SetBit(50); + mask.SetBit(100); + Assert.Equal(2, mask.PopCount()); - cache.ClearBit(50); + mask.ClearBit(50); - Assert.False(cache.GetBit(50)); - Assert.True(cache.GetBit(100)); - Assert.Equal(1, cache.GetCount()); + Assert.False(mask.GetBit(50)); + Assert.True(mask.GetBit(100)); + Assert.Equal(1, mask.PopCount()); } [Theory] @@ -73,31 +73,31 @@ public class SectorSpawnCacheTests [InlineData(1, 64)] // First bit of second ulong [InlineData(2, 128)] // First bit of third ulong [InlineData(3, 192)] // First bit of fourth ulong - public void SectorSpawnCache_GetNthBitPosition_FirstBitInEachUlong(int n, int expectedPosition) + public void BitMask256_GetNthSetBit_FirstBitInEachUlong(int n, int expectedPosition) { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); // Set first bit in each ulong - cache.SetBit(0); - cache.SetBit(64); - cache.SetBit(128); - cache.SetBit(192); + mask.SetBit(0); + mask.SetBit(64); + mask.SetBit(128); + mask.SetBit(192); - Assert.Equal(expectedPosition, cache.GetNthBitPosition(n)); + Assert.Equal(expectedPosition, mask.GetNthSetBit(n)); } [Fact] - public void SectorSpawnCache_GetNthBitPosition_WithinSingleUlong() + public void BitMask256_GetNthSetBit_WithinSingleUlong() { - var cache = new SectorSpawnCache(); + var mask = BitMask256.AllClear(); - cache.SetBit(5); - cache.SetBit(10); - cache.SetBit(20); + mask.SetBit(5); + mask.SetBit(10); + mask.SetBit(20); - Assert.Equal(5, cache.GetNthBitPosition(0)); - Assert.Equal(10, cache.GetNthBitPosition(1)); - Assert.Equal(20, cache.GetNthBitPosition(2)); + Assert.Equal(5, mask.GetNthSetBit(0)); + Assert.Equal(10, mask.GetNthSetBit(1)); + Assert.Equal(20, mask.GetNthSetBit(2)); } } diff --git a/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs b/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs index 936fc064c..20a364789 100644 --- a/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs +++ b/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs @@ -15,130 +15,13 @@ using System; using System.Collections.Generic; -using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Runtime.Intrinsics.X86; using Server.Collections; using Server.Regions; namespace Server.Engines.Spawners; -/// -/// Cached spawn position data for a 16x16 sector. -/// Uses a bitmap to track valid spawn positions (256 bits = 4 ulongs = 32 bytes). -/// -public struct SectorSpawnCache -{ - public ulong Bits0; - public ulong Bits1; - public ulong Bits2; - public ulong Bits3; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly int GetCount() => - BitOperations.PopCount(Bits0) + - BitOperations.PopCount(Bits1) + - BitOperations.PopCount(Bits2) + - BitOperations.PopCount(Bits3); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetBit(int bitIndex) - { - var ulongIndex = bitIndex >> 6; // / 64 - var bitPosition = bitIndex & 0x3F; // % 64 - - switch (ulongIndex) - { - case 0: Bits0 |= 1UL << bitPosition; break; - case 1: Bits1 |= 1UL << bitPosition; break; - case 2: Bits2 |= 1UL << bitPosition; break; - case 3: Bits3 |= 1UL << bitPosition; break; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly bool GetBit(int bitIndex) - { - var ulongIndex = bitIndex >> 6; - var bitPosition = bitIndex & 0x3F; - - return ulongIndex switch - { - 0 => (Bits0 & (1UL << bitPosition)) != 0, - 1 => (Bits1 & (1UL << bitPosition)) != 0, - 2 => (Bits2 & (1UL << bitPosition)) != 0, - 3 => (Bits3 & (1UL << bitPosition)) != 0, - _ => false - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void ClearBit(int bitIndex) - { - var ulongIndex = bitIndex >> 6; - var bitPosition = bitIndex & 0x3F; - - switch (ulongIndex) - { - case 0: Bits0 &= ~(1UL << bitPosition); break; - case 1: Bits1 &= ~(1UL << bitPosition); break; - case 2: Bits2 &= ~(1UL << bitPosition); break; - case 3: Bits3 &= ~(1UL << bitPosition); break; - } - } - - /// - /// Gets the Nth set bit position (0-indexed) from the bitmap. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly int GetNthBitPosition(int n) - { - var count0 = BitOperations.PopCount(Bits0); - if (n < count0) - { - return GetNthBitInUlong(Bits0, n); - } - n -= count0; - - var count1 = BitOperations.PopCount(Bits1); - if (n < count1) - { - return 64 + GetNthBitInUlong(Bits1, n); - } - n -= count1; - - var count2 = BitOperations.PopCount(Bits2); - if (n < count2) - { - return 128 + GetNthBitInUlong(Bits2, n); - } - n -= count2; - - return 192 + GetNthBitInUlong(Bits3, n); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetNthBitInUlong(ulong bits, int n) - { - // BMI2 PDEP: O(1) - deposits the nth selector bit into the position of the nth set bit - if (Bmi2.X64.IsSupported) - { - var deposited = Bmi2.X64.ParallelBitDeposit(1UL << n, bits); - return BitOperations.TrailingZeroCount(deposited); - } - - // Fallback: O(popcount) - clear n set bits, then find position of next one - while (n > 0 && bits != 0) - { - bits &= bits - 1; // Clear lowest set bit - n--; - } - - return bits == 0 ? -1 : BitOperations.TrailingZeroCount(bits); - } -} - /// /// Global manager for sector-based spawn position caching. /// Shared across all spawners for efficient memory usage and house invalidation. @@ -147,8 +30,8 @@ public struct SectorSpawnCache /// public static class SectorSpawnCacheManager { - private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _landCaches = []; - private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _waterCaches = []; + private static readonly Dictionary<(Map, int, int), BitMask256> _landCaches = []; + private static readonly Dictionary<(Map, int, int), BitMask256> _waterCaches = []; /// /// Marks a position as valid for spawning in the global cache. @@ -245,7 +128,7 @@ public static class SectorSpawnCacheManager if (caches.TryGetValue((map, sx, sy), out var cache)) { - var count = cache.GetCount(); + var count = cache.PopCount(); if (count > 0) { sectorList.Add((sx, sy, count)); @@ -281,7 +164,7 @@ public static class SectorSpawnCacheManager } var indexInSector = targetIndex - currentIndex; - var bitPosition = cache.GetNthBitPosition(indexInSector); + var bitPosition = cache.GetNthSetBit(indexInSector); var localX = bitPosition & (Map.SectorSize - 1); var localY = bitPosition >> Map.SectorShift;