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

300 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
(`GetSuccessorsSlowPath``CheckMovement`), 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 `WalkMobileRange``MoveTowardsOrAwayFrom` (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.Configure``BitmapAStarAlgorithm.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-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). |
### Default configuration (recommended)
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 residuals***shipped 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).