feat(pathfinding): .swb format v8 compact index (#3b) (#2471)

## 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.
This commit is contained in:
Kamron Batman 2026-06-07 01:22:20 -07:00 committed by GitHub
parent c265bcbb5e
commit c7aaf33de9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 960 additions and 868 deletions

View file

@ -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);
}

View file

@ -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
{

View file

@ -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); }
}
}

View file

@ -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.
/// </summary>
private static bool RequiresSlowPath(Mobile m) =>
m is BaseCreature bc && bc.CanFly;
private static bool RequiresSlowPath(Mobile m) => m is BaseCreature bc && bc.CanFly;
}

View file

@ -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()
{

View file

@ -22,7 +22,7 @@ public sealed class StepCache
private readonly Dictionary<long, StepChunk> _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<long> _keysList = new();
private readonly List<long> _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
/// </summary>
public void BeginFindGeneration()
{
unchecked { _findGeneration++; }
if (_findGeneration == 0) { _findGeneration = 1; }
unchecked { CurrentFindGeneration++; }
if (CurrentFindGeneration == 0)
{
CurrentFindGeneration = 1;
}
}
/// <summary>Test-only: read the current Find generation.</summary>
internal uint CurrentFindGeneration => _findGeneration;
internal uint CurrentFindGeneration { get; private set; }
/// <summary>
/// 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))
{

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).</summary>
private const int BytesPerChunkBase =
@ -141,12 +128,6 @@ internal static class StepCacheFile
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
/// <summary>Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays.</summary>
private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk;
/// <summary>Strata trailer overhead when present: 256×u16 offset table + u32 data length.</summary>
private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint);
/// <summary>
/// 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<ulong, (ulong offset, uint length)>((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));
/// <summary>
/// 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 &lt; 8) or WetMask (d &gt;= 8)
/// with direction bit (d &amp; 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 &lt; 8) or WetMask (d &gt;= 8) with
/// direction bit (d &amp; 7).
/// </summary>
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))
};
/// <summary>
@ -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<sbyte> 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),

View file

@ -4,17 +4,19 @@ namespace Server.Engines.Pathing.Cache;
/// <summary>
/// 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.
/// </summary>
internal sealed class StepChunk
{
public const int CellsPerChunk = 256; // 16 x 16
/// <summary>Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here.</summary>
/// <summary>Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i".
/// Raw — no diagonal corner-cut applied here.</summary>
public readonly byte[] WalkMask = new byte[CellsPerChunk];
/// <summary>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.</summary>
/// <summary>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.</summary>
public readonly byte[] WetMask = new byte[CellsPerChunk];
public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk];
@ -103,25 +105,6 @@ internal sealed class StepChunk
}
}
/// <summary>Test/serialization hook: install pre-built swim-layer arrays. Pass nulls to clear.</summary>
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;
}
/// <summary>Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays.</summary>
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<byte> StrataData =>
_strataData == null ? ReadOnlySpan<byte>.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.
/// </summary>
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]);

View file

@ -242,8 +242,8 @@ public static class StepProbe
/// </summary>
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
/// </summary>
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.
/// </summary>
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.
/// </summary>
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;

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}
}

View file

@ -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];
}
}

View file

@ -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;
}

View file

@ -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);
}
}