modernuo/dev-docs/pathfinding.md
Kamron Batman 9d26a44a28
fix: Fixes pathfinding prebake and pathfinding multi-fallthrough. (#2478)
## Summary

Fixes the pathfinding step-cache (`.swb`) prebake so it bakes **once** and skips when a valid cache already exists, instead of re-baking on every boot. The root cause was the staleness fingerprint hashing mutable in-memory tile data rather than the on-disk files. This PR makes the fingerprint a pure function of the client data files and separates dynamic multis (houses/boats) from the static cache.

> This branch builds on the `ConfigurePrompts` first-boot-prompt unification (commit `5df8d0bd`, also included here) — that commit accounts for the `ServerConfiguration.cs` and `dev-docs/server-lifecycle.md` changes in the diff.

## The bug

With `pathfinding.prebakeMaps` set, the cache re-baked on **every** boot. The `.swb` staleness fingerprint hashed the live `TileData.LandTable`/`ItemTable` flags, which the server patches at runtime (`ItemFixes`, `LOSBlocker`, `PotionKeg`, `CTF`) at nondeterministic lifecycle points (Initialize-phase methods share a priority; static ctors fire lazily). So a fingerprint stamped at runtime (`[PathBake`) never matched the one recomputed during startup `Initialize()`, and the cache rebaked every time.

## Changes

**1. Fingerprint the files, not the in-memory tables** (`fix`)
Hash `tiledata.mul` (cached, computed once) plus the per-map `.mul`/`.uop` files — never the runtime-mutated `TileData` tables. The fingerprint is now lifecycle-stable. Existing `.swb` files rebake once after deploy, then stay stable.

**2. Compute the fingerprint once per boot** (`refactor`)
`Configure()`'s `AutoLoadAtStartup()` already opens and fingerprint-validates a reader for every up-to-date `.swb`. `Initialize()` now skips baking any map that already has an open reader (`StepCache.HasLazyReader`) instead of recomputing the fingerprint a second time.

**3. Bake static-only; route multis to the live path** (`refactor`)
Multis (houses/boats) are dynamic, so they're no longer baked into the static chunk cache — they were tagged with `BuiltMultisVersion`, a non-persisted session counter, which made persisting them unsafe (false matches / wasted re-bakes).
- Chunks bake land + `statics.mul` only.
- At query time, any cell whose sector (or its 1-cell halo) contains a multi routes to `Fallthrough_Multi` → the existing live, multi-aware `CheckMovement` path. The halo prevents a cell proposing a walkable edge into a neighbouring wall; interior (multi-free) cells pay one sector lookup.
- Adds `Sector.HasMultis` (one engine accessor); `.swb` format → v9 (rejects old multi-baked files); new `Fallthrough_Multi` telemetry.
- Behaviour-preserving: multis use the same live path the engine used before the cache existed.

**4. Comment polish** (`style`) — no behaviour change.

## Testing

All green: **92** pathfinding (incl. a new fingerprint-stability test and a multi-halo fallthrough test), **423** UOContent, **708** Server.

## Follow-ups (not in this PR)

- **Background bake worker** — make `[PathBake` and the boot prebake non-blocking (game thread serves tile reads to an off-thread worker).
- **Per-multi MCL cache** — cache walkability in each multi's own frame (keyed by multiID, movement-invariant) so houses/boats get a fast path instead of the live fallback.
- **House-pathfinding equivalence tests** — the one area not yet covered by a dedicated automated test; multi pathing is currently correct by delegation to the live path.
2026-06-08 11:59:24 -07:00

21 KiB
Raw Permalink Blame History

Pathfinding architecture, configuration, and tuning levers

How creatures navigate in ModernUO: the algorithm, the static-walkability cache, the AI integration, the configuration knobs, and what to tell shard owners on different hardware. Written so a future contributor (human or AI) can reason about the system without re-deriving it from the code.

The stack (top to bottom)

BaseAI movement  (AIMovement.cs: MoveTo / WalkMobileRange → ApproachTarget)
        │  builds/keeps a
        ▼
PathFollower     (Engines/Pathing/PathFollower.cs)  — owns a MovementPath, walks it, repaths
        │  constructs a
        ▼
MovementPath     (Engines/Pathing/MovementPath.cs)  — thin wrapper; calls the algorithm once
        │  calls
        ▼
BitmapAStarAlgorithm.Find   (Engines/Pathing/BitmapAStarAlgorithm.cs)  — windowed A*
        │  per cell expansion asks
        ▼
StepCache        (Engines/Pathing/Cache/)  — static per-chunk walkability bitmaps (+ optional .swb)
        │  on cache miss / non-default walker, falls through to
        ▼
MovementImpl.CheckMovement   (the per-direction "slow path")

There is one pathfinding algorithm now: BitmapAStarAlgorithm. The old FastAStarAlgorithm was removed. Its behavior survives as the slow path inside BitmapAStar (GetSuccessorsSlowPathCheckMovement), which is what runs on a cache miss or when the cache is disabled (see Levers). So "no cache" ≈ "old FastAStar", not a missing capability.

AI integration: BaseAI.ApproachTarget

All creature goal-seeking funnels through one primitive (AIMovement.cs). MoveTo (combat chase, AOS follow) and WalkMobileRangeMoveTowardsOrAwayFrom (pet come/follow, most combat AIs) both delegate to ApproachTarget(target, run, range).

Per think-tick decision:

  1. Greedy fast path — if no path is active and the target is in LOS, take one direct step. It only counts as progress when the move fully succeeds (MoveResult.Success) and actually gets closer. A blocked step or an auto-turn sidestep (SuccessAutoTurn) that didn't reduce distance does not count — it falls through to the planner. (This is the fix for the old "pace back and forth at a concave obstacle" bug: a sidestep used to be mistaken for progress and discard the path.)
  2. Planner — commit to a persistent PathFollower (kept across ticks, never discarded by a greedy step) and follow it around the obstacle.
  3. Give-up / idle — a best-distance stall counter (ApproachGiveUpTicks = 40, AIMovement.cs) idles a creature that can't lower its closest-ever distance to a stationary goal, so it stops shuffling on a genuinely unreachable target. A moving goal (active chase) resets the baseline every tick and never gives up.

Open terrain stays on the greedy fast path and never builds a PathFollower — pathfinding only engages when greedy movement stalls.

The algorithm: windowed A* with hard limits

BitmapAStarAlgorithm (Find) is a bounded local pathfinder, not a global one. Key constants:

Constant Value Meaning
AreaSize 38 Search is a 38×38 box centered on the midpoint of start+goal. Every path cell must lie within ~19 tiles of that midpoint.
MaxSearchNodes 1000 (settable) Max node expansions per Find before bailing — a per-pathfind CPU bound on the single game thread.
PlaneCount/PlaneHeight/PlaneOffset 13 / 20 / 128 Z handling: 13 planes × 20 height, offset 128 → multi-Z (stairs) representable when it fits the window.

Consequences (these are by-design limits, not bugs):

  • The window is sized by the straight-line start↔goal span, not the route. When start and goal are close but separated by a big obstacle (around a building, or one floor up via remote stairs), the real detour can fall outside the 38-box and is unfindable at any budget. Verified example: pet at (1443,1568,30) → owner upstairs (1444,1566,50) Trammel returns null at every budget (300→3000). That class needs higher-level handling (waypoints, or pets teleporting to master when they can't path — classic UO behavior), not a bigger A*.
  • MaxSearchNodes only bounds long / failed searches; successful open paths terminate on goal-found and never approach it. Sizing data (Release, BDN, MaxSearchNodesBenchmarks): open path ~2 µs at every budget; a ~33-step indoor detour needs ≥ ~500 expansions (NULL below); an unreachable search's cost rises then plateaus when it exhausts the window (~15001700 nodes). 1000 is near-optimal: above the ~500 needed for indoor detours, below the window-exhaustion ceiling, so a failed search at 1000 costs ~79 µs (Release) and bails before the ~185 µs full-exhaustion cost. Raising it past ~1500 buys nothing.

Internal A* working buffers (_nodes ~18,772 entries, _nodeStates, _path, _successors, _openQueue) are static singletons reused every call — zero allocation. The only intentional per-call allocation is the returned Direction[] path (handed off to the PathFollower and read across ticks until repath/arrival).

The StepCache (static walkability)

StepCache stores, per map chunk, a precomputed bitmap of which of the 8 directions are walkable from each cell plus the destination Z — so a default walker's cell expansion is one TryGetMask lookup instead of 8 CheckMovement calls. Cells the cache can't model (multi-Z fallthroughs, non-default walkers: fliers, non-GM players, swim/door/clip capabilities) fall through to the slow path for that one cell.

  • Warming is on-demand and second-touch gated. A chunk is built when a second distinct pathfind touches it (avoids building chunks a one-off search will never reuse). Until built, touches fall through to the slow path.
  • Memory is bounded, not unbounded. Resident chunks are LRU-capped at pathfinding.maxResidentChunks (default 8192 ≈ ~40 MB). It ramps to the cap and plateaus; it does not grow forever.
  • Steady-state win: once warm, BitmapAStar is 25× faster than the old FastAStar at zero allocation (BDN PathfindBenchmarks, LazyWarm/WarmNoFile providers).
  • Cold cost: with the cache on but never warm (every search a first-touch), it is ~1.15 1.25× slower than FastAStar (the per-cell probe overhead without payoff). This is the transient first-pathfind-per-region case, not steady state.

.swb baked files (optional)

[PathBake / [PathCacheSave write a <mapId>.swb per map; [PathCacheLoad (and startup auto-load) open them as lazy backing stores — only the header + chunk-offset index is read up front (~16 B/chunk); individual chunks are fetched on demand and remain LRU-capped, so RAM stays bounded by maxResidentChunks regardless of file size. The only thing a baked file buys is zero first-pathfind-after-boot latency for a region (chunks reload from file instead of being rebuilt by the runtime baker).

Disk cost (current, format v8): the size-reduction roadmap (§ .swb size reduction) took Trammel from ~565 MB to 17.9 MB; the other facets are smaller, so all six together are on the order of tens of MB — not the ~1.52 GB the uncompressed v2 format cost. Baking is now cheap enough to offer by prompt at first boot (below) rather than being a heavy opt-in-only step. Still don't ship prebaked .swb files (they're tile-data-version-specific and regenerate from the client files anyway).

First-boot pre-bake prompt

On the first boot (right after map selection) an interactive prompt offers to pre-bake the .swb cache for the selected maps. The answer is stored in modernuo.json as pathfinding.prebakeMaps (default false), so it is asked exactly once; headless/CI boots (redirected input) skip the prompt and default to off — operators can set the flag directly.

When the flag is set, PathCacheCommands.Initialize() bakes, at startup, any map whose .swb is missing or stale (its tile-data fingerprint no longer matches — e.g. after a client/map update). A fresh cache is a no-op, so only the first boot (or a post-update boot) pays the several-minutes cost. Wiring:

  • The prompt runs in a dedicated AssemblyHandler.Invoke("ConfigurePrompts") startup phase — after assemblies load (so content can register prompts) but before Serilog starts, so the console prompt is not interleaved with the async console sink. Any class can participate by defining public static void ConfigurePrompts() and self-gating on first-boot state.
  • The bake runs in the later Invoke("Initialize") phase (after the tile matrix + world load, which the bake walks).
  • Staleness is decided by the .swb fingerprint, which StepCacheFile.OpenForLazy validates at open time (hash of tiledata.mul + the per-map .mul/.uop files — never the in-memory TileData tables, which the server patches at runtime). Configure opens a reader for every up-to-date file; the bake in Initialize then skips any map where StepCache.HasLazyReader is already true, so the fingerprint is computed once per boot, not twice.

Configuration levers

Lever Where Default Effect
pathfinding.enable PathFollower.Configure true Master switch for PathFollower pathfinding. Off → greedy/auto-turn only, no A* at all.
bitmap_pathfinding_cache feature flag (ContentFeatureFlags.BitmapPathfindingCache, Server.Systems.FeatureFlags) FeatureFlagManager true Off → BitmapAStar routes straight to the slow path with no cache probe and no warming memory. ≈ old FastAStar at ~1×.
pathfinding.maxResidentChunks PathCacheCommands.Configure 8192 (~40 MB) LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 5121024 ≈ 2.55 MB) on small shards.
pathfinding.maxSearchNodes PathCacheCommands.ConfigureBitmapAStarAlgorithm.MaxSearchNodes 1000 A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot.
pathfinding.prebakeMaps PathCacheCommands (first-boot prompt + Initialize) false When set, bakes any missing/stale .swb for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt.
PathFollower RepathDelay PathFollower.cs (const) 2 s Throttle: a moving goal re-Finds at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time).

Cache on, warm-on-demand, no .swb. Most shards get the 25× steady-state win for free, with warming memory plateau-capped at ~40 MB. .swb baking stays opt-in for large shards that care about first-pathfind-after-boot latency and can spend ~1.52 GB of disk.

Small / crappy-hardware shards — the spectrum

Config Perf Memory Disk
bitmap_pathfinding_cache = false ≈1× (old FastAStar) 0 cache RAM 0
Cache on, maxResidentChunks low (~512) ~25× on hot regions ~few MB 0
Cache on, default cap (8192) 25× warm ~40 MB plateau 0
Cache on + baked .swb + zero first-pathfind-after-boot latency ~40 MB + index ~tens of MB (v8)

The key point for RAM-starved boxes: disabling the cache is not a regression — it's the old FastAStar behavior at ~1× with zero warming memory (the slow path does the same per-cell work, and with the flag off there's no probe overhead). The CacheOffBenchmarks in the benchmark repo exists to prove this (ratio ≈ 1.0 vs the vendored FastAStar baseline).

Diagnostics & tooling

  • [PathCacheStats — resident-chunk count + hit/miss/eviction telemetry. Watch evictions(lruCap) on a live shard to see if the working set exceeds the cap.
  • [PathCacheClear — drop residents + zero counters.
  • [PathBake [mapId] / [PathCacheSave / [PathCacheLoad — produce / persist / lazy-open .swb files (see disk cost above).
  • [PathRecord on|off|flush — capture every Find as JSONL (replay / benchmark corpus).
  • MapDump (ModernUO-Tools/TerrainAnalyzer/, see its MapDump.md) — offline UO client-file inspector (dump/scan/floors modes) for diagnosing whether a route exists and whether it fits the 38-tile window. Read raw geometry, then confirm with a real Find in a test.
  • Benchmarks (ModernUO-Benchmarks/Benchmarks/PathfindInGame/, BenchmarkDotNet, Release): PathfindBenchmarks (BitmapAStar vs FastAStar × 4 cache providers × corpus), MaxSearchNodesBenchmarks (budget sweep), CacheOffBenchmarks (flag-off ≈ FastAStar). Use these to validate any change to the defaults above against the recorded corpus.

Testing notes

  • Pathfinding tests live in Projects/UOContent.Tests/Tests/Engines/Pathing/ and .../Mobiles/AI/ApproachTargetTests.cs, in the Sequential Pathfinding Tests collection (the A* statics are not reentrant). They run against the live Trammel TileMatrix.
  • Run in Debug or Release — both work since the PathfindingTestFixture data-copy was fixed to use a project-relative path (UOContent.Tests.csproj); historically Release threw an NRE because Data/skills.json wasn't copied to the Release bin.

Future work

Cache / bake (deferred follow-ups)

  • Background-thread bake. Build chunks off the game thread so even promoted chunks don't pay the ~700 µs build cost on the main thread. Rule 10 (no Task.Run/new Thread() in game code) applies — the bake itself is a pure data transform, but the main-thread synchronization on chunk-state transitions (resident map insert, LRU bookkeeping, generation gate) has to be threaded through carefully. Needs its own design; defer.
  • Long-traverse BDN scenario. A multi-Find benchmark simulating ~50 pet repaths across chunk transitions, to exercise the gate under sustained cross-chunk movement. Requires restructuring the bench harness (it's currently one Find per scenario); the existing corpus + Cold provider already covers the single-Find gate, so this is lower priority.
  • Swim SourceZ bake. The corpus sea-serpent scenario shows ~56 B allocation on warm paths because the cache's SourceZ is computed under default-walker rules, so swim creatures fall through to the slow path. Baking swim-aware source Z (or a swim stratum) would let them hit the cache. Independent of the size-reduction work below.

.swb size reduction (the ~565 MB → tens of MB roadmap)

The v2 format stores every 16×16 chunk as a flat ~5,393-byte record, uncompressed, with no uniform-region elision — so Trammel's ~114,688 chunks × 5.4 KB ≈ ~565 MB, and ocean / Green Acres / void cost the same as dense dungeon. Three independent, separately-shippable wins, all of which preserve seekable random reads (compress at chunk/sector granularity, never whole-file) and bounded RAM (only touched chunks materialize, LRU-capped):

  1. Uniform-chunk elision (biggest ratio, lowest risk). A chunk whose 256 cells share one mask + Z is stored as a few bytes (flag + mask + Z) instead of 5,393. Pairs with a two-level sector index so a uniform super-sector (open ocean) collapses to a single entry — UO's 16-tile sectors nest cleanly inside.
  2. Predictive (lossless) Z residualsshipped as format v6. Kills the bulk of the 16 base directional Z arrays in Full chunks. Each array is stored as a masked residual against the cell's own SourceZ: predict mask bit ? SourceZ : 0 (the mask term matches the baker, which leaves non-walkable directional slots at 0), residual = Z predict via unchecked two's-complement (byte-exact for all inputs). A new u16 ZArrayMask flags which of the 16 arrays differ from their prediction; an array that matches everywhere (flat terrain — including partial-walkability coastlines with nonzero SourceZ) is omitted entirely and synthesized from mask + SourceZ at read. Reconstruct Z = predict + residual → byte-identical in-memory StepChunk, so no runtime recompute and no risk of diverging from MovementImpl. Self-prediction (not a neighbor) keeps chunk independence. The residual subtraction itself saves ~0 bytes (a residual is still 1 byte/cell); the win is the per-array elision, and leaving non-elided arrays in residual form makes #3 a pure codec add (no further transform/format bump). Swim-layer + strata trailers stay absolute, deferred to #3.
  3. Per-chunk block compression + index compaction.
    • #3a — per-chunk compression: shipped as format v7. Each record is libdeflate-compressed independently (random access preserved; the reader inflates one chunk on read into a reused buffer) behind a u32 UncompressedLen frame; tiny Uniform records that do not shrink are stored raw (detected as on-disk payload length == UncompressedLen). Codec chosen by full-Trammel spike: libdeflate VeryHigh beat zstd L19/22 (17.4 MB) and tied managed Brotli q11 (16.4 MB) at 16.5 MB of compressed records, with the fastest decompress (1.83 µs/chunk, vs zstd 2.21) — and it is already the repo's packet codec (LibDeflate.Bindings), so no new dependency. 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: 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 clean v3 format bump; MinSupportedVersion already silently rejects + overwrites older files. Validate size and read-latency vs the corpus in the benchmark repo after each.

Measured headroom (Trammel). Two measurements, both 2026-06-06:

  • Pre-swim audit (v2 spike, indicative): directional-Z is 95.2% zero-residual for WalkZ, 38.1% for SwimZ vs SourceZ (→ #2; swim wants its own predictor or leans on #3). The spike's combined size projection double-counted and is superseded by the calibration below.
  • Calibrated on the real v4/v5 format (SaveToFile-measured): 114,688 chunks; 62.7% fully uniform (swim-aware → #1 elides these), 8.9% carry a swim layer and 1.8% strata (both stay Full). #1 alone: 592.2 MB → 231.9 MB (61%), actual on-disk. The residual is ~150 MB of non-uniform land Z-blocks (→ #2 predictive-Z) + ~81 MB of swim-layer trailers (→ #2/#3). Confirms build order #1 → #2 → #3, with #1 the dominant, lowest-risk, no-algorithm-change win (shipped as format v5).
  • Calibrated v6 (BakeMap-measured, full Trammel, MaxResidentChunks raised above 114,688): #2 predictive-Z: 231.9 MB → 124.7 MB (46%, 107 MB; 78% vs the original 565 MB). Of the 42,775 Full chunks (71,913 are Uniform), walk directional-Z arrays elide 47.7% and swim arrays elide 87.6% (per-array, not per-cell — one slope cell keeps a 256-byte array, which is why the per-array rate trails the 95.2% per-cell zero-residual). Remaining v6 bytes are dominated by present walk-residual blocks (~46 MB, mostly ±1..3 + zeros → highly compressible), the per-Full-chunk mask/SourceZ base (~33 MB), and absolute swim-layer trailers (~26 MB) — all prime targets for #3 per-chunk compression.
  • Calibrated v7 (BakeMap-measured, full Trammel): #3a per-chunk libdeflate VeryHigh: 124.7 MB → 19.2 MB (85%); 97% vs the original 565 MB. Codec spike (per-chunk over the 122.4 MB of v6 records): libdeflate VeryHigh 16.5 MB vs Brotli q11 16.4 / zstd L1922 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).