From c7aaf33de90b44d3d4ce821b40c22f6e4f82b612 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:22:20 -0700 Subject: [PATCH] feat(pathfinding): .swb format v8 compact index (#3b) (#2471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase #3b (final roadmap item), stacked on #2470. Compacts the index trailer from 20 to 8 bytes/chunk. Trammel: 19.2 MB → 17.9 MB. Roadmap total: 565 MB → 17.9 MB (−96.8%). ## Details - Trailer stores `{ u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength }` per chunk, in record write order; the file offset is dropped and reconstructed by cumulative recordLength from HeaderSize. - No record reordering, no varint; fixed-stride, TryReadChunk unchanged. - Also simplifies the accumulated `.swb` code comments across the stack. - Format v8; v7 files rejected and re-baked once. ## Tests v8 multi-chunk round-trip (cumulative offset reconstruction) + the v6/v7 suite; full pathfinding suite green; Release build clean. --- Projects/Server/Compression/Deflate.cs | 4 +- .../Engines/Pathing/StepCacheFileV7Tests.cs | 4 +- .../Engines/Pathing/StepCacheFileV8Tests.cs | 121 +++ .../Engines/Pathing/BitmapAStarAlgorithm.cs | 6 +- .../Pathing/Cache/CacheEvictionTimer.cs | 2 +- .../Engines/Pathing/Cache/StepCache.cs | 40 +- .../Engines/Pathing/Cache/StepCacheFile.cs | 105 +- .../Engines/Pathing/Cache/StepChunk.cs | 51 +- .../Engines/Pathing/Cache/StepProbe.cs | 22 +- .../UOContent/Engines/Pathing/MoveResult.cs | 21 +- .../UOContent/Engines/Pathing/Movement.cs | 973 +++++++++--------- .../UOContent/Engines/Pathing/MovementPath.cs | 189 ++-- .../Engines/Pathing/PathAlgorithm.cs | 50 +- .../Engines/Pathing/PathCacheCommands.cs | 5 +- .../UOContent/Engines/Pathing/PathFollower.cs | 221 ++-- dev-docs/pathfinding.md | 14 +- 16 files changed, 960 insertions(+), 868 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs diff --git a/Projects/Server/Compression/Deflate.cs b/Projects/Server/Compression/Deflate.cs index 556c41e6c..7f4bc0dcf 100644 --- a/Projects/Server/Compression/Deflate.cs +++ b/Projects/Server/Compression/Deflate.cs @@ -28,8 +28,6 @@ public static class Deflate public static LibDeflateBinding Standard => _standard ??= new LibDeflateBinding(); - // Best-ratio compressor. Construction allocates a native libdeflate compressor, so it is - // cached per thread like Standard and reused. Decompression is level-independent, so the - // decompress path can use either accessor. libdeflate is not thread-safe — hence ThreadStatic. + // Best-ratio compressor, cached per thread like Standard (construction allocates native state). public static LibDeflateBinding Maximum => _maximum ??= new LibDeflateBinding(LibDeflateCompressionLevel.VeryHigh); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs index dc42c71da..83a44d46a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs @@ -7,9 +7,7 @@ namespace Server.Tests.Pathfinding; // v7 = per-chunk libdeflate compression on top of the v6 predictive-Z format. Each record is // compressed independently (random access preserved) behind a u32 uncompressed-length prefix; -// records that do not shrink (tiny Uniform records) are stored raw. StepCacheFileV6Tests already -// round-trips every chunk shape through this compression path; these tests cover the v7-specific -// behavior: that compression engages, that the stored-raw fallback round-trips, and v6 rejection. +// records that do not shrink (tiny Uniform records) are stored raw. [Collection("Sequential Pathfinding Tests")] public class StepCacheFileV7Tests { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs new file mode 100644 index 000000000..0a391fb39 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +// v8 = compact index on top of the v7 compression format. The trailer drops the per-chunk file +// offset (reconstructed by cumulative record length in write order) and packs the key to u32. +[Collection("Sequential Pathfinding Tests")] +public class StepCacheFileV8Tests +{ + private static StepChunk VariedChunk(int seed, int multis) + { + var c = new StepChunk { BuiltMultisVersion = multis }; + for (var i = 0; i < StepChunk.CellsPerChunk; i++) + { + c.WalkMask[i] = (byte)((i + seed) & 0xFF); + c.WetMask[i] = (byte)((i * 7 + seed) & 0xFF); + c.SourceZ[i] = (sbyte)(((i + seed) % 40) - 20); + c.WalkZN[i] = (sbyte)(c.SourceZ[i] + (i % 3)); + c.SwimZS[i] = (sbyte)(c.SourceZ[i] - (i % 2)); + } + return c; + } + + private static StepChunk UniformChunk(sbyte z, int multis) + { + var c = new StepChunk { BuiltMultisVersion = multis }; + Array.Fill(c.WalkMask, (byte)0xC1); + Array.Fill(c.SourceZ, z); + foreach (var arr in new[] + { + c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW, + c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW + }) + { + Array.Fill(arr, z); + } + return c; + } + + private static void AssertBaseEqual(StepChunk a, StepChunk b) + { + Assert.Equal(a.BuiltMultisVersion, b.BuiltMultisVersion); + Assert.True(a.WalkMask.AsSpan().SequenceEqual(b.WalkMask)); + Assert.True(a.WetMask.AsSpan().SequenceEqual(b.WetMask)); + Assert.True(a.SourceZ.AsSpan().SequenceEqual(b.SourceZ)); + var az = new[] { a.WalkZN, a.WalkZNE, a.WalkZE, a.WalkZSE, a.WalkZS, a.WalkZSW, a.WalkZW, a.WalkZNW, + a.SwimZN, a.SwimZNE, a.SwimZE, a.SwimZSE, a.SwimZS, a.SwimZSW, a.SwimZW, a.SwimZNW }; + var bz = new[] { b.WalkZN, b.WalkZNE, b.WalkZE, b.WalkZSE, b.WalkZS, b.WalkZSW, b.WalkZW, b.WalkZNW, + b.SwimZN, b.SwimZNE, b.SwimZE, b.SwimZSE, b.SwimZS, b.SwimZSW, b.SwimZW, b.SwimZNW }; + for (var i = 0; i < az.Length; i++) + { + Assert.True(az[i].AsSpan().SequenceEqual(bz[i]), $"base Z array {i} differs"); + } + } + + private static string WriteMany(IReadOnlyList<(int cx, int cy, StepChunk c)> chunks) + { + var path = Path.Combine(Path.GetTempPath(), $"swbv8_{Guid.NewGuid():N}.swb"); + var idx = 0; + StepCacheFile.Write(path, 1u, (uint)chunks.Count, (out int ox, out int oy, out StepChunk oc) => + { + if (idx >= chunks.Count) { ox = oy = 0; oc = null!; return false; } + var e = chunks[idx++]; + ox = e.cx; oy = e.cy; oc = e.c; + return true; + }); + return path; + } + + [Fact] + public void MultiChunk_RoundTrips_WithDerivedOffsets() + { + // Distinct chunks at distinct coords. A wrong derived offset would read another chunk's + // bytes, so per-chunk identity verifies cumulative offset reconstruction across records. + var chunks = new List<(int, int, StepChunk)> + { + (1, 1, VariedChunk(seed: 3, multis: 2)), + (2, 5, UniformChunk(z: 14, multis: 9)), // tiny record (stored-raw path) in the middle + (10, 3, VariedChunk(seed: 99, multis: 4)), + (300, 200, VariedChunk(seed: 17, multis: 5)), // large packed-key coords (high 16 bits) + }; + + var path = WriteMany(chunks); + try + { + using var reader = StepCacheFile.OpenForLazy(path); + Assert.NotNull(reader); + Assert.Equal((uint)chunks.Count, reader!.ChunkCount); + + foreach (var (cx, cy, src) in chunks) + { + Assert.True(reader.Has(cx, cy), $"missing chunk ({cx},{cy})"); + var rt = reader.TryReadChunk(cx, cy); + Assert.NotNull(rt); + AssertBaseEqual(src, rt!); + } + + // A coordinate that was never written must not resolve. + Assert.Null(reader.TryReadChunk(7, 7)); + } + finally { File.Delete(path); } + } + + [Fact] + public void V7_IsRejected() + { + var path = WriteMany(new List<(int, int, StepChunk)> { (0, 0, UniformChunk(z: 10, multis: 1)) }); + try + { + var bytes = File.ReadAllBytes(path); + bytes[4] = 7; bytes[5] = 0; bytes[6] = 0; bytes[7] = 0; // version 7 < MinSupportedVersion 8 + File.WriteAllBytes(path, bytes); + Assert.Null(StepCacheFile.OpenForLazy(path)); + } + finally { File.Delete(path); } + } +} diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index 46d2bdf9e..564791ed5 100644 --- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using Server.Engines.Pathing; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.Systems.FeatureFlags; @@ -114,7 +115,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm // and trips the threshold immediately. StepCache.Instance.BeginFindGeneration(); - Server.Engines.Pathing.PathfindRecorder.RecordIfEnabled(m, map, start, goal); + PathfindRecorder.RecordIfEnabled(m, map, start, goal); _currentMobileNeedsSlowPath = RequiresSlowPath(m); _currentMobilePlayerStrict = m.Player && m.AccessLevel < AccessLevel.GameMaster; @@ -548,6 +549,5 @@ public class BitmapAStarAlgorithm : PathAlgorithm /// + wetMask). CanOpenDoors / CanMoveOverObstacles only affect dynamic items and don't /// disqualify the cache. /// - private static bool RequiresSlowPath(Mobile m) => - m is BaseCreature bc && bc.CanFly; + private static bool RequiresSlowPath(Mobile m) => m is BaseCreature bc && bc.CanFly; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs index 76c43379b..031d3ed21 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs @@ -22,7 +22,7 @@ public class CacheEvictionTimer : Timer _instance.Start(); } - private CacheEvictionTimer() : base(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) { } + private CacheEvictionTimer() : base(TimeSpan.FromSeconds(120), TimeSpan.FromSeconds(120)) { } protected override void OnTick() { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 1c2af3e7e..2be027477 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -22,7 +22,7 @@ public sealed class StepCache private readonly Dictionary _chunks = new(); // Parallel list of keys for O(1) random sampling during eviction. Kept in lockstep // with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction. - private readonly List _keysList = new(); + private readonly List _keysList = []; // Second-touch promotion tracker. A chunk's first miss within the window returns // Fallthrough_NotBuilt; the caller takes the slow path. The Nth DISTINCT-FIND miss @@ -38,7 +38,6 @@ public sealed class StepCache // Generation counter incremented by BeginFindGeneration(). Sentinel 0 = "no Find started // yet"; treated as a distinct generation per call so callers that bypass BeginFindGeneration // (single-call tests, BakeMap with threshold=1) get sensible behavior. - private uint _findGeneration; private struct ChunkMissState { @@ -93,12 +92,16 @@ public sealed class StepCache /// public void BeginFindGeneration() { - unchecked { _findGeneration++; } - if (_findGeneration == 0) { _findGeneration = 1; } + unchecked { CurrentFindGeneration++; } + + if (CurrentFindGeneration == 0) + { + CurrentFindGeneration = 1; + } } /// Test-only: read the current Find generation. - internal uint CurrentFindGeneration => _findGeneration; + internal uint CurrentFindGeneration { get; private set; } /// /// Pack (mapId, chunkX, chunkY) into a single long key. @@ -107,7 +110,7 @@ public sealed class StepCache internal static long EncodeKey(int mapId, int chunkX, int chunkY) => ((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF); - public CacheStats GetStats() => new CacheStats( + public CacheStats GetStats() => new( residentChunks: _chunks.Count, hits: _hits, missesNotBuilt: _missesNotBuilt, @@ -142,7 +145,7 @@ public sealed class StepCache _chunks.Clear(); _keysList.Clear(); _chunkMissTracker.Clear(); - _findGeneration = 0; + CurrentFindGeneration = 0; _hits = 0; _missesNotBuilt = 0; _missesDirtyRebuild = 0; @@ -193,10 +196,17 @@ public sealed class StepCache // and the bake would write an empty file. Force eager build for the duration. var prevThreshold = MissPromotionThreshold; MissPromotionThreshold = 1; + var startTick = Core.TickCount; try { var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize; var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize; + var logEvery = Math.Max(1, chunkRows / 32); + + logger.Information( + "PathBake map {MapId}: walking {Cols}x{Rows} = {Total} chunks (synchronous; no eviction during the walk)...", + mapId, chunkCols, chunkRows, chunkCols * chunkRows + ); for (var cy = 0; cy < chunkRows; cy++) { @@ -206,7 +216,21 @@ public sealed class StepCache // whether the query returns Hit or Fallthrough_SourceZMismatch. TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0); } + + if ((cy + 1) % logEvery == 0 || cy == chunkRows - 1) + { + logger.Information( + "PathBake map {MapId}: row {Row}/{Rows} ({Pct}%), {Resident} chunks resident, {Elapsed:F1}s, {HeapMB} MB heap", + mapId, cy + 1, chunkRows, (cy + 1) * 100 / chunkRows, + _chunks.Count, (Core.TickCount - startTick) / 1000.0, GC.GetTotalMemory(false) >> 20 + ); + } } + + logger.Information( + "PathBake map {MapId}: walk complete in {Elapsed:F1}s, writing {Resident} chunks to disk...", + mapId, (Core.TickCount - startTick) / 1000.0, _chunks.Count + ); } finally { @@ -610,7 +634,7 @@ public sealed class StepCache // Environment.TickCount, not Core.TickCount: tests/bench fixtures may not advance // the game-loop tick. The promotion window is wall-clock anyway. var now = (uint)Environment.TickCount; - var gen = _findGeneration; + var gen = CurrentFindGeneration; if (_chunkMissTracker.TryGetValue(chunkKey, out var state)) { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index a578650f7..d737e6b52 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -16,11 +16,11 @@ namespace Server.Engines.Pathing.Cache; /// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless /// of file size. /// -/// File layout v7 (little-endian, BufferWriter / BufferReader convention): +/// File layout v8 (little-endian, BufferWriter / BufferReader convention): /// -/// Header (48 bytes): +/// Header (40 bytes): /// u32 Magic = 0x42575300 ('SWB\0') -/// u32 Version = current FormatVersion (7) +/// u32 Version = current FormatVersion (8) /// u32 MapId /// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the /// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul. @@ -73,8 +73,10 @@ namespace Server.Engines.Pathing.Cache; /// sbyte walkZ_N..NW (8) /// sbyte swimZ_N..NW (8) /// -/// Index trailer (20 × ChunkCount bytes): -/// For each chunk: { u64 chunkKey, u64 fileOffset, u32 recordLength } +/// Index trailer (8 × ChunkCount bytes), in record write order: +/// For each chunk: { u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength } +/// The file offset is not stored — reconstructed as a cumulative sum of recordLength +/// starting at HeaderSize (the first record sits immediately after the header). /// /// Per-chunk fixed portion (Kind + flags + ZArrayMask + WalkMask + WetMask + SourceZ): /// ~783 bytes; each present base Z array adds 256 bytes (0..16 present, so up to ~4 KB). @@ -88,29 +90,14 @@ namespace Server.Engines.Pathing.Cache; internal static class StepCacheFile { public const uint Magic = 0x42575300; // 'SWB\0' - public const uint FormatVersion = 7; + public const uint FormatVersion = 8; /// - /// Lowest format version this binary can load. Files below this version are treated as - /// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with - /// the current FormatVersion. Bumped to 3 when the swim layer landed (v2 had no swim - /// layer). Bumped to 4 when the baker switched to clearance-aware standable-surface - /// strata: v3 bakes anchored every cell at the land average and so missed walkable - /// static-over-land surfaces (sewer/dungeon walkways, bridges, upper building floors), - /// producing ~98% source-Z fallthroughs on those routes. The on-disk layout is - /// unchanged; only the strata population differs, so the bump exists purely to force a - /// one-time re-bake of stale v3 files on first boot under the new binary. Bumped to 5 for - /// uniform-chunk elision: each record now begins with a Kind byte (0 = Full, 2 = Uniform); - /// a fully-uniform chunk (no strata, no swim layer, all 19 base arrays constant) stores - /// one cell's worth of data (~28 bytes) instead of the full record. Bumped to 6 for - /// predictive-Z residuals: each base directional Z array is stored as a masked residual - /// against SourceZ (a ZArrayMask u16 flags which arrays are present); arrays matching their - /// prediction are omitted and synthesized at read. v5 files are rejected and re-baked once. - /// Bumped to 7 for per-chunk compression: each chunk record is libdeflate-compressed - /// independently (random access preserved) behind a u32 uncompressed-length prefix; tiny - /// records that do not shrink are stored raw. v6 files are rejected and re-baked once. + /// Lowest format version this binary can load. Files below it are treated as missing + /// (silently rejected) and overwritten by the next SaveToFile / BakeMap. The cache is + /// fully regenerable, so a format bump just forces a one-time re-bake of stale files. /// - public const uint MinSupportedVersion = 7; + public const uint MinSupportedVersion = 8; // Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved. private const byte KindFull = 0; @@ -125,10 +112,10 @@ internal static class StepCacheFile + sizeof(uint) // ChunkCount + sizeof(ulong); // IndexOffset - // Index entry: chunkKey + fileOffset + recordLength. Bumped to include length when - // strata made chunk records variable-size; the lazy reader uses length to do a - // single bulk read per chunk without consulting the next offset. - private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint); + // Index entry (v8 compact): u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength. + // The file offset is NOT stored — entries are in record write order, so the reader + // reconstructs each offset by cumulative sum of record lengths starting at HeaderSize. + private const int IndexEntryBytes = sizeof(uint) + sizeof(uint); /// Fixed-size portion of a chunk record (everything except the optional strata + swim trailers). private const int BytesPerChunkBase = @@ -141,12 +128,6 @@ internal static class StepCacheFile + 8 * StepChunk.CellsPerChunk // WalkZ[8] + 8 * StepChunk.CellsPerChunk; // SwimZ[8] - /// Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays. - private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk; - - /// Strata trailer overhead when present: 256×u16 offset table + u32 data length. - private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint); - /// /// Byte offset of the IndexOffset u64 within the header /// (Magic+Version+MapId+Fingerprint+BakeTimestamp+ChunkCount = 32). Patched after chunks land. @@ -267,11 +248,8 @@ internal static class StepCacheFile w.Write(chunkCount); w.Write(0UL); // IndexOffset placeholder, patched after chunks - // Per-chunk compression: each record is built uncompressed into `recordScratch`, then - // libdeflate-compressed into `compScratch` and framed as [u32 uncompressedLen][payload]. - // Reuse the cached per-thread VeryHigh compressor (construction allocates native state). - // libdeflate is not thread-safe, but bake runs single-threaded. VeryHigh is the best-ratio - // level and its slow compression is irrelevant offline (decompression is what's hot, ~1.8us). + // Each record is built uncompressed into recordScratch, then libdeflate-compressed into + // compScratch and framed as [u32 uncompressedLen][payload]. var packer = Deflate.Maximum; var recordScratch = new byte[BytesPerChunkBase + 1024]; var compScratch = new byte[packer.MaxPackSize(recordScratch.Length)]; @@ -303,8 +281,11 @@ internal static class StepCacheFile var indexOffset = (ulong)w.Position; for (var i = 0u; i < chunkCount; i++) { - w.Write(indexEntries[i].key); - w.Write(indexEntries[i].offset); + // v8 compact entry: u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength. + // Offset is omitted; entries are in record write order so the reader derives it. + var key = indexEntries[i].key; + var packedKey = ((uint)(key >> 32) << 16) | (uint)(key & 0xFFFF); + w.Write(packedKey); w.Write(indexEntries[i].length); } @@ -383,14 +364,19 @@ internal static class StepCacheFile return null; } + // v8 compact index: { u32 packedKey, u32 length } per chunk, in record write order. + // The file offset is not stored — reconstruct it by cumulative record length starting + // at the first record (immediately after the header). var offsets = new Dictionary((int)chunkCount); + var runningOffset = (ulong)HeaderSize; for (var i = 0; i < chunkCount; i++) { var entry = indexBuf.AsSpan(i * IndexEntryBytes); - var key = BinaryPrimitives.ReadUInt64LittleEndian(entry); - var off = BinaryPrimitives.ReadUInt64LittleEndian(entry[8..]); - var len = BinaryPrimitives.ReadUInt32LittleEndian(entry[16..]); - offsets[key] = (off, len); + var packedKey = BinaryPrimitives.ReadUInt32LittleEndian(entry); + var len = BinaryPrimitives.ReadUInt32LittleEndian(entry[4..]); + var key = PackChunkKey((int)(packedKey >> 16), (int)(packedKey & 0xFFFF)); + offsets[key] = (runningOffset, len); + runningOffset += len; } return new LazyReader(stream, mapId, fingerprint, bakeTimestamp, chunkCount, offsets); @@ -423,14 +409,17 @@ internal static class StepCacheFile internal static sbyte DecodeZ(sbyte predict, sbyte residual) => unchecked((sbyte)(predict + residual)); /// - /// The 16 base directional-Z arrays in canonical order: walk N..NW (indices 0-7), - /// then swim N..NW (8-15). Index d uses WalkMask (d < 8) or WetMask (d >= 8) - /// with direction bit (d & 7). Allocates a 16-slot reference array (bake/read time only). + /// The base directional-Z array for direction index d in canonical order: walk N..NW (0-7), + /// then swim N..NW (8-15). Index d uses WalkMask (d < 8) or WetMask (d >= 8) with + /// direction bit (d & 7). /// - private static sbyte[][] GetBaseZArrays(StepChunk c) => new[] + private static sbyte[] GetBaseZArray(StepChunk c, int d) => d switch { - c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW, - c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW, + 0 => c.WalkZN, 1 => c.WalkZNE, 2 => c.WalkZE, 3 => c.WalkZSE, + 4 => c.WalkZS, 5 => c.WalkZSW, 6 => c.WalkZW, 7 => c.WalkZNW, + 8 => c.SwimZN, 9 => c.SwimZNE, 10 => c.SwimZE, 11 => c.SwimZSE, + 12 => c.SwimZS, 13 => c.SwimZSW, 14 => c.SwimZW, 15 => c.SwimZNW, + _ => throw new ArgumentOutOfRangeException(nameof(d)) }; /// @@ -515,11 +504,10 @@ internal static class StepCacheFile // Predictive-Z: each base directional Z array is stored as a masked residual against // SourceZ. Bit d of ZArrayMask is set only when array d differs from its prediction // somewhere; cleared arrays are omitted and rebuilt from mask+SourceZ at read. - var zArrays = GetBaseZArrays(chunk); ushort zArrayMask = 0; for (var d = 0; d < 16; d++) { - var z = zArrays[d]; + var z = GetBaseZArray(chunk, d); var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask; var bit = d & 7; for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++) @@ -544,7 +532,7 @@ internal static class StepCacheFile { continue; } - var z = zArrays[d]; + var z = GetBaseZArray(chunk, d); var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask; var bit = d & 7; for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++) @@ -629,11 +617,10 @@ internal static class StepCacheFile // Predictive-Z reconstruction: present arrays carry residuals (z = predict + residual); // absent arrays are synthesized from mask+SourceZ (z = predict, residual implicitly 0). - var zArrays = GetBaseZArrays(chunk); Span residual = stackalloc sbyte[StepChunk.CellsPerChunk]; for (var d = 0; d < 16; d++) { - var z = zArrays[d]; + var z = GetBaseZArray(chunk, d); var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask; var bit = d & 7; if ((zArrayMask >> d & 1) != 0) @@ -787,9 +774,7 @@ internal static class StepCacheFile } else { - // Decompression is level-independent, so reuse the shared per-thread binding - // rather than allocating a native decompressor per reader. The cache is read on - // the single game thread; libdeflate's non-thread-safety is satisfied by ThreadStatic. + // Decompression is level-independent, so reuse the shared per-thread binding. var result = Deflate.Standard.Unpack( _bodyBuffer.AsSpan(0, uncompressedLen), _buffer.AsSpan(sizeof(uint), payloadLen), diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs index 6d18b4032..3d26aee5d 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -4,17 +4,19 @@ namespace Server.Engines.Pathing.Cache; /// /// Per-chunk storage backing StepCache. Holds raw walk + swim masks and destination Z -/// values for each of 256 cells in a 16x16 chunk, plus build-time metadata (multis -/// version, multi-Z strata) and LRU bookkeeping. +/// values for each of 256 cells in a 16x16 chunk, plus build-time metadata (multis version, multi-Z strata) +/// and LRU bookkeeping. /// internal sealed class StepChunk { public const int CellsPerChunk = 256; // 16 x 16 - /// Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here. + /// Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i". + /// Raw — no diagonal corner-cut applied here. public readonly byte[] WalkMask = new byte[CellsPerChunk]; - /// Bit i of WetMask[c] = "swim-only mob can step from cell c to neighbor (Direction)i". Layered with WalkMask via canSwim/cantWalk capability flags. + /// Bit i of WetMask[c] = "swim-only mob can step from cell c to neighbor (Direction)i". + /// Layered with WalkMask via canSwim/cantWalk capability flags. public readonly byte[] WetMask = new byte[CellsPerChunk]; public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk]; @@ -103,25 +105,6 @@ internal sealed class StepChunk } } - /// Test/serialization hook: install pre-built swim-layer arrays. Pass nulls to clear. - internal void SetSwimLayer( - sbyte[] swimSourceZ, byte[] swimMask, - sbyte[] zN, sbyte[] zNE, sbyte[] zE, sbyte[] zSE, - sbyte[] zS, sbyte[] zSW, sbyte[] zW, sbyte[] zNW - ) - { - _swimSourceZ = swimSourceZ; - _swimMask = swimMask; - _swimZN_extra = zN; - _swimZNE_extra = zNE; - _swimZE_extra = zE; - _swimZSE_extra = zSE; - _swimZS_extra = zS; - _swimZSW_extra = zSW; - _swimZW_extra = zW; - _swimZNW_extra = zNW; - } - /// Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays. public const ushort NoStrata = ushort.MaxValue; @@ -151,8 +134,7 @@ internal sealed class StepChunk public bool IsCellMultiZ(int cellIndex) => GetStrataOffset(cellIndex) != NoStrata; - public ushort GetStrataOffset(int cellIndex) => - _strataOffsetByCell == null ? NoStrata : _strataOffsetByCell[cellIndex]; + public ushort GetStrataOffset(int cellIndex) => _strataOffsetByCell == null ? NoStrata : _strataOffsetByCell[cellIndex]; public ReadOnlySpan StrataData => _strataData == null ? ReadOnlySpan.Empty : _strataData.AsSpan(); @@ -182,18 +164,13 @@ internal sealed class StepChunk /// record. Chunks with a swim layer (shore cells) are never uniform — their per-cell swim /// data must be preserved via the Full record. /// - internal bool IsUniform() - { - if (_strataOffsetByCell != null || HasSwimLayer) - { - return false; - } - return AllSame(WalkMask) && AllSame(WetMask) && AllSame(SourceZ) - && AllSame(WalkZN) && AllSame(WalkZNE) && AllSame(WalkZE) && AllSame(WalkZSE) - && AllSame(WalkZS) && AllSame(WalkZSW) && AllSame(WalkZW) && AllSame(WalkZNW) - && AllSame(SwimZN) && AllSame(SwimZNE) && AllSame(SwimZE) && AllSame(SwimZSE) - && AllSame(SwimZS) && AllSame(SwimZSW) && AllSame(SwimZW) && AllSame(SwimZNW); - } + internal bool IsUniform() => _strataOffsetByCell == null + && !HasSwimLayer + && AllSame(WalkMask) && AllSame(WetMask) && AllSame(SourceZ) + && AllSame(WalkZN) && AllSame(WalkZNE) && AllSame(WalkZE) && AllSame(WalkZSE) + && AllSame(WalkZS) && AllSame(WalkZSW) && AllSame(WalkZW) && AllSame(WalkZNW) + && AllSame(SwimZN) && AllSame(SwimZNE) && AllSame(SwimZE) && AllSame(SwimZSE) + && AllSame(SwimZS) && AllSame(SwimZSW) && AllSame(SwimZW) && AllSame(SwimZNW); // "All 256 cells equal" via SIMD-accelerated ContainsAnyExcept (skip cell 0, the reference). private static bool AllSame(byte[] a) => a.Length < 2 || !a.AsSpan(1).ContainsAnyExcept(a[0]); diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index 2d90e771a..8b6bd5b20 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -242,8 +242,8 @@ public static class StepProbe /// public static int ComputeStandingZ(Map map, int x, int y, int locZ) { - GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, - out _, out _, out var zCenter); + GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, out _, out _, out var zCenter); + return zCenter; } @@ -255,11 +255,7 @@ public static class StepProbe /// public static int ComputeSwimStandingZ(Map map, int x, int y) { - if (map == null || map == Map.Internal) - { - return int.MinValue; - } - if (x < 0 || y < 0 || x >= map.Width || y >= map.Height) + if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height) { return int.MinValue; } @@ -290,8 +286,7 @@ public static class StepProbe /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk. /// private static void GetStaticStartZ( - Map map, int x, int y, int locZ, bool canSwim, bool cantWalk, - out int zLow, out int zTop, out int zCenter + Map map, int x, int y, int locZ, bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter ) { var landTile = map.Tiles.GetLandTile(x, y); @@ -300,8 +295,7 @@ public static class StepProbe // Mirrors MovementImpl: impassable + swim on water is OK; otherwise block on // cantWalk or impassable. - var landBlocks = (cantWalk || impassable) - && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); + var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); map.GetAverageZ(x, y, out var landZ, out var landCenter, out var landTop); @@ -356,8 +350,7 @@ public static class StepProbe /// Items and mobile collision phases are omitted. /// private static bool CheckStaticStep( - Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, - out int newZ + Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, out int newZ ) { newZ = 0; @@ -371,8 +364,7 @@ public static class StepProbe var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; var impassable = (flags & TileFlag.Impassable) != 0; - var landBlocks = (cantWalk || impassable) - && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); + var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); var considerLand = !landTile.Ignored; diff --git a/Projects/UOContent/Engines/Pathing/MoveResult.cs b/Projects/UOContent/Engines/Pathing/MoveResult.cs index cd9b91ddd..6eb0fcdfb 100644 --- a/Projects/UOContent/Engines/Pathing/MoveResult.cs +++ b/Projects/UOContent/Engines/Pathing/MoveResult.cs @@ -1,12 +1,11 @@ -namespace Server -{ - public delegate MoveResult MoveMethod(Direction d, bool badStateOk); +namespace Server; - public enum MoveResult - { - BadState, - Blocked, - Success, - SuccessAutoTurn - } -} +public delegate MoveResult MoveMethod(Direction d, bool badStateOk); + +public enum MoveResult +{ + BadState, + Blocked, + Success, + SuccessAutoTurn +} \ No newline at end of file diff --git a/Projects/UOContent/Engines/Pathing/Movement.cs b/Projects/UOContent/Engines/Pathing/Movement.cs index 3f1b263f2..1cb077a30 100644 --- a/Projects/UOContent/Engines/Pathing/Movement.cs +++ b/Projects/UOContent/Engines/Pathing/Movement.cs @@ -4,579 +4,578 @@ using System.Runtime.CompilerServices; using Server.Items; using Server.Mobiles; -namespace Server.Movement +namespace Server.Movement; + +public class MovementImpl : IMovementImpl { - public class MovementImpl : IMovementImpl + private const int PersonHeight = 16; + private const int StepHeight = 2; + + private const TileFlag ImpassableSurface = TileFlag.Impassable | TileFlag.Surface; + + private static Point3D _goal; + + public static void Configure() { - private const int PersonHeight = 16; - private const int StepHeight = 2; + Movement.Impl = new MovementImpl(); + } - private const TileFlag ImpassableSurface = TileFlag.Impassable | TileFlag.Surface; + private readonly List[] _mobPools = { new(), new(), new() }; - private static Point3D _goal; + private readonly List[] _pools = { new(), new(), new(), new() }; - public static void Configure() - { - Movement.Impl = new MovementImpl(); - } + private MovementImpl() + { + } - private readonly List[] _mobPools = { new(), new(), new() }; + public static bool AlwaysIgnoreDoors { get; set; } + public static bool IgnoreMovableImpassables { get; set; } - private readonly List[] _pools = { new(), new(), new(), new() }; + public static Point3D Goal + { + get => _goal; + set => _goal = value; + } - private MovementImpl() - { - } - - public static bool AlwaysIgnoreDoors { get; set; } - public static bool IgnoreMovableImpassables { get; set; } - - public static Point3D Goal - { - get => _goal; - set => _goal = value; - } - - public bool CheckMovement(Mobile m, Map map, Point3D loc, Direction d, out int newZ) - { - if (map == null || map == Map.Internal) - { - newZ = 0; - return false; - } - - var xStart = loc.X; - var yStart = loc.Y; - - int xForward = xStart, yForward = yStart; - int xRight = xStart, yRight = yStart; - int xLeft = xStart, yLeft = yStart; - - var checkDiagonals = ((int)d & 0x1) == 0x1; - - Movement.Offset(d, ref xForward, ref yForward); - Movement.Offset((Direction)(((int)d - 1) & 0x7), ref xLeft, ref yLeft); - Movement.Offset((Direction)(((int)d + 1) & 0x7), ref xRight, ref yRight); - - if (xForward < 0 || yForward < 0 || xForward >= map.Width || yForward >= map.Height) - { - newZ = 0; - return false; - } - - var itemsStart = _pools[0]; - var itemsForward = _pools[1]; - var itemsLeft = _pools[2]; - var itemsRight = _pools[3]; - - var ignoreMovableImpassables = IgnoreMovableImpassables; - var reqFlags = ImpassableSurface; - - if (m.CanSwim) - { - reqFlags |= TileFlag.Wet; - } - - var mobsForward = _mobPools[0]; - var mobsLeft = _mobPools[1]; - var mobsRight = _mobPools[2]; - - var checkMobs = (m as BaseCreature)?.Controlled == false && (xForward != _goal.X || yForward != _goal.Y); - - if (checkMobs) - { - foreach (var mob in map.GetMobilesInRange(loc, 1)) - { - if (mob.AtPoint(xForward, yForward)) - { - mobsForward.Add(mob); - } - else if (checkDiagonals && mob.AtPoint(xLeft, yLeft)) - { - mobsLeft.Add(mob); - } - else if (checkDiagonals && mob.AtPoint(xRight, yRight)) - { - mobsRight.Add(mob); - } - } - } - - foreach (var item in map.GetItemsInRange(loc, 1)) - { - if (ignoreMovableImpassables && item.Movable && item.ItemData.ImpassableSurface) - { - continue; - } - - if (!item.ItemData[reqFlags] || item.ItemID > TileData.MaxItemValue || item.Parent != null) - { - continue; - } - - if (item is BaseMulti) - { - continue; - } - - if (item.AtPoint(xStart, yStart)) - { - itemsStart.Add(item); - } - else if (item.AtPoint(xForward, yForward)) - { - itemsForward.Add(item); - } - else if (checkDiagonals && item.AtPoint(xLeft, yLeft)) - { - itemsLeft.Add(item); - } - else if (checkDiagonals && item.AtPoint(xRight, yRight)) - { - itemsRight.Add(item); - } - } - - GetStartZ(m, map, loc, itemsStart, out var startZ, out var startTop); - - var moveIsOk = Check(map, m, itemsForward, mobsForward, xForward, yForward, startTop, startZ, out newZ); - - if (moveIsOk && checkDiagonals) - { - if (m.Player && m.AccessLevel < AccessLevel.GameMaster) - { - if (!Check(map, m, itemsLeft, mobsLeft, xLeft, yLeft, startTop, startZ, out _) || - !Check(map, m, itemsRight, mobsRight, xRight, yRight, startTop, startZ, out _)) - { - moveIsOk = false; - } - } - else - { - if (!Check(map, m, itemsLeft, mobsLeft, xLeft, yLeft, startTop, startZ, out _) && - !Check(map, m, itemsRight, mobsRight, xRight, yRight, startTop, startZ, out _)) - { - moveIsOk = false; - } - } - } - - for (int i = 0, c = checkDiagonals ? 4 : 2; i < c; ++i) - { - _pools[i].Clear(); - } - - for (int i = 0, c = checkDiagonals ? 3 : 1; i < c; ++i) - { - _mobPools[i].Clear(); - } - - if (!moveIsOk) - { - newZ = startZ; - } - - return moveIsOk; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool CheckMovement(Mobile m, Direction d, out int newZ) => CheckMovement(m, m.Map, m.Location, d, out newZ); - - private static bool IsOk( - bool ignoreDoors, bool ignoreSpellFields, int ourZ, int ourTop, Map map, int x, int y, List items - ) - { - foreach (var check in map.Tiles.GetStaticAndMultiTiles(x, y)) - { - var itemData = TileData.ItemTable[check.ID & TileData.MaxItemValue]; - - if (itemData.ImpassableSurface) - { - var checkZ = check.Z; - var checkTop = checkZ + itemData.CalcHeight; - - if (checkTop > ourZ && ourTop > checkZ) - { - return false; - } - } - } - - for (var i = 0; i < items.Count; ++i) - { - var item = items[i]; - var itemID = item.ItemID & TileData.MaxItemValue; - var itemData = TileData.ItemTable[itemID]; - - if (itemData.ImpassableSurface) - { - if (ignoreDoors && (itemData.Door || itemID is 0x692 or 0x846 or 0x873 || itemID >= 0x6F5 && itemID <= 0x6F6)) - { - continue; - } - - if (ignoreSpellFields && itemID is 0x82 or 0x3946 or 0x3956) - { - continue; - } - - var checkZ = item.Z; - var checkTop = checkZ + itemData.CalcHeight; - - if (checkTop > ourZ && ourTop > checkZ) - { - return false; - } - } - } - - return true; - } - - private static bool Check( - Map map, - Mobile m, - List items, - List mobiles, - int x, - int y, - int startTop, - int startZ, - out int newZ - ) + public bool CheckMovement(Mobile m, Map map, Point3D loc, Direction d, out int newZ) + { + if (map == null || map == Map.Internal) { newZ = 0; + return false; + } - var cantWalk = m.CantWalk; - var canSwim = m.CanSwim; - var landTile = map.Tiles.GetLandTile(x, y); - var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; - var impassable = (flags & TileFlag.Impassable) != 0; + var xStart = loc.X; + var yStart = loc.Y; - // Impassable + swim on water is ok, otherwise block if cannot walk or impassable - var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); + int xForward = xStart, yForward = yStart; + int xRight = xStart, yRight = yStart; + int xLeft = xStart, yLeft = yStart; - var considerLand = !landTile.Ignored; + var checkDiagonals = ((int)d & 0x1) == 0x1; - map.GetAverageZ(x, y, out var landZ, out var landCenter, out _); + Movement.Offset(d, ref xForward, ref yForward); + Movement.Offset((Direction)(((int)d - 1) & 0x7), ref xLeft, ref yLeft); + Movement.Offset((Direction)(((int)d + 1) & 0x7), ref xRight, ref yRight); - var moveIsOk = false; + if (xForward < 0 || yForward < 0 || xForward >= map.Width || yForward >= map.Height) + { + newZ = 0; + return false; + } - var stepTop = startTop + StepHeight; - var checkTop = startZ + PersonHeight; + var itemsStart = _pools[0]; + var itemsForward = _pools[1]; + var itemsLeft = _pools[2]; + var itemsRight = _pools[3]; - var ignoreDoors = AlwaysIgnoreDoors || !m.Alive || m.Body.BodyID == 0x3DB || m.IsDeadBondedPet; - var ignoreSpellFields = m is PlayerMobile && map != Map.Felucca; + var ignoreMovableImpassables = IgnoreMovableImpassables; + var reqFlags = ImpassableSurface; - int testTop; + if (m.CanSwim) + { + reqFlags |= TileFlag.Wet; + } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + var mobsForward = _mobPools[0]; + var mobsLeft = _mobPools[1]; + var mobsRight = _mobPools[2]; + + var checkMobs = (m as BaseCreature)?.Controlled == false && (xForward != _goal.X || yForward != _goal.Y); + + if (checkMobs) + { + foreach (var mob in map.GetMobilesInRange(loc, 1)) { - var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; - - if (m.Flying && itemData.Name.InsensitiveEquals("hover over")) + if (mob.AtPoint(xForward, yForward)) { - newZ = tile.Z; - return true; + mobsForward.Add(mob); } - - // Stygian Dragon - if (m.Body == 826 && map == Map.TerMur) + else if (checkDiagonals && mob.AtPoint(xLeft, yLeft)) { - if (x is >= 307 and <= 354 && y is >= 126 and <= 192) - { - if (tile.Z > newZ) - { - newZ = tile.Z; - } - - moveIsOk = true; - } - else if (x is >= 42 and <= 89 && y is >= 333 and <= 399 or >= 531 and <= 597 or >= 739 and <= 805) - { - if (tile.Z > newZ) - { - newZ = tile.Z; - } - - moveIsOk = true; - } + mobsLeft.Add(mob); } + else if (checkDiagonals && mob.AtPoint(xRight, yRight)) + { + mobsRight.Add(mob); + } + } + } - var notWater = !itemData.Wet; + foreach (var item in map.GetItemsInRange(loc, 1)) + { + if (ignoreMovableImpassables && item.Movable && item.ItemData.ImpassableSurface) + { + continue; + } - /* - * To move we must satisfy the following: - * 1. Item is a _passable_ surface and Mob can walk -or- - * 2. Item is water and Mob can swim - */ - if ( - (!itemData.Surface || itemData.Impassable) && (!canSwim || notWater) || - cantWalk && notWater - ) + if (!item.ItemData[reqFlags] || item.ItemID > TileData.MaxItemValue || item.Parent != null) + { + continue; + } + + if (item is BaseMulti) + { + continue; + } + + if (item.AtPoint(xStart, yStart)) + { + itemsStart.Add(item); + } + else if (item.AtPoint(xForward, yForward)) + { + itemsForward.Add(item); + } + else if (checkDiagonals && item.AtPoint(xLeft, yLeft)) + { + itemsLeft.Add(item); + } + else if (checkDiagonals && item.AtPoint(xRight, yRight)) + { + itemsRight.Add(item); + } + } + + GetStartZ(m, map, loc, itemsStart, out var startZ, out var startTop); + + var moveIsOk = Check(map, m, itemsForward, mobsForward, xForward, yForward, startTop, startZ, out newZ); + + if (moveIsOk && checkDiagonals) + { + if (m.Player && m.AccessLevel < AccessLevel.GameMaster) + { + if (!Check(map, m, itemsLeft, mobsLeft, xLeft, yLeft, startTop, startZ, out _) || + !Check(map, m, itemsRight, mobsRight, xRight, yRight, startTop, startZ, out _)) + { + moveIsOk = false; + } + } + else + { + if (!Check(map, m, itemsLeft, mobsLeft, xLeft, yLeft, startTop, startZ, out _) && + !Check(map, m, itemsRight, mobsRight, xRight, yRight, startTop, startZ, out _)) + { + moveIsOk = false; + } + } + } + + for (int i = 0, c = checkDiagonals ? 4 : 2; i < c; ++i) + { + _pools[i].Clear(); + } + + for (int i = 0, c = checkDiagonals ? 3 : 1; i < c; ++i) + { + _mobPools[i].Clear(); + } + + if (!moveIsOk) + { + newZ = startZ; + } + + return moveIsOk; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CheckMovement(Mobile m, Direction d, out int newZ) => CheckMovement(m, m.Map, m.Location, d, out newZ); + + private static bool IsOk( + bool ignoreDoors, bool ignoreSpellFields, int ourZ, int ourTop, Map map, int x, int y, List items + ) + { + foreach (var check in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var itemData = TileData.ItemTable[check.ID & TileData.MaxItemValue]; + + if (itemData.ImpassableSurface) + { + var checkZ = check.Z; + var checkTop = checkZ + itemData.CalcHeight; + + if (checkTop > ourZ && ourTop > checkZ) + { + return false; + } + } + } + + for (var i = 0; i < items.Count; ++i) + { + var item = items[i]; + var itemID = item.ItemID & TileData.MaxItemValue; + var itemData = TileData.ItemTable[itemID]; + + if (itemData.ImpassableSurface) + { + if (ignoreDoors && (itemData.Door || itemID is 0x692 or 0x846 or 0x873 || itemID >= 0x6F5 && itemID <= 0x6F6)) { continue; } - var itemZ = tile.Z; - var itemTop = itemZ; - var ourZ = itemZ + itemData.CalcHeight; - testTop = checkTop; - - if (moveIsOk) + if (ignoreSpellFields && itemID is 0x82 or 0x3946 or 0x3956) { - var cmp = (ourZ - m.Z).Abs() - (newZ - m.Z).Abs(); + continue; + } - if (cmp > 0 || cmp == 0 && ourZ > newZ) + var checkZ = item.Z; + var checkTop = checkZ + itemData.CalcHeight; + + if (checkTop > ourZ && ourTop > checkZ) + { + return false; + } + } + } + + return true; + } + + private static bool Check( + Map map, + Mobile m, + List items, + List mobiles, + int x, + int y, + int startTop, + int startZ, + out int newZ + ) + { + newZ = 0; + + var cantWalk = m.CantWalk; + var canSwim = m.CanSwim; + var landTile = map.Tiles.GetLandTile(x, y); + var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var impassable = (flags & TileFlag.Impassable) != 0; + + // Impassable + swim on water is ok, otherwise block if cannot walk or impassable + var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); + + var considerLand = !landTile.Ignored; + + map.GetAverageZ(x, y, out var landZ, out var landCenter, out _); + + var moveIsOk = false; + + var stepTop = startTop + StepHeight; + var checkTop = startZ + PersonHeight; + + var ignoreDoors = AlwaysIgnoreDoors || !m.Alive || m.Body.BodyID == 0x3DB || m.IsDeadBondedPet; + var ignoreSpellFields = m is PlayerMobile && map != Map.Felucca; + + int testTop; + + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + + if (m.Flying && itemData.Name.InsensitiveEquals("hover over")) + { + newZ = tile.Z; + return true; + } + + // Stygian Dragon + if (m.Body == 826 && map == Map.TerMur) + { + if (x is >= 307 and <= 354 && y is >= 126 and <= 192) + { + if (tile.Z > newZ) { - continue; + newZ = tile.Z; } + + moveIsOk = true; } - - if (ourZ + PersonHeight > testTop) + else if (x is >= 42 and <= 89 && y is >= 333 and <= 399 or >= 531 and <= 597 or >= 739 and <= 805) { - testTop = ourZ + PersonHeight; - } + if (tile.Z > newZ) + { + newZ = tile.Z; + } - if (!itemData.Bridge) - { - itemTop += itemData.Height; - } - - if (stepTop < itemTop) - { - continue; - } - - var landCheck = itemZ + Math.Min(itemData.Height, StepHeight); - - if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ) - { - continue; - } - - if (IsOk(ignoreDoors, ignoreSpellFields, ourZ, testTop, map, x, y, items)) - { - newZ = ourZ; moveIsOk = true; } } - for (var i = 0; i < items.Count; ++i) + var notWater = !itemData.Wet; + + /* + * To move we must satisfy the following: + * 1. Item is a _passable_ surface and Mob can walk -or- + * 2. Item is water and Mob can swim + */ + if ( + (!itemData.Surface || itemData.Impassable) && (!canSwim || notWater) || + cantWalk && notWater + ) { - var item = items[i]; - var itemData = item.ItemData; - - if (m.Flying && itemData.Name.InsensitiveEquals("hover over")) - { - newZ = item.Z; - return true; - } - - var notWater = !itemData.Wet; - - /* - * To move we must satisfy the following: - * 1. Item is not movable - * 2. Item is a _passable_ surface and Mob can walk -or- - * Item is water and Mob can swim - */ - if ( - item.Movable || - (!itemData.Surface || itemData.Impassable) && (!canSwim || notWater) || - cantWalk && notWater - ) - { - continue; - } - - var itemZ = item.Z; - var itemTop = itemZ; - var ourZ = itemZ + itemData.CalcHeight; - testTop = checkTop; - - if (moveIsOk) - { - var cmp = (ourZ - m.Z).Abs() - (newZ - m.Z).Abs(); - - if (cmp > 0 || cmp == 0 && ourZ > newZ) - { - continue; - } - } - - if (ourZ + PersonHeight > testTop) - { - testTop = ourZ + PersonHeight; - } - - if (!itemData.Bridge) - { - itemTop += itemData.Height; - } - - if (stepTop < itemTop) - { - continue; - } - - var landCheck = itemZ + Math.Min(itemData.Height, StepHeight); - - if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ) - { - continue; - } - - if (IsOk(ignoreDoors, ignoreSpellFields, ourZ, testTop, map, x, y, items)) - { - newZ = ourZ; - moveIsOk = true; - } - } - - if (!considerLand || landBlocks || stepTop < landZ) - { - return moveIsOk; + continue; } + var itemZ = tile.Z; + var itemTop = itemZ; + var ourZ = itemZ + itemData.CalcHeight; testTop = checkTop; - if (landCenter + PersonHeight > testTop) - { - testTop = landCenter + PersonHeight; - } - - var shouldCheck = true; - if (moveIsOk) { - var cmp = (landCenter - m.Z).Abs() - (newZ - m.Z).Abs(); + var cmp = (ourZ - m.Z).Abs() - (newZ - m.Z).Abs(); - if (cmp > 0 || cmp == 0 && landCenter > newZ) + if (cmp > 0 || cmp == 0 && ourZ > newZ) { - shouldCheck = false; + continue; } } - if (shouldCheck && IsOk(ignoreDoors, ignoreSpellFields, landCenter, testTop, map, x, y, items)) + if (ourZ + PersonHeight > testTop) { - newZ = landCenter; + testTop = ourZ + PersonHeight; + } + + if (!itemData.Bridge) + { + itemTop += itemData.Height; + } + + if (stepTop < itemTop) + { + continue; + } + + var landCheck = itemZ + Math.Min(itemData.Height, StepHeight); + + if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ) + { + continue; + } + + if (IsOk(ignoreDoors, ignoreSpellFields, ourZ, testTop, map, x, y, items)) + { + newZ = ourZ; moveIsOk = true; } + } + + for (var i = 0; i < items.Count; ++i) + { + var item = items[i]; + var itemData = item.ItemData; + + if (m.Flying && itemData.Name.InsensitiveEquals("hover over")) + { + newZ = item.Z; + return true; + } + + var notWater = !itemData.Wet; + + /* + * To move we must satisfy the following: + * 1. Item is not movable + * 2. Item is a _passable_ surface and Mob can walk -or- + * Item is water and Mob can swim + */ + if ( + item.Movable || + (!itemData.Surface || itemData.Impassable) && (!canSwim || notWater) || + cantWalk && notWater + ) + { + continue; + } + + var itemZ = item.Z; + var itemTop = itemZ; + var ourZ = itemZ + itemData.CalcHeight; + testTop = checkTop; if (moveIsOk) { - for (var i = 0; moveIsOk && i < mobiles.Count; ++i) - { - var mob = mobiles[i]; + var cmp = (ourZ - m.Z).Abs() - (newZ - m.Z).Abs(); - if (mob != m && mob.Z + 15 > newZ && newZ + 15 > mob.Z && !CanMoveOver(m, mob)) - { - moveIsOk = false; - } + if (cmp > 0 || cmp == 0 && ourZ > newZ) + { + continue; } } + if (ourZ + PersonHeight > testTop) + { + testTop = ourZ + PersonHeight; + } + + if (!itemData.Bridge) + { + itemTop += itemData.Height; + } + + if (stepTop < itemTop) + { + continue; + } + + var landCheck = itemZ + Math.Min(itemData.Height, StepHeight); + + if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ) + { + continue; + } + + if (IsOk(ignoreDoors, ignoreSpellFields, ourZ, testTop, map, x, y, items)) + { + newZ = ourZ; + moveIsOk = true; + } + } + + if (!considerLand || landBlocks || stepTop < landZ) + { return moveIsOk; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool CanMoveOver(Mobile m, Mobile t) => - !t.Alive || !m.Alive || t.IsDeadBondedPet || m.IsDeadBondedPet || t.Hidden && t.AccessLevel > AccessLevel.Player; + testTop = checkTop; - private static void GetStartZ(Mobile m, Map map, Point3D loc, List itemList, out int zLow, out int zTop) + if (landCenter + PersonHeight > testTop) { - int xCheck = loc.X, yCheck = loc.Y; + testTop = landCenter + PersonHeight; + } - var landTile = map.Tiles.GetLandTile(xCheck, yCheck); - var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; - var impassable = (flags & TileFlag.Impassable) != 0; + var shouldCheck = true; - // Impassable + swim on water is ok, otherwise block if cannot walk or impassable - var landBlocks = (m.CantWalk || impassable) && !(impassable && m.CanSwim && (flags & TileFlag.Wet) != 0); + if (moveIsOk) + { + var cmp = (landCenter - m.Z).Abs() - (newZ - m.Z).Abs(); - map.GetAverageZ(xCheck, yCheck, out var landZ, out var landCenter, out var landTop); - - var considerLand = !landTile.Ignored; - - var zCenter = zLow = zTop = 0; - var isSet = false; - - if (considerLand && !landBlocks && loc.Z >= landCenter) + if (cmp > 0 || cmp == 0 && landCenter > newZ) { - zLow = landZ; - zCenter = landCenter; + shouldCheck = false; + } + } - zTop = landTop; + if (shouldCheck && IsOk(ignoreDoors, ignoreSpellFields, landCenter, testTop, map, x, y, items)) + { + newZ = landCenter; + moveIsOk = true; + } - isSet = true; + if (moveIsOk) + { + for (var i = 0; moveIsOk && i < mobiles.Count; ++i) + { + var mob = mobiles[i]; + + if (mob != m && mob.Z + 15 > newZ && newZ + 15 > mob.Z && !CanMoveOver(m, mob)) + { + moveIsOk = false; + } + } + } + + return moveIsOk; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool CanMoveOver(Mobile m, Mobile t) => + !t.Alive || !m.Alive || t.IsDeadBondedPet || m.IsDeadBondedPet || t.Hidden && t.AccessLevel > AccessLevel.Player; + + private static void GetStartZ(Mobile m, Map map, Point3D loc, List itemList, out int zLow, out int zTop) + { + int xCheck = loc.X, yCheck = loc.Y; + + var landTile = map.Tiles.GetLandTile(xCheck, yCheck); + var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var impassable = (flags & TileFlag.Impassable) != 0; + + // Impassable + swim on water is ok, otherwise block if cannot walk or impassable + var landBlocks = (m.CantWalk || impassable) && !(impassable && m.CanSwim && (flags & TileFlag.Wet) != 0); + + map.GetAverageZ(xCheck, yCheck, out var landZ, out var landCenter, out var landTop); + + var considerLand = !landTile.Ignored; + + var zCenter = zLow = zTop = 0; + var isSet = false; + + if (considerLand && !landBlocks && loc.Z >= landCenter) + { + zLow = landZ; + zCenter = landCenter; + + zTop = landTop; + + isSet = true; + } + + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(xCheck, yCheck)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var calcTop = tile.Z + id.CalcHeight; + + if (isSet && calcTop < zCenter || loc.Z < calcTop || !id.Surface && !(m.CanSwim && id.Wet)) + { + continue; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(xCheck, yCheck)) + if (m.CantWalk && !id.Wet) { - var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; - var calcTop = tile.Z + id.CalcHeight; - - if (isSet && calcTop < zCenter || loc.Z < calcTop || !id.Surface && !(m.CanSwim && id.Wet)) - { - continue; - } - - if (m.CantWalk && !id.Wet) - { - continue; - } - - zLow = tile.Z; - zCenter = calcTop; - - var top = tile.Z + id.Height; - - if (!isSet || top > zTop) - { - zTop = top; - } - - isSet = true; + continue; } - for (var i = 0; i < itemList.Count; ++i) + zLow = tile.Z; + zCenter = calcTop; + + var top = tile.Z + id.Height; + + if (!isSet || top > zTop) { - var item = itemList[i]; - var id = item.ItemData; - var calcTop = item.Z + id.CalcHeight; - - if (isSet && calcTop < zCenter || loc.Z < calcTop || !id.Surface && !(m.CanSwim && id.Wet)) - { - continue; - } - - if (m.CantWalk && !id.Wet) - { - continue; - } - - zLow = item.Z; - zCenter = calcTop; - - var top = item.Z + id.Height; - - if (!isSet || top > zTop) - { - zTop = top; - } - - isSet = true; + zTop = top; } - if (!isSet) + isSet = true; + } + + for (var i = 0; i < itemList.Count; ++i) + { + var item = itemList[i]; + var id = item.ItemData; + var calcTop = item.Z + id.CalcHeight; + + if (isSet && calcTop < zCenter || loc.Z < calcTop || !id.Surface && !(m.CanSwim && id.Wet)) { - zLow = zTop = loc.Z; + continue; } - else if (loc.Z > zTop) + + if (m.CantWalk && !id.Wet) { - zTop = loc.Z; + continue; } + + zLow = item.Z; + zCenter = calcTop; + + var top = item.Z + id.Height; + + if (!isSet || top > zTop) + { + zTop = top; + } + + isSet = true; + } + + if (!isSet) + { + zLow = zTop = loc.Z; + } + else if (loc.Z > zTop) + { + zTop = loc.Z; } } } diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index 583b1df77..eb5d2f9d4 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -8,115 +8,114 @@ using Server.PathAlgorithms.BitmapAStar; using Server.Spells; using Server.Targeting; -namespace Server +namespace Server; + +public sealed class MovementPath { - public sealed class MovementPath + public MovementPath(Mobile m, Point3D goal) { - public MovementPath(Mobile m, Point3D goal) + var start = m.Location; + var map = m.Map; + + Map = map; + Start = start; + Goal = goal; + + if (map == null || map == Map.Internal) { - var start = m.Location; - var map = m.Map; - - Map = map; - Start = start; - Goal = goal; - - if (map == null || map == Map.Internal) - { - return; - } - - if (Utility.InRange(start, goal, 1)) - { - return; - } - - try - { - var alg = OverrideAlgorithm ?? BitmapAStarAlgorithm.Instance; - - if (alg?.CheckCondition(m, map, start, goal) == true) - { - Directions = alg.Find(m, map, start, goal); - } - } - catch (Exception e) - { - Console.WriteLine("Warning: {0}: Pathing error from {1} to {2}", e.GetType().Name, start, goal); - } + return; } - public Map Map { get; } - - public Point3D Start { get; } - - public Point3D Goal { get; } - - public Direction[] Directions { get; } - - public bool Success => Directions?.Length > 0; - - public static PathAlgorithm OverrideAlgorithm { get; set; } - - public static void Configure() + if (Utility.InRange(start, goal, 1)) { - CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); - CacheEvictionTimer.Configure(); - PathCacheCommands.Configure(); + return; } - [Usage("Path")] - [Description("Draws a path from your current location to a targeted location.")] - public static void Path_OnCommand(CommandEventArgs e) + try { - e.Mobile.BeginTarget(-1, true, TargetFlags.None, Path_OnTarget); - e.Mobile.SendMessage("Target a location and a path will be drawn there."); - } + var alg = OverrideAlgorithm ?? BitmapAStarAlgorithm.Instance; - private static void Path(Mobile from, IPoint3D p, PathAlgorithm alg, string name, int zOffset) - { - OverrideAlgorithm = alg; - - var watch = new Stopwatch(); - watch.Start(); - var path = new MovementPath(from, new Point3D(p)); - watch.Stop(); - - if (!path.Success) + if (alg?.CheckCondition(m, map, start, goal) == true) { - from.SendMessage($"{name} path failed: {watch.ElapsedMilliseconds}ms"); - } - else - { - from.SendMessage($"{name} path success: {watch.ElapsedMilliseconds}ms"); - - var x = from.X; - var y = from.Y; - var z = from.Z; - - WayPoint waypoint = null; - - for (var i = 0; i < path.Directions.Length; ++i) - { - Movement.Movement.Offset(path.Directions[i], ref x, ref y); - - waypoint = new WayPoint(waypoint); - waypoint.MoveToWorld(new Point3D(x, y, z + zOffset), from.Map); - } + Directions = alg.Find(m, map, start, goal); } } - - public static void Path_OnTarget(Mobile from, object targeted) + catch (Exception e) { - if (targeted is not IPoint3D p) - { - return; - } - - SpellHelper.GetSurfaceTop(ref p); - - Path(from, p, BitmapAStarAlgorithm.Instance, "Bitmap", 0); - OverrideAlgorithm = null; + Console.WriteLine("Warning: {0}: Pathing error from {1} to {2}", e.GetType().Name, start, goal); } } + + public Map Map { get; } + + public Point3D Start { get; } + + public Point3D Goal { get; } + + public Direction[] Directions { get; } + + public bool Success => Directions?.Length > 0; + + public static PathAlgorithm OverrideAlgorithm { get; set; } + + public static void Configure() + { + CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); + CacheEvictionTimer.Configure(); + PathCacheCommands.Configure(); + } + + [Usage("Path")] + [Description("Draws a path from your current location to a targeted location.")] + public static void Path_OnCommand(CommandEventArgs e) + { + e.Mobile.BeginTarget(-1, true, TargetFlags.None, Path_OnTarget); + e.Mobile.SendMessage("Target a location and a path will be drawn there."); + } + + private static void Path(Mobile from, IPoint3D p, PathAlgorithm alg, string name, int zOffset) + { + OverrideAlgorithm = alg; + + var watch = new Stopwatch(); + watch.Start(); + var path = new MovementPath(from, new Point3D(p)); + watch.Stop(); + + if (!path.Success) + { + from.SendMessage($"{name} path failed: {watch.ElapsedMilliseconds}ms"); + } + else + { + from.SendMessage($"{name} path success: {watch.ElapsedMilliseconds}ms"); + + var x = from.X; + var y = from.Y; + var z = from.Z; + + WayPoint waypoint = null; + + for (var i = 0; i < path.Directions.Length; ++i) + { + Movement.Movement.Offset(path.Directions[i], ref x, ref y); + + waypoint = new WayPoint(waypoint); + waypoint.MoveToWorld(new Point3D(x, y, z + zOffset), from.Map); + } + } + } + + public static void Path_OnTarget(Mobile from, object targeted) + { + if (targeted is not IPoint3D p) + { + return; + } + + SpellHelper.GetSurfaceTop(ref p); + + Path(from, p, BitmapAStarAlgorithm.Instance, "Bitmap", 0); + OverrideAlgorithm = null; + } } diff --git a/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs b/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs index 1ac86fc40..faba9d2a0 100644 --- a/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs @@ -1,35 +1,29 @@ -namespace Server.PathAlgorithms +namespace Server.PathAlgorithms; + +public abstract class PathAlgorithm { - public abstract class PathAlgorithm + private static readonly Direction[] _calcDirections = { - private static readonly Direction[] _calcDirections = - { - Direction.Up, - Direction.North, - Direction.Right, - Direction.West, - Direction.North, - Direction.East, - Direction.Left, - Direction.South, - Direction.Down - }; + Direction.Up, + Direction.North, + Direction.Right, + Direction.West, + Direction.North, + Direction.East, + Direction.Left, + Direction.South, + Direction.Down + }; - public abstract bool CheckCondition(Mobile m, Map map, Point3D start, Point3D goal); - public abstract Direction[] Find(Mobile m, Map map, Point3D start, Point3D goal); + public abstract bool CheckCondition(Mobile m, Map map, Point3D start, Point3D goal); + public abstract Direction[] Find(Mobile m, Map map, Point3D start, Point3D goal); - public static Direction GetDirection(int xSource, int ySource, int xDest, int yDest) - { - var x = xDest + 1 - xSource; - var y = yDest + 1 - ySource; - var v = y * 3 + x; + public static Direction GetDirection(int xSource, int ySource, int xDest, int yDest) + { + var x = xDest + 1 - xSource; + var y = yDest + 1 - ySource; + var v = y * 3 + x; - if (v is < 0 or >= 9) - { - return Direction.North; - } - - return _calcDirections[v]; - } + return v is < 0 or >= 9 ? Direction.North : _calcDirections[v]; } } diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index c41b9b484..e53746ebd 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.IO; using Server.Engines.Pathing.Cache; @@ -95,12 +96,12 @@ public static class PathCacheCommands var totalChunks = 0; var totalMaps = 0; - var sw = System.Diagnostics.Stopwatch.StartNew(); + var sw = Stopwatch.StartNew(); for (var i = 0; i < Map.Maps.Length; i++) { var map = Map.Maps[i]; - if (map == null || map == Map.Internal || (only.HasValue && map.MapID != only.Value)) + if (map == null || map == Map.Internal || only.HasValue && map.MapID != only.Value) { continue; } diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index fa2557f48..3a0a56fa5 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -1,155 +1,152 @@ using System; using CalcMoves = Server.Movement.Movement; -namespace Server +namespace Server; + +public class PathFollower { - public class PathFollower + private static bool Enabled; + private static readonly TimeSpan RepathDelay = TimeSpan.FromSeconds(2.0); + + private readonly Mobile m_From; + private int m_Index; + private DateTime m_LastPathTime; + private Point3D m_Next, m_LastGoalLoc; + private MovementPath m_Path; + + public PathFollower(Mobile from, IPoint3D goal) { - private static bool Enabled; - private static readonly TimeSpan RepathDelay = TimeSpan.FromSeconds(2.0); + m_From = from; + Goal = goal; + } - private readonly Mobile m_From; - private int m_Index; - private DateTime m_LastPathTime; - private Point3D m_Next, m_LastGoalLoc; - private MovementPath m_Path; + public MoveMethod Mover { get; set; } - public PathFollower(Mobile from, IPoint3D goal) + public IPoint3D Goal { get; } + + public static void Configure() + { + Enabled = ServerConfiguration.GetOrUpdateSetting("pathfinding.enable", true); + } + + public MoveResult Move(Direction d) => + Mover?.Invoke(d, true) ?? (m_From.Move(d) ? MoveResult.Success : MoveResult.Blocked); + + public Point3D GetGoalLocation() => (Goal as Item)?.GetWorldLocation() ?? new Point3D(Goal); + + public void Advance(ref Point3D p, int index) + { + if (m_Path?.Success == true) { - m_From = from; - Goal = goal; - } + var dirs = m_Path.Directions; - public MoveMethod Mover { get; set; } - - public IPoint3D Goal { get; } - - public static void Configure() - { - Enabled = ServerConfiguration.GetOrUpdateSetting("pathfinding.enable", true); - } - - public MoveResult Move(Direction d) => - Mover?.Invoke(d, true) ?? (m_From.Move(d) ? MoveResult.Success : MoveResult.Blocked); - - public Point3D GetGoalLocation() => (Goal as Item)?.GetWorldLocation() ?? new Point3D(Goal); - - public void Advance(ref Point3D p, int index) - { - if (m_Path?.Success == true) + if (index >= 0 && index < dirs.Length) { - var dirs = m_Path.Directions; - - if (index >= 0 && index < dirs.Length) - { - CalcMoves.Offset(dirs[index], ref p); - } + CalcMoves.Offset(dirs[index], ref p); } } + } - public void ForceRepath() + public void ForceRepath() + { + m_Path = null; + } + + public bool CheckPath() + { + if (!Enabled) { - m_Path = null; + return false; } - public bool CheckPath() + var goal = GetGoalLocation(); + + if (m_Path != null && (m_Path.Success && goal == m_LastGoalLoc || m_LastPathTime + RepathDelay > Core.Now) && + !(m_Path.Success && Check(m_From.Location, m_LastGoalLoc, 0))) { - if (!Enabled) - { - return false; - } + return false; + } - var goal = GetGoalLocation(); + m_LastPathTime = Core.Now; + m_LastGoalLoc = goal; - if (m_Path != null && (m_Path.Success && goal == m_LastGoalLoc || m_LastPathTime + RepathDelay > Core.Now) && - !(m_Path.Success && Check(m_From.Location, m_LastGoalLoc, 0))) - { - return false; - } + m_Path = new MovementPath(m_From, goal); - m_LastPathTime = Core.Now; - m_LastGoalLoc = goal; + m_Index = 0; + m_Next = m_From.Location; - m_Path = new MovementPath(m_From, goal); + Advance(ref m_Next, m_Index); - m_Index = 0; - m_Next = m_From.Location; + return true; + } - Advance(ref m_Next, m_Index); + public static bool Check(Point3D loc, Point3D goal, int range) => + Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); + public bool Follow(bool run, int range) + { + var goal = GetGoalLocation(); + Direction d; + + if (Check(m_From.Location, goal, range)) + { return true; } - public static bool Check(Point3D loc, Point3D goal, int range) => - Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); + var repathed = CheckPath(); - public bool Follow(bool run, int range) + if (!(Enabled && m_Path.Success)) { - var goal = GetGoalLocation(); - Direction d; - - if (Check(m_From.Location, goal, range)) - { - return true; - } - - var repathed = CheckPath(); - - if (!(Enabled && m_Path.Success)) - { - d = m_From.GetDirectionTo(goal, run); - m_From.SetDirection(d); - - return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn - && Check(m_From.Location, goal, range); - } - - d = m_From.GetDirectionTo(m_Next, run); + d = m_From.GetDirectionTo(goal, run); m_From.SetDirection(d); - var res = Move(d); - if (res == MoveResult.Blocked) + return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); + } + + d = m_From.GetDirectionTo(m_Next, run); + m_From.SetDirection(d); + var res = Move(d); + + if (res == MoveResult.Blocked) + { + if (repathed) { - if (repathed) - { - return false; - } + return false; + } - m_Path = null; - CheckPath(); + m_Path = null; + CheckPath(); - if (!m_Path!.Success) - { - d = m_From.GetDirectionTo(goal); - m_From.SetDirection(d); - - return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn - && Check(m_From.Location, goal, range); - } - - d = m_From.GetDirectionTo(m_Next); + if (!m_Path!.Success) + { + d = m_From.GetDirectionTo(goal); m_From.SetDirection(d); - if (Move(d) == MoveResult.Blocked) - { - return false; - } + return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - if (m_From.X == m_Next.X && m_From.Y == m_Next.Y) + d = m_From.GetDirectionTo(m_Next); + m_From.SetDirection(d); + + if (Move(d) == MoveResult.Blocked) { - if (m_From.Z == m_Next.Z) - { - ++m_Index; - Advance(ref m_Next, m_Index); - } - else - { - m_Path = null; - } + return false; } - - return Check(m_From.Location, goal, range); } + + if (m_From.X == m_Next.X && m_From.Y == m_Next.Y) + { + if (m_From.Z == m_Next.Z) + { + ++m_Index; + Advance(ref m_Next, m_Index); + } + else + { + m_Path = null; + } + } + + return Check(m_From.Location, goal, range); } } diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index f9696b246..40ae35917 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -232,9 +232,14 @@ whole-file) and bounded RAM (only touched chunks materialize, LRU-capped): zstd's large-window advantage doesn't apply at 256-cell record granularity. Compression runs at bake time only (offline); decompression is one-time per chunk (LRU-cached after). **Calibrated: v6 124.7 MB → v7 19.2 MB (−85%); −97% vs the original 565 MB.** - - **#3b — index compaction (remaining, ~2 MB).** The v7 file's residual is the still-uncompacted - index (20 B/chunk × 114 K ≈ 2.3 MB). Compact it: chunks in fixed sweep order → implicit keys, - delta-encoded offsets, lengths derivable → ~3 B/chunk ≈ 0.34 MB, landing the file at ~17 MB. + - **#3b — index compaction: *shipped as format v8.*** The v7 file's residual was the + uncompacted index (20 B/chunk × 114 K ≈ 2.3 MB). The trailer now stores only + `{ u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength }` per chunk (8 B), in record + write order; the per-chunk file offset is dropped and reconstructed by cumulative `recordLength` + from `HeaderSize` at open. No record reordering, no varint — a fixed-stride, low-risk change to + the load-bearing index. **Calibrated: v7 19.2 MB → v8 17.9 MB.** (A varint/implicit-key scheme + could shave the index toward ~0.34 MB for another ~0.6 MB, at the cost of variable-stride + parsing and record reordering — not worth it on an already −97% file.) **Do first:** a uniformity audit over a baked facet (how many chunks are fully uniform / have all-zero Z residuals?) to size the #1/#2 win before writing any format code. Each technique is a @@ -265,3 +270,6 @@ Validate size **and** read-latency vs the corpus in the benchmark repo after eac records): libdeflate VeryHigh **16.5 MB** vs Brotli q11 16.4 / zstd L19–22 17.4; decompress **1.83 µs/chunk** (libdeflate) vs 2.21 (zstd). The remaining ~2.3 MB is the uncompacted index (→ #3b). +- *Calibrated v8 (`BakeMap`-measured, full Trammel):* **#3b index compaction (20 → 8 B/chunk): + 19.2 MB → 17.9 MB.** Roadmap end-to-end: **565 MB → 17.9 MB (−96.8%)** across #1 uniform elision + (v5) → #2 predictive-Z (v6) → #3a per-chunk libdeflate (v7) → #3b compact index (v8).