## Summary About 114 tests skipped on CI because they needed the official client files (`tiledata.mul`, map and multi files). They gave CI no signal, and locally they ran against whichever client a developer had installed. The test hosts now build their own world instead, so every test runs on CI and runs the same on every machine. CI simulation: Server.Tests 837 passed, UOContent.Tests 1131 passed, **0 skipped**. - **Generated tile data.** The fixtures write a pre-HS `tiledata.mul` from a small invented tile kit (`Server.Tests/Terrain/TestTiles`) and load it through the real `TileData.Load()`. The test hosts no longer read any client directory (`MODERNUO_CLIENT_PATH`, `MODERNUO_TEST_DATA_DIR` and `C:\Ultima Online Classic` are gone from test code). The file is written to `SyntheticData` in each test project's output, overwritten every run and removed by `dotnet clean`. - **Injected terrain.** `TestTerrain` stamps land and statics into Felucca and Trammel, which share one terrain. Writes after startup must go in a `TestTerrain.Scope()`, which puts back every block it touched and keeps the step cache fresh. - **Prefab scenes.** Seven named scenes (town, causeway, sewer walkway, coastline, deep sea, map-edge sea, an upstairs room with an L-shaped counter) replace the Britain coordinates the pathing, AI and fishing tests relied on. Each scene-based test asserts its scene is present first, so a missing scene fails instead of passing on flat ground. Stamping all seven takes 3-5 ms. - **Multi footprints.** Contrived footprints for the three multi IDs content looks up (0x74, 0x7A, 0x0) are seeded through a new internal `MultiData.SetComponents`. - **Fingerprint.** `StepCacheFile.HashTileDataFile` is extracted so a test can show that a one-flag change to the file changes the fingerprint, while runtime table mutation does not. ### Engine changes - `TileMatrix.GetLandBlock`/`GetStaticBlock`: on a map without its files, return the block injected with `SetLandBlock`/`SetStaticBlock`, or the default when nothing was injected. Maps with files take the same path as before. - `MultiData.SetComponents` (internal): registers a footprint without multi files. ### Test changes worth a look - Values that were properties of real Britain terrain (probes at Z 10, a pinned plain walk mask of 0xC1) are re-pinned to the synthetic plain (Z 0, 0xFF). - No tests were deleted. The multi walkability and boat-deck tests assert movement behaviour over a footprint, not what a real multi looks like, so they run on the contrived footprints. - Maps 0 and 1 keep file indexes 0 and 1, and every stamp writes both, rather than sharing one file index. On a map without files the getters never consult the shared-file cache, and its patch bits would hide one facet's injection from the other. ## Test plan - [x] `dotnet test -p:SolutionDir=... --filter "Category!=Manual"` with the client env vars pointed at an empty directory: 837 + 1131 passed, 0 skipped, 0 failed - [x] Mutation check: removing the step-cache clear from `TestTerrain` makes the stale-cache tests fail - [x] CI green on all platforms
437 lines
15 KiB
C#
437 lines
15 KiB
C#
using System;
|
|
using System.IO;
|
|
using Server.Engines.Pathing.Cache;
|
|
using Xunit;
|
|
using static Server.Tests.Pathfinding.PathingTestSupport;
|
|
|
|
namespace Server.Tests.Pathfinding;
|
|
|
|
[Collection("Sequential Pathfinding Tests")]
|
|
public class StepCacheFileTests : IDisposable
|
|
{
|
|
private readonly StepCacheStateScope _cacheState = new();
|
|
|
|
public void Dispose() => _cacheState.Dispose();
|
|
|
|
/// <summary>
|
|
/// Round-trip a populated cache through a .swb file under lazy loading: build chunks,
|
|
/// save, clear (closes lazy readers + drops residents), open as lazy backing store,
|
|
/// then re-query and verify chunks come back from the file (not the runtime baker).
|
|
/// </summary>
|
|
[Fact]
|
|
public void RoundTrip_LazyLoad_PreservesChunkMaskAndZ()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
|
|
|
var map = Map.Maps[1];
|
|
Assert.NotNull(map);
|
|
|
|
// Populate three distinct chunks by querying different sectors. Query at each cell's
|
|
// real standable surface Z (where the cache anchors) so first-touch yields a clean
|
|
// hit rather than an off-surface fallthrough.
|
|
var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) };
|
|
var standZ = new sbyte[sourceQueries.Length];
|
|
{
|
|
Span<sbyte> surfZ = stackalloc sbyte[16];
|
|
for (var i = 0; i < sourceQueries.Length; i++)
|
|
{
|
|
var (qx, qy) = sourceQueries[i];
|
|
var n = StepProbe.ComputeStandableSurfaceZs(map, qx, qy, surfZ);
|
|
Assert.True(n > 0, $"({qx},{qy}) has no standable surface — bad test cell");
|
|
standZ[i] = surfZ[0];
|
|
}
|
|
}
|
|
for (var i = 0; i < sourceQueries.Length; i++)
|
|
{
|
|
var (x, y) = sourceQueries[i];
|
|
cache.TryGetMask(map, x, y, standZ[i]);
|
|
}
|
|
|
|
Assert.Equal(3, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(3L, cache.GetStats().BuildsTotal);
|
|
|
|
// Snapshot the answers we expect to recover after round-trip.
|
|
var expected = new StepMask[sourceQueries.Length];
|
|
for (var i = 0; i < sourceQueries.Length; i++)
|
|
{
|
|
var (x, y) = sourceQueries[i];
|
|
expected[i] = cache.TryGetMask(map, x, y, standZ[i]);
|
|
}
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
var written = cache.SaveToFile(path, map.MapID);
|
|
Assert.Equal(3, written);
|
|
|
|
cache.Clear();
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
Assert.Equal(1, cache.OpenLazyReaderCount);
|
|
|
|
// Lazy: opening doesn't materialize chunks, so the resident set stays empty
|
|
// until we query.
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal);
|
|
|
|
// Sanity: the lazy reader's index covers each chunk we're about to query.
|
|
foreach (var (x, y) in sourceQueries)
|
|
{
|
|
Assert.True(cache.LazyReaderHasChunk(map.MapID, x >> 4, y >> 4),
|
|
$"lazy reader missing chunk ({x >> 4},{y >> 4})");
|
|
}
|
|
|
|
for (var i = 0; i < sourceQueries.Length; i++)
|
|
{
|
|
var (x, y) = sourceQueries[i];
|
|
var lookup = cache.TryGetMask(map, x, y, standZ[i]);
|
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
|
Assert.Equal(expected[i].WalkMask, lookup.WalkMask);
|
|
Assert.Equal(expected[i].WetMask, lookup.WetMask);
|
|
Assert.Equal(expected[i].WalkZ_N, lookup.WalkZ_N);
|
|
Assert.Equal(expected[i].WalkZ_NW, lookup.WalkZ_NW);
|
|
}
|
|
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal);
|
|
Assert.Equal(3, cache.GetStats().ResidentChunks);
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear(); // Releases the FileStream so the delete below succeeds on Windows.
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryOpenLazyReader_MissingFile_ReturnsFalse()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-missing-{Guid.NewGuid():N}.swb");
|
|
Assert.False(cache.TryOpenLazyReader(path, mapId: 1));
|
|
Assert.Equal(0, cache.OpenLazyReaderCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// HasLazyReader is the boot prebake's skip predicate (PathCacheCommands.Initialize): a map
|
|
/// with an open, fingerprint-valid reader needs no bake. Lock the open/clear contract.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HasLazyReader_TracksOpenAndClear()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
|
|
var map = Map.Maps[1];
|
|
Assert.NotNull(map);
|
|
Assert.False(cache.HasLazyReader(map.MapID));
|
|
|
|
// Build + save a chunk so there's a valid .swb to open.
|
|
cache.MissPromotionThreshold = 1;
|
|
Span<sbyte> surfZ = stackalloc sbyte[16];
|
|
Assert.True(StepProbe.ComputeStandableSurfaceZs(map, 1500, 1600, surfZ) > 0);
|
|
cache.TryGetMask(map, 1500, 1600, surfZ[0]);
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-haslazy-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
Assert.True(cache.SaveToFile(path, map.MapID) > 0);
|
|
cache.Clear();
|
|
Assert.False(cache.HasLazyReader(map.MapID));
|
|
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
Assert.True(cache.HasLazyReader(map.MapID)); // open → true
|
|
|
|
cache.Clear();
|
|
Assert.False(cache.HasLazyReader(map.MapID)); // clear closes the reader → false
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear();
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryOpenLazyReader_BadMagic_ReturnsFalse()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-badmagic-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
File.WriteAllBytes(path, new byte[]
|
|
{
|
|
0xDE, 0xAD, 0xBE, 0xEF,
|
|
0x01, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00
|
|
});
|
|
Assert.False(cache.TryOpenLazyReader(path, mapId: 1));
|
|
Assert.Equal(0, cache.OpenLazyReaderCount);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryOpenLazyReader_FingerprintMismatch_ReturnsFalse()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
|
|
var map = Map.Maps[1];
|
|
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
cache.SaveToFile(path, map.MapID);
|
|
|
|
// 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++)
|
|
{
|
|
bytes[i] ^= 0xFF;
|
|
}
|
|
File.WriteAllBytes(path, bytes);
|
|
|
|
cache.Clear();
|
|
Assert.False(cache.TryOpenLazyReader(path, map.MapID));
|
|
Assert.Equal(0, cache.OpenLazyReaderCount);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The lazy reader holds a FileStream open. Saving 100 chunks then opening them all
|
|
/// must NOT materialize any of them in the resident set — that's the whole point of
|
|
/// lazy loading on RAM-constrained shards. A query for one specific chunk pulls only
|
|
/// that one chunk into memory.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LazyReader_DoesNotMaterializeUntilQueried()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
|
|
|
var map = Map.Maps[1];
|
|
Assert.NotNull(map);
|
|
|
|
// Populate a handful of chunks.
|
|
var coords = new[]
|
|
{
|
|
(1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600)
|
|
};
|
|
foreach (var (x, y) in coords)
|
|
{
|
|
cache.TryGetMask(map, x, y, sourceZ: PlainZ);
|
|
}
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID));
|
|
|
|
cache.Clear();
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
|
|
// Open succeeded — but no chunks resident yet.
|
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
|
|
|
// Query one specific chunk: only that chunk lands in the resident set.
|
|
cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ);
|
|
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal); // resolved from file, not baker
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear();
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A chunk's swim layer must survive Save → Clear → LazyOpen → first-touch query. The layer is
|
|
/// an optional trailer, so a chunk that has one is the only thing that proves it is written and
|
|
/// read back rather than silently dropped.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SwimLayer_RoundTrips_ThroughLazyReader()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1;
|
|
|
|
var map = Map.Maps[1];
|
|
Assert.NotNull(map);
|
|
|
|
// Build a chunk and inject a synthetic swim layer onto one cell.
|
|
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
|
|
|
var chunk = cache.GetResidentChunk(map.MapID, 1500 >> 4, 1600 >> 4);
|
|
Assert.NotNull(chunk);
|
|
|
|
chunk.AllocateSwimLayer();
|
|
var cellIndex = PathingTestSupport.CellIndex(1500, 1600);
|
|
chunk.SwimSourceZ[cellIndex] = -7;
|
|
chunk.SwimMask[cellIndex] = 0b0000_1111;
|
|
chunk.SwimZN_Layer[cellIndex] = -7;
|
|
chunk.SwimZNE_Layer[cellIndex] = -7;
|
|
chunk.SwimZE_Layer[cellIndex] = -7;
|
|
chunk.SwimZSE_Layer[cellIndex] = -7;
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
|
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1;
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
|
|
// Pull the chunk back via a query at swim Z; the layer must hit and serve our
|
|
// injected mask. Walk-Z query of the same cell should still hit the walk
|
|
// layer with whatever the bake produced.
|
|
var swim = cache.TryGetMask(map, 1500, 1600, sourceZ: -7);
|
|
Assert.True(swim.IsHit);
|
|
Assert.Equal((byte)0, swim.WalkMask);
|
|
Assert.Equal((byte)0b0000_1111, swim.WetMask);
|
|
Assert.Equal((sbyte)-7, swim.SwimZ_N);
|
|
Assert.Equal((sbyte)-7, swim.SwimZ_E);
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear();
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// PreloadOnLazyOpen=true must materialize every chunk in the .swb file into the
|
|
/// resident set immediately, eliminating first-touch file-read latency. Counterpart
|
|
/// to <see cref="LazyReader_DoesNotMaterializeUntilQueried"/> which proves the
|
|
/// default lazy behavior.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1;
|
|
|
|
var map = Map.Maps[1];
|
|
Assert.NotNull(map);
|
|
|
|
var coords = new[]
|
|
{
|
|
(1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600)
|
|
};
|
|
foreach (var (x, y) in coords)
|
|
{
|
|
cache.TryGetMask(map, x, y, sourceZ: PlainZ);
|
|
}
|
|
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID));
|
|
|
|
cache.Clear();
|
|
cache.PreloadOnLazyOpen = true;
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
|
|
// Every chunk should be resident — no further queries needed.
|
|
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
|
|
|
// Subsequent query is a clean Hit, not a Miss_NotBuilt.
|
|
var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ);
|
|
Assert.Equal(CacheHitKind.Hit, lookup.HitKind);
|
|
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear();
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A chunk the .swb can satisfy must be served on first touch, without consulting the promotion
|
|
/// gate. This is the deployment shape where an admin ships baked files and expects the very
|
|
/// first pathfind through a region to use the cache rather than the slow path — the gate would
|
|
/// otherwise defer that first touch and defeat the whole point of shipping the bake.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
|
|
{
|
|
var cache = StepCache.Instance;
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 1; // eager build for save phase
|
|
|
|
var map = Map.Maps[1];
|
|
|
|
// Build + save one chunk.
|
|
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{Guid.NewGuid():N}.swb");
|
|
try
|
|
{
|
|
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
|
|
|
// Reset to a fresh state with the file open as a lazy reader and the deferred
|
|
// promotion threshold restored to 2.
|
|
cache.Clear();
|
|
cache.MissPromotionThreshold = 2;
|
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
|
|
|
// First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the
|
|
// chunk and serves it before the miss tracker is consulted.
|
|
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
|
Assert.True(lookup.IsHit);
|
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
|
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
|
Assert.Equal(0L, cache.GetStats().FallthroughNotBuilt);
|
|
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
|
}
|
|
finally
|
|
{
|
|
cache.Clear();
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
}
|
|
}
|