diff --git a/Projects/Server/TileMatrix/TileMatrix.cs b/Projects/Server/TileMatrix/TileMatrix.cs
index 17ed0d900..333cea53e 100644
--- a/Projects/Server/TileMatrix/TileMatrix.cs
+++ b/Projects/Server/TileMatrix/TileMatrix.cs
@@ -17,6 +17,7 @@ using Server.Logging;
using System;
using System.Collections.Generic;
using System.IO;
+using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -48,6 +49,16 @@ public class TileMatrix
public FileStream DataStream { get; }
public BinaryReader IndexReader { get; }
+ ///
+ /// XxHash3 fingerprint over the bytes of the three on-disk files for this map
+ /// (mapX.mul / .uop, staidxX.mul, staticsX.mul). The .mul format has no built-in
+ /// CRC and CentredSharp / UOFiddler rewrite these files in place, so this is the
+ /// only way to detect "client patched / mapper saved" changes that invalidate
+ /// downstream caches keyed on map data. Computed once at construction; never
+ /// changes for the lifetime of the TileMatrix.
+ ///
+ public ulong MapFilesFingerprint { get; }
+
public static bool Pre6000ClientSupport { get; private set; }
public static void Configure()
@@ -140,6 +151,12 @@ public class TileMatrix
{
logger.Warning("{File} was not found.", $"statics{fileIndex}.mul");
}
+
+ // Stream all three on-disk files through XxHash3 once. Saves + restores
+ // each stream's position so this is invisible to the rest of the constructor
+ // (UOP path has already read part of MapStream by now). Cost is ~5-10ms per
+ // map at boot — paid once, then MapFilesFingerprint is a constant.
+ MapFilesFingerprint = ComputeMapFilesFingerprint();
}
_emptyStaticBlock = new StaticTile[8][][];
@@ -164,6 +181,27 @@ public class TileMatrix
Patch = new TileMatrixPatch(this, fileIndex);
}
+ private ulong ComputeMapFilesFingerprint()
+ {
+ var hasher = HashUtility.CreateXxHash3();
+ AppendStreamFromStart(hasher, MapStream);
+ AppendStreamFromStart(hasher, IndexStream);
+ AppendStreamFromStart(hasher, DataStream);
+ return hasher.GetCurrentHashAsUInt64();
+ }
+
+ private static void AppendStreamFromStart(XxHash3 hasher, FileStream stream)
+ {
+ if (stream == null)
+ {
+ return;
+ }
+ var originalPosition = stream.Position;
+ stream.Position = 0;
+ hasher.Append(stream);
+ stream.Position = originalPosition;
+ }
+
public StaticTile[][][] EmptyStaticBlock => _emptyStaticBlock;
public void SetStaticBlock(int x, int y, StaticTile[][][] value)
diff --git a/Projects/Server/Utilities/HashUtility.cs b/Projects/Server/Utilities/HashUtility.cs
index 2655cff5d..76f61f911 100644
--- a/Projects/Server/Utilities/HashUtility.cs
+++ b/Projects/Server/Utilities/HashUtility.cs
@@ -14,6 +14,7 @@
*************************************************************************/
using System;
+using System.IO;
using System.IO.Hashing;
using System.Numerics;
using System.Runtime.InteropServices;
@@ -66,6 +67,36 @@ public static class HashUtility
return result;
}
+ ///
+ /// One-shot streaming hash. Reads from the stream's current position to its end and
+ /// returns the XxHash3 result. The caller is responsible for setting the stream
+ /// position before the call (and restoring it afterward, if the stream will be reused).
+ /// Returns 0 on a null or unreadable stream.
+ ///
+ public static ulong ComputeHash64(Stream stream)
+ {
+ if (stream == null || !stream.CanRead)
+ {
+ return 0;
+ }
+
+ var hasher = _xxHash3 ??= new XxHash3(unchecked((long)xxHash3Seed));
+ hasher.Append(stream);
+
+ var result = hasher.GetCurrentHashAsUInt64();
+ hasher.Reset();
+
+ return result;
+ }
+
+ ///
+ /// Returns a fresh seeded with HashUtility's standard seed.
+ /// Use this when you need to combine multiple inputs into a single hash via
+ /// successive Append calls — the one-shot ComputeHash64 overloads are stateless
+ /// (Reset between callers) and can't compose. Caller owns the returned instance.
+ ///
+ public static XxHash3 CreateXxHash3() => new(unchecked((long)xxHash3Seed));
+
public static uint ComputeHash32(ReadOnlySpan str)
{
if (str == ReadOnlySpan.Empty)
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs
new file mode 100644
index 000000000..da44a4f19
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs
@@ -0,0 +1,163 @@
+using System.IO;
+using Server.Engines.Pathing;
+using Xunit;
+
+namespace Server.Tests.Pathfinding;
+
+[Collection("Sequential Pathfinding Tests")]
+public class PathfindRecorderTests
+{
+ private static string NewTempPath() =>
+ Path.Combine(Path.GetTempPath(), $"pathfind-recorder-{System.Guid.NewGuid():N}.jsonl");
+
+ ///
+ /// Reflection-set the static _outputPath without going through Configure (which
+ /// reads from server.cfg) so tests don't poison the project's server.cfg.
+ ///
+ private static void OverrideOutputPath(string path)
+ {
+ typeof(PathfindRecorder).GetField("_outputPath",
+ System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!
+ .SetValue(null, path);
+ }
+
+ [Fact]
+ public void Disabled_RecordIfEnabled_DoesNothing()
+ {
+ var path = NewTempPath();
+ OverrideOutputPath(path);
+ PathfindRecorder.SetEnabled(false);
+
+ try
+ {
+ var stub = new RecorderStub(World.NewMobile);
+ stub.DefaultMobileInit();
+ stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]);
+
+ PathfindRecorder.RecordIfEnabled(
+ stub, Map.Maps[1],
+ new Point3D(1500, 1600, 0), new Point3D(1498, 1598, 0)
+ );
+
+ stub.Delete();
+ Assert.False(File.Exists(path), "disabled recorder must not create the output file");
+ }
+ finally
+ {
+ if (File.Exists(path)) { File.Delete(path); }
+ }
+ }
+
+ [Fact]
+ public void Enabled_RecordIfEnabled_WritesValidJsonlLine()
+ {
+ var path = NewTempPath();
+ OverrideOutputPath(path);
+ PathfindRecorder.SetEnabled(true);
+
+ try
+ {
+ Assert.True(PathfindRecorder.Enabled);
+
+ var stub = new RecorderStub(World.NewMobile);
+ stub.DefaultMobileInit();
+ stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]);
+
+ PathfindRecorder.RecordIfEnabled(
+ stub, Map.Maps[1],
+ new Point3D(1500, 1600, 5), new Point3D(1498, 1598, 5)
+ );
+
+ // Disable to flush + close before reading.
+ PathfindRecorder.SetEnabled(false);
+ stub.Delete();
+
+ Assert.True(File.Exists(path));
+ var content = File.ReadAllText(path).TrimEnd();
+ Assert.Single(content.Split('\n'));
+ Assert.StartsWith("{\"Name\":\"recorded\"", content);
+ Assert.Contains("\"MapId\":1", content);
+ Assert.Contains("\"StartX\":1500", content);
+ Assert.Contains("\"StartY\":1600", content);
+ Assert.Contains("\"GoalX\":1498", content);
+ Assert.Contains("\"GoalY\":1598", content);
+ Assert.Contains("\"CanSwim\":false", content);
+ Assert.EndsWith("}", content);
+ }
+ finally
+ {
+ PathfindRecorder.SetEnabled(false);
+ if (File.Exists(path)) { File.Delete(path); }
+ }
+ }
+
+ [Fact]
+ public void Enabled_RecordsCapabilityFlagsFromBaseCreature()
+ {
+ var path = NewTempPath();
+ OverrideOutputPath(path);
+ PathfindRecorder.SetEnabled(true);
+
+ try
+ {
+ var stub = new RecorderStub(World.NewMobile);
+ stub.DefaultMobileInit();
+ stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]);
+ stub.CanSwim = true;
+
+ PathfindRecorder.RecordIfEnabled(
+ stub, Map.Maps[1],
+ new Point3D(1500, 1600, 0), new Point3D(1498, 1598, 0)
+ );
+
+ PathfindRecorder.SetEnabled(false);
+ stub.Delete();
+
+ var content = File.ReadAllText(path);
+ Assert.Contains("\"CanSwim\":true", content);
+ Assert.Contains("\"CanFly\":false", content);
+ Assert.Contains("\"CanOpenDoors\":", content); // value depends on BaseCreature defaults
+ Assert.Contains("\"CanMoveOverObstacles\":", content); // — same.
+ }
+ finally
+ {
+ PathfindRecorder.SetEnabled(false);
+ if (File.Exists(path)) { File.Delete(path); }
+ }
+ }
+
+ [Fact]
+ public void SetEnabled_TogglingTwice_IsIdempotent()
+ {
+ var path = NewTempPath();
+ OverrideOutputPath(path);
+
+ try
+ {
+ PathfindRecorder.SetEnabled(true);
+ Assert.True(PathfindRecorder.Enabled);
+
+ PathfindRecorder.SetEnabled(true); // second on: no-op, no error
+ Assert.True(PathfindRecorder.Enabled);
+
+ PathfindRecorder.SetEnabled(false);
+ Assert.False(PathfindRecorder.Enabled);
+
+ PathfindRecorder.SetEnabled(false); // second off: no-op
+ Assert.False(PathfindRecorder.Enabled);
+ }
+ finally
+ {
+ PathfindRecorder.SetEnabled(false);
+ if (File.Exists(path)) { File.Delete(path); }
+ }
+ }
+
+ private sealed class RecorderStub : Server.Mobiles.BaseCreature
+ {
+ public RecorderStub(Serial serial) : base(serial)
+ {
+ Body = 0xC9;
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
index 0917d26bf..e2f288fa5 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
@@ -126,7 +126,7 @@ public class StepCacheFileTests
}
[Fact]
- public void TryOpenLazyReader_TileDataHashMismatch_ReturnsFalse()
+ public void TryOpenLazyReader_FingerprintMismatch_ReturnsFalse()
{
var cache = StepCache.Instance;
cache.Clear();
@@ -139,7 +139,7 @@ public class StepCacheFileTests
{
cache.SaveToFile(path, map.MapID);
- // Corrupt the TileDataHash field at byte offset 12 (Magic[4] + Version[4] + MapId[4]).
+ // Corrupt the Fingerprint field at byte offset 12 (Magic[4] + Version[4] + MapId[4]).
var bytes = File.ReadAllBytes(path);
for (var i = 12; i < 20; i++)
{
diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
index 17e1aa882..8357040ba 100644
--- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
+++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
@@ -108,6 +108,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
return null;
}
+ Server.Engines.Pathing.PathfindRecorder.RecordIfEnabled(m, map, start, goal);
+
_currentMobileNeedsSlowPath = RequiresSlowPath(m);
_currentMobilePlayerStrict = m.Player && m.AccessLevel < AccessLevel.GameMaster;
if (m is BaseCreature creature)
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
index a5f00d397..fb1e7be9e 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
@@ -64,10 +64,21 @@ public sealed class StepCache
/// counters are since-last-clear, not since-startup.
///
public void Clear()
+ {
+ ClearResidentChunks();
+ CloseLazyReaders();
+ }
+
+ ///
+ /// Drop all resident chunks AND zero counters, but keep lazy readers open.
+ /// Useful in benchmark loops that want to measure "first query after boot" cost
+ /// without paying the lazy-reader reopen overhead each iteration. Same intent as
+ /// minus the file-handle teardown.
+ ///
+ public void ClearResidentChunks()
{
_chunks.Clear();
_keysList.Clear();
- CloseLazyReaders();
_hits = 0;
_missesNotBuilt = 0;
_missesDirtyRebuild = 0;
@@ -83,6 +94,51 @@ public sealed class StepCache
// stays bounded by MaxResidentChunks regardless of file size.
private readonly Dictionary _lazyReaders = new();
+ ///
+ /// Combined XxHash3 fingerprint of the running server's TileData flag tables AND
+ /// the per-map .mul / .uop file contents (mapX.mul, staidxX.mul, staticsX.mul).
+ /// Public surface for tooling (benchmark fixtures, bake utilities) that wants to
+ /// detect a stale .swb file without round-tripping through the lazy-open path.
+ ///
+ public static ulong ComputeLiveFingerprint(int mapId) => StepCacheFile.ComputeFingerprint(mapId);
+
+ ///
+ /// Peek at a .swb file's stored fingerprint field without parsing the rest of the
+ /// header. Returns false on missing file, bad magic, or wrong version.
+ ///
+ public static bool TryReadFingerprintFromFile(string path, out ulong fingerprint) =>
+ StepCacheFile.TryReadFingerprint(path, out fingerprint);
+
+ ///
+ /// Walk every chunk in , populate the resident set, then
+ /// save to . Returns the number of chunks written.
+ /// Designed for offline / fixture use; blocks the calling thread for many seconds
+ /// on a full Trammel walk.
+ ///
+ public int BakeMap(int mapId, string path)
+ {
+ var map = Map.Maps[mapId];
+ if (map == null || map == Map.Internal)
+ {
+ return 0;
+ }
+
+ var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize;
+ var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize;
+
+ for (var cy = 0; cy < chunkRows; cy++)
+ {
+ for (var cx = 0; cx < chunkCols; cx++)
+ {
+ // Any sourceZ works — the chunk is built on first access regardless of
+ // whether the query returns Hit or Fallthrough_SourceZMismatch.
+ TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
+ }
+ }
+
+ return SaveToFile(path, mapId);
+ }
+
///
/// Persist all resident chunks for to a .swb file. Returns
/// the number of chunks written. The file embeds a TileData fingerprint so a stale
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
index 610573ff4..ebd00811f 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
@@ -20,9 +20,12 @@ namespace Server.Engines.Pathing.Cache;
/// u32 Magic = 0x42575300 ('SWB\0')
/// u32 Version = current FormatVersion
/// u32 MapId
-/// u64 TileDataHash XxHash3 over LandTable + ItemTable flags (via
-/// HashUtility); rejects a load when client tile data has
-/// shifted under us.
+/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
+/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
+/// Rejects a load when EITHER tile flags shifted (client patch)
+/// OR the map data was rewritten (CentredSharp / UOFiddler edit).
+/// The .mul format has no built-in CRC; this is the only way
+/// to detect those mutations.
/// u64 BakeTimestamp DateTime.UtcNow.Ticks at write time (informational).
/// u32 ChunkCount
/// u64 IndexOffset File position where the chunk index begins.
@@ -54,7 +57,7 @@ internal static class StepCacheFile
sizeof(uint) // Magic
+ sizeof(uint) // Version
+ sizeof(uint) // MapId
- + sizeof(ulong) // TileDataHash
+ + sizeof(ulong) // Fingerprint
+ sizeof(ulong) // BakeTimestamp
+ sizeof(uint) // ChunkCount
+ sizeof(ulong); // IndexOffset
@@ -73,27 +76,69 @@ internal static class StepCacheFile
///
/// Byte offset of the IndexOffset u64 within the header
- /// (Magic+Version+MapId+TileDataHash+BakeTimestamp+ChunkCount = 32). Patched after chunks land.
+ /// (Magic+Version+MapId+Fingerprint+BakeTimestamp+ChunkCount = 32). Patched after chunks land.
///
private const int IndexOffsetFieldPosition = 32;
public delegate bool ChunkEnumerator(out int chunkX, out int chunkY, out StepChunk chunk);
///
- /// Computes a stable hash of the loaded TileData flags via XxHash3 (HashUtility).
- /// Bake files carry this hash so a load can refuse to populate the cache when tile
- /// data has shifted (client patch, mismatched version) — mismatched data would
- /// silently skew walkability answers. Hash is stable as long as HashUtility's seed
- /// constant doesn't change.
+ /// Peek at a .swb file's Fingerprint field (header byte offset 12) without
+ /// reading any chunk data. Returns false on missing file, bad magic, or wrong
+ /// version. Cheap — reads 20 bytes total.
///
- public static ulong ComputeTileDataHash()
+ public static bool TryReadFingerprint(string path, out ulong fingerprint)
{
+ fingerprint = 0;
+ if (!File.Exists(path))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
+ Span buf = stackalloc byte[20];
+ if (stream.Read(buf) < 20)
+ {
+ return false;
+ }
+ if (BinaryPrimitives.ReadUInt32LittleEndian(buf) != Magic)
+ {
+ return false;
+ }
+ if (BinaryPrimitives.ReadUInt32LittleEndian(buf[4..]) != FormatVersion)
+ {
+ return false;
+ }
+ // mapId is at buf[8..12], we skip; hash is at buf[12..20].
+ fingerprint = BinaryPrimitives.ReadUInt64LittleEndian(buf[12..]);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the
+ /// per-map .mul / .uop file contents (via ).
+ /// Bake files carry this hash so a load can refuse to populate the cache when EITHER
+ /// tile flags shifted (client patch) OR the map data was rewritten (CentredSharp /
+ /// UOFiddler edit). The .mul format has no built-in CRC; this is the only way to
+ /// detect those mutations.
+ ///
+ public static ulong ComputeFingerprint(int mapId)
+ {
+ var hasher = HashUtility.CreateXxHash3();
+
+ // TileData flag tables — same projection trick as before: just the Flags ulong
+ // from each entry, written little-endian into a contiguous byte buffer. The
+ // struct itself has a string Name (reference) whose object identity isn't
+ // stable across runs, so MemoryMarshal.Cast over the whole struct would drift.
var landTable = TileData.LandTable;
var itemTable = TileData.ItemTable;
-
- // Project just the Flags ulong from each entry into a contiguous byte buffer.
- // The struct itself contains a string Name (reference) whose object identity isn't
- // stable across runs, so we can't MemoryMarshal.Cast the whole struct.
var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)];
var span = bytes.AsSpan();
@@ -106,8 +151,19 @@ internal static class StepCacheFile
{
BinaryPrimitives.WriteUInt64LittleEndian(span[(itemOffset + i * 8)..], (ulong)itemTable[i].Flags);
}
+ hasher.Append(bytes);
- return HashUtility.ComputeHash64(bytes);
+ // Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already
+ // streamed them through XxHash3 once at construction; mix the result in.
+ var map = Map.Maps[mapId];
+ if (map != null && map != Map.Internal && map.Tiles != null)
+ {
+ Span mapHashBytes = stackalloc byte[sizeof(ulong)];
+ BinaryPrimitives.WriteUInt64LittleEndian(mapHashBytes, map.Tiles.MapFilesFingerprint);
+ hasher.Append(mapHashBytes);
+ }
+
+ return hasher.GetCurrentHashAsUInt64();
}
///
@@ -128,7 +184,7 @@ internal static class StepCacheFile
w.Write(Magic);
w.Write(FormatVersion);
w.Write(mapId);
- w.Write(ComputeTileDataHash());
+ w.Write(ComputeFingerprint((int)mapId));
w.Write((ulong)DateTime.UtcNow.Ticks);
w.Write(chunkCount);
w.Write(0UL); // IndexOffset placeholder, patched after chunks
@@ -172,7 +228,7 @@ internal static class StepCacheFile
///
/// Opens a .swb file and reads only its header + chunk-offset index. Returns null on
- /// missing file, magic / version mismatch, or TileDataHash mismatch (a stale bake
+ /// missing file, magic / version mismatch, or Fingerprint mismatch (a stale bake
/// against a freshly patched client). Callers own disposal of the returned reader.
///
public static LazyReader OpenForLazy(string path)
@@ -213,12 +269,12 @@ internal static class StepCacheFile
}
var mapId = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[8..]);
- var tileDataHash = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[12..]);
+ var fingerprint = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[12..]);
var bakeTimestamp = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[20..]);
var chunkCount = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[28..]);
var indexOffset = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[32..]);
- if (tileDataHash != ComputeTileDataHash())
+ if (fingerprint != ComputeFingerprint((int)mapId))
{
stream.Dispose();
return null;
@@ -243,7 +299,7 @@ internal static class StepCacheFile
offsets[key] = off;
}
- return new LazyReader(stream, mapId, tileDataHash, bakeTimestamp, chunkCount, offsets);
+ return new LazyReader(stream, mapId, fingerprint, bakeTimestamp, chunkCount, offsets);
}
catch
{
@@ -352,7 +408,7 @@ internal static class StepCacheFile
private byte[] _buffer;
public uint MapId { get; }
- public ulong TileDataHash { get; }
+ public ulong Fingerprint { get; }
public ulong BakeTimestamp { get; }
public uint ChunkCount { get; }
public int IndexedChunkCount => _offsets.Count;
@@ -360,13 +416,13 @@ internal static class StepCacheFile
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
internal LazyReader(
- FileStream stream, uint mapId, ulong tileDataHash, ulong bakeTimestamp,
+ FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
uint chunkCount, Dictionary offsets
)
{
_stream = stream;
MapId = mapId;
- TileDataHash = tileDataHash;
+ Fingerprint = fingerprint;
BakeTimestamp = bakeTimestamp;
ChunkCount = chunkCount;
_offsets = offsets;
diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
index 1daae3266..c75e90d0b 100644
--- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
+++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
@@ -9,6 +9,7 @@ namespace Server.Engines.Pathing;
/// [PathCacheClear — drop all cached chunks, close lazy readers, zero counters.
/// [PathCacheSave — persist resident chunks per map to Data/Pathfinding/<mapId>.swb.
/// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup.
+/// [PathRecord — toggle JSONL telemetry capture for replay / benchmark corpora.
///
public static class PathCacheCommands
{
@@ -25,10 +26,13 @@ public static class PathCacheCommands
8192
);
+ PathfindRecorder.Configure();
+
CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats);
CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear);
CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave);
CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad);
+ CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord);
AutoLoadAtStartup();
}
@@ -120,4 +124,43 @@ public static class PathCacheCommands
$"StepCache: opened {openedMaps} map(s) for lazy loading (total readers open: {StepCache.Instance.OpenLazyReaderCount})."
);
}
+
+ [Usage("PathRecord [on|off|flush|status]")]
+ [Description("Toggles PathfindRecorder. With no arg, reports state. 'on' enables JSONL capture of every Find call; 'off' disables and flushes; 'flush' forces a buffer flush without disabling.")]
+ private static void OnPathRecord(CommandEventArgs e)
+ {
+ var arg = (e.Arguments.Length > 0 ? e.Arguments[0] : "status").ToLowerInvariant();
+ var from = e.Mobile;
+ switch (arg)
+ {
+ case "on":
+ {
+ PathfindRecorder.SetEnabled(true);
+ from.SendMessage(PathfindRecorder.Enabled
+ ? $"PathRecord: ON, writing to {PathfindRecorder.OutputPath}"
+ : "PathRecord: enable failed (see server log)");
+ break;
+ }
+ case "off":
+ {
+ PathfindRecorder.SetEnabled(false);
+ from.SendMessage("PathRecord: OFF");
+ break;
+ }
+ case "flush":
+ {
+ PathfindRecorder.Flush();
+ from.SendMessage($"PathRecord: flushed ({PathfindRecorder.RecordsWritten} records this session)");
+ break;
+ }
+ default:
+ {
+ from.SendMessage(
+ $"PathRecord: {(PathfindRecorder.Enabled ? "ON" : "OFF")}, "
+ + $"path={PathfindRecorder.OutputPath}, records={PathfindRecorder.RecordsWritten}"
+ );
+ break;
+ }
+ }
+ }
}
diff --git a/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs
new file mode 100644
index 000000000..795719911
--- /dev/null
+++ b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs
@@ -0,0 +1,170 @@
+using System.IO;
+using System.Text;
+using Server.Logging;
+using Server.Mobiles;
+using Server.Text;
+
+namespace Server.Engines.Pathing;
+
+///
+/// Admin-toggled telemetry: appends a JSONL line per pathfind request to a file.
+/// One record per BitmapAStarAlgorithm.Find call, capturing the inputs (start, goal,
+/// map, capability flags) needed to replay the scenario in benchmarks. Output format
+/// matches the corpus the BDN harness consumes.
+///
+/// Hot-toggleable at runtime via the [PathRecord admin command — no restart needed.
+/// only seeds the initial state from server.cfg
+/// (pathfinding.recorder.enable, default false).
+///
+/// Holds a single StreamWriter open while recording; its internal buffer absorbs
+/// per-record writes without per-call File.Open / File.Append. Each record is built
+/// in a stack-allocated ValueStringBuilder (zero per-int allocation for the field
+/// formatting), then handed to the writer as a ReadOnlySpan<char>.
+///
+/// Workload note: intended for short bursts of capture (turn on, walk a region
+/// or trigger a scenario, turn off). On a busy server with hundreds of pathfinds
+/// per second, sustained recording can saturate the StreamWriter's 4 KB buffer and
+/// block the game thread on disk writes. A backpressure-aware async sink is a
+/// future enhancement if 24/7 capture becomes a use case.
+///
+public static class PathfindRecorder
+{
+ private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathfindRecorder));
+
+ private static bool _enabled;
+ private static string _outputPath;
+ private static StreamWriter _writer;
+ private static long _recordsWritten;
+
+ public static bool Enabled => _enabled;
+ public static string OutputPath => _outputPath;
+ public static long RecordsWritten => _recordsWritten;
+
+ public static void Configure()
+ {
+ _outputPath = ServerConfiguration.GetOrUpdateSetting(
+ "pathfinding.recorder.path",
+ Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", "recordings", "pathfinds.jsonl")
+ );
+
+ var startEnabled = ServerConfiguration.GetOrUpdateSetting("pathfinding.recorder.enable", false);
+ if (startEnabled)
+ {
+ SetEnabled(true);
+ }
+ }
+
+ ///
+ /// Toggle recording. When enabling, opens an append-mode StreamWriter; when
+ /// disabling, flushes + disposes it. Idempotent — calling twice with the same
+ /// state is a no-op.
+ ///
+ public static void SetEnabled(bool enabled)
+ {
+ if (enabled == _enabled)
+ {
+ return;
+ }
+
+ if (enabled)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? ".");
+ var stream = new FileStream(_outputPath, FileMode.Append, FileAccess.Write, FileShare.Read);
+ _writer = new StreamWriter(stream, new UTF8Encoding(false));
+ _enabled = true;
+ logger.Information("PathfindRecorder enabled, writing to {Path}", _outputPath);
+ }
+ catch (IOException ex)
+ {
+ logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", _outputPath);
+ _writer = null;
+ _enabled = false;
+ }
+ }
+ else
+ {
+ _enabled = false;
+ try
+ {
+ _writer?.Flush();
+ _writer?.Dispose();
+ }
+ catch (IOException ex)
+ {
+ logger.Warning(ex, "PathfindRecorder: error closing {Path}", _outputPath);
+ }
+ _writer = null;
+ logger.Information("PathfindRecorder disabled ({Count} records this session)", _recordsWritten);
+ }
+ }
+
+ ///
+ /// Force a flush of the writer's internal buffer to disk. Safe to call when
+ /// disabled (no-op). Useful after a burst of recording when an admin wants to
+ /// inspect the file without waiting for buffer fill or disable.
+ ///
+ public static void Flush()
+ {
+ try
+ {
+ _writer?.Flush();
+ }
+ catch (IOException ex)
+ {
+ logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", _outputPath);
+ }
+ }
+
+ ///
+ /// Capture one Find call. Hot path: cheap when disabled (single bool check).
+ /// When enabled, formats one JSONL line and writes it through the StreamWriter's
+ /// internal buffer — flush is amortized across many calls.
+ ///
+ public static void RecordIfEnabled(Mobile m, Map map, Point3D start, Point3D goal)
+ {
+ if (!_enabled || _writer == null || m == null || map == null)
+ {
+ return;
+ }
+
+ var canSwim = false;
+ var canFly = false;
+ var canOpenDoors = false;
+ var canMoveOverObstacles = false;
+ if (m is BaseCreature bc)
+ {
+ canSwim = bc.CanSwim;
+ canFly = bc.CanFly;
+ canOpenDoors = bc.CanOpenDoors;
+ canMoveOverObstacles = bc.CanMoveOverObstacles;
+ }
+
+ try
+ {
+ // One interpolation handles every numeric field with no per-int ToString
+ // allocation; bool fields use explicit literal spans because JSON wants
+ // lowercase "true"/"false" and bool.ToString() yields "True"/"False".
+ using var vsb = ValueStringBuilder.Create(192);
+ vsb.Append(
+ $"{{\"Name\":\"recorded\",\"MapId\":{map.MapID},\"StartX\":{start.X},\"StartY\":{start.Y},\"StartZ\":{start.Z},\"GoalX\":{goal.X},\"GoalY\":{goal.Y},\"GoalZ\":{goal.Z},\"CanSwim\":"
+ );
+ vsb.Append(canSwim ? "true" : "false");
+ vsb.Append(",\"CanFly\":");
+ vsb.Append(canFly ? "true" : "false");
+ vsb.Append(",\"CanOpenDoors\":");
+ vsb.Append(canOpenDoors ? "true" : "false");
+ vsb.Append(",\"CanMoveOverObstacles\":");
+ vsb.Append(canMoveOverObstacles ? "true" : "false");
+ vsb.Append("}\n");
+ _writer.Write(vsb.AsSpan());
+ _recordsWritten++;
+ }
+ catch (IOException ex)
+ {
+ logger.Warning(ex, "PathfindRecorder: write failed, disabling");
+ SetEnabled(false);
+ }
+ }
+}
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 38150443e..b7c489197 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -41,6 +41,7 @@
false
+