From 1a35b83cf8dd24b3ae914506789756336fa35ab7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 19:46:04 +0200 Subject: [PATCH] =?UTF-8?q?docs(vt):=20VT1=20catalog=2006=20=E2=80=94=20VT?= =?UTF-8?q?ank=20navigation=20engine=20and=20the=20.nav=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x.cs reader/writer (uTank2 NAV 1.2, route types, ten waypoint types incl. the live-position quirk of five of them), ca.cs cycle driver, fd.cs steering and creep band, bi.cs jump (2000 ms cap), b7.cs door/lockpick, priority interactions, 240 m/unit confirmed at four sites, two real routes decoded. MossTank gap ranked. Lead spot-check: header vs a real file, jump cap, conversion sites. Co-Authored-By: Claude Fable 5.1 --- .../vtank-kb/06-navigation-and-nav.md | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 docs/research/vtank-kb/06-navigation-and-nav.md diff --git a/docs/research/vtank-kb/06-navigation-and-nav.md b/docs/research/vtank-kb/06-navigation-and-nav.md new file mode 100644 index 00000000..2101d530 --- /dev/null +++ b/docs/research/vtank-kb/06-navigation-and-nav.md @@ -0,0 +1,580 @@ +# VTank KB 06 — Navigation and `.nav` + +Oracle: `refs/vtank/decompiled/` (ILSpy of `utank2-i.dll`, obfuscated identifiers, +intact strings/settings). All citations are `file:line` against that tree +unless marked otherwise. Decompiled source is never pasted verbatim; every +value/behavior below was read directly from the cited lines. Real sample +files were read from `C:\Games\VirindiPlugins\VirindiTank\*.nav` (read-only, +1,593 files on this machine) to produce the worked decodes in §1. + +Prior art consumed before writing this: `refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` +(pins the rule engine `cLogic.cs`, the `g8`/`fd` approach machinery, the FCM +saga, and the 240 m/unit inference). This doc extends that trace into the +`.nav` file format, waypoint execution, movement/steering, door/lockpick, +follow, and priority wiring, then compares against the acdream port. + +## 0. Class map (obfuscated name → role) + +| Class | Role | +|---|---| +| `uTank2.x` (`x.cs`) | The nav **route** object: type (`eNavType`), waypoint list, filename I/O, mutators. Owns the `"uTank2 NAV 1.2"` format. | +| `uTank2.eWaypointType` / `uTank2.eNavType` | The two public enums (waypoint kind, route kind). | +| `bz` (`bz.cs`) + `g3` (`g3.cs`) | The waypoint contract: `bz` = position/distance/arrival/type/bonus/label/reset; `g3` = `TextReader`/`TextWriter` (de)serialization. Every waypoint class implements both. | +| `at` (`at.cs`) | Abstract base for the five "action" waypoint kinds (Pause, ChatCommand, OpenVendor, Portal2, UseNPC). Provides the run-once arrival gate (`p()`) and reports the **player's live position** as the node's own coordinate. | +| `fn`,`fq`,`a4`,`f4`,`e`,`v`,`e9`,`fa`,`gr`,`di` | The ten waypoint implementations (Point, Portal, Recall, Pause, ChatCommand, OpenVendor, Portal2, UseNPC, Checkpoint, Jump — same order as `eWaypointType`). | +| `gl` (`gl.cs`) | The single "follow this object" node used by `eNavType.Target` routes (VTank's Follow/Object-PF feature). | +| `ca` (`ca.cs`) | The **cycle-advance** driver: implements `bz` over the *whole route*, dispatches Circular/Linear/Once/Target semantics, and is itself handed to the mover as "the current thing to walk toward." | +| `fd` (`fd.cs`) | The **close-in mover**: per-tick steering/turning/creep state machine, reused for nav routes, corpse approach, and combat approach. | +| `g8` (`g8.cs`) | `ILogicRule` wrapper around one `fd` instance — the "Navigate" rule (also reused for corpse-approach and target-approach rules under different names/settings). | +| `b7` (`b7.cs`) | `ILogicRule` for the "OpenDoor" rule — door identify/open/lockpick, fully separate from `g8`/`fd`. | +| `eb` (`eb.cs`) | The `bz` used by the **combat** target-approach `g8` instance — same interface as nav waypoints, different backing data (current attack target). | +| `da` (`da.cs`) | Per-character macro-profile container; owns the `x` route instance (`da.k`), the `.nav` filename (`da.n`), and save/load (`da.r()`/`da.o()`). | +| `bi` (`bi.cs`) | The Jump waypoint's execution state machine (turn-to-heading → charge → release → wait-for-landing). | +| `d` (`d.cs`) | A lightweight EW/NS/Z coordinate+distance helper, independent of `sCoord`, used by Portal2/UseNPC/Checkpoint for "compass" positions and distance math. | + +## 1. `.nav` format, byte-for-byte + +### 1.1 Header and route-level fields + +Format constant and reader/writer: `x.cs:9` (`"uTank2 NAV 1.2"`), reader +`x.cs:200-322` (`a(TextReader)`), writer `x.cs:324-360` (`a(TextWriter)`). +Every field is one line (`TextWriter.WriteLine`/`TextReader.ReadLine`), +`CultureInfo.InvariantCulture` throughout (`x.cs:209,227,241-243`), no +delimiter escaping — a `ChatCommand` payload containing an embedded newline +would desync the reader (not accounted for in the ground truth). + +| Line(s) | Field | Values | Cite | +|---|---|---|---| +| 1 | Header | literal `uTank2 NAV 1.2` — mismatch → load refused, error to chat | `x.cs:9,204-208` | +| 2 | Route type (int) | `1`=Circular, `2`=Linear, `3`=Target, `4`=Once | `x.cs:210-223` | +| **If Target (3):** | | | | +| 3 | Follow target name (string) | display-only, not used to re-find the object | `x.cs:226` | +| 4 | Follow target object id (int) | the live game GUID to follow; `0` = no target added | `x.cs:227-231` | +| **Else (Circular/Linear/Once):** | | | | +| 3 | Waypoint count (int) | | `x.cs:235` | +| 4..N | One record per waypoint (§1.2) | | `x.cs:236-320` | + +Writer mirrors this exactly (`x.cs:326-359`); there is no version field beyond +the header string itself — VTank has shipped exactly one `.nav` format +revision (1.2) as far as this decompile shows. + +### 1.2 Per-waypoint record + +Every waypoint, regardless of type, starts with a **fixed 5-line header** +(`x.cs:238-245` read / `x.cs:353-357` write): + +| Line | Field | Notes | +|---|---|---| +| 1 | Waypoint-type int (0-9) | dispatches the `switch` at `x.cs:246-318`; matches `eWaypointType` numerically | +| 2 | East/West (double) | | +| 3 | North/South (double) | | +| 4 | Elevation/Z (double) | | +| 5 | **Discarded** — writer always emits literal `0.0` (`x.cs:357`); reader reads and throws the line away (`x.cs:245`) | Not `sWaypointDesc.bonus`; that field exists in the struct (`sWaypointDesc.cs:7`) but is never populated from this stream. Dead placeholder in the shipped format. | + +**Critical quirk — the header x/y/z triple is meaningless for five of the ten +waypoint types.** Point (`fn`), Portal (`fq`), Recall (`a4`), Checkpoint +(`gr`), and Jump (`di`) store/report a real, load-bearing coordinate here. +Pause (`f4`), ChatCommand (`e`), OpenVendor (`v`), Portal2 (`e9`), and UseNPC +(`fa`) all extend `at`, whose position accessor is +`f9.a(this.c.ay.CharacterFilter.Id, this.c.az.Actions)` — **the player's own +live position** (`at.cs:19-23`) — not a stored waypoint location. Because the +writer calls `item.e()` (`bz.e()`) uniformly for every node +(`x.cs:354-356`), the header x/y/z written for those five types is simply +wherever the character was standing at the moment "Save" was clicked, and is +never read back into anything meaningful. Confirmed against three real +files (§1.4): every Pause/ChatCommand node in a route shares the exact same +x/y/z as its neighbors, because they were all saved from one stationary +position. + +Per-type payload (after the 5-line header), reader `x.cs:246-318`, writer +`x.cs:353-358` (dispatches to `item.f(TextWriter)`): + +| Type | Class | Extra fields (in order) | Cite (read / write) | +|---|---|---|---| +| 0 Point | `fn` | *(none)* | `fn.cs:35-53` (both no-ops) | +| 1 Portal | `fq` | int: portal-use object id | `fq.cs:47-61` | +| 2 Recall | `a4` | int: recall spell id | `a4.cs:48-62` | +| 3 Pause | `f4` | int: duration, milliseconds | `f4.cs:24-38` | +| 4 ChatCommand | `e` | string: the literal command line | `e.cs:23-37` | +| 5 OpenVendor | `v` | int: vendor object id, then string: vendor name | `v.cs:28-44` | +| 6 Portal2 (`PortalByName`) | `e9` | string: object name; int: `ObjectClass`; then a `d`-record: bool "valid" + double EW + double NS + double Elev | `e9.cs:169-187` (delegates the last 4 lines to `d.a(TextReader/TextWriter)`, `d.cs:185-200`) | +| 7 UseNPC | `fa` | string: NPC name; int: `ObjectClass`; then the same `d`-record (bool+3 doubles) | `fa.cs:134-152` | +| 8 Checkpoint | `gr` | *(none)* | `gr.cs:40-58` (both no-ops — the header x/y/z **is** the real, load-bearing checkpoint coordinate here, unlike types 3-5) | +| 9 Jump | `di` | double: heading (degrees); bool: "Shift" (walk vs run); one combined line: `charge-ms formatted "0.0000" + direction digit` where digit ∈ {`3`=Forward,`4`=StrafeLeft,`5`=StrafeRight}, parsed by regex `^(?[0-9]+\.[0-9]{4})(?3|4|5)$` (falls back to plain-double + Forward if the regex doesn't match, e.g. an old file predating the direction suffix) | `di.cs:116-172,180-183` | + +Types 6/7's embedded `d`-record is the **real** target coordinate (matched +against live world objects with a name+class+proximity search — §2); the +outer header x/y/z for those two types is exactly as meaningless as it is +for 3/4/5, since `e9`/`fa` also extend `at`. + +### 1.3 String encoding, coordinate units + +- Every string field is one raw line via `TextWriter.WriteLine`/`ReadLine` — + effectively the platform default text encoding of a `StreamWriter` + (`x.cs:189`, no explicit `Encoding` passed) with `\r\n` line endings on + Windows. No escaping of embedded newlines. +- Doubles are written with `Convert.ToString(double, InvariantCulture)` + (round-trip "R"-ish default formatting) and parsed with + `Convert.ToDouble(string, InvariantCulture)` — plain decimal, no scientific + notation guard (though .NET will happily parse `E`-notation on read, and a + small elevation value like `2.70833571751912E-05` was observed verbatim in + a real file — see §1.4). +- **Coordinate units are VTank's own EW/NS/Z "sCoord" units, not meters.** + The conversion constant `240.0` (units-per-meter) is confirmed + independently at **three** unrelated call sites, which upgrades this past + a single-line inference: + - `d.cs:46-47` — converts a raw landblock+offset position into EW/NS by + dividing an accumulated offset by `240.0`. + - `sCoord.cs:42` — converts a cell's height field with `hf2.k() / 240f`. + - `dz.cs:239` (cited in the prior trace note) — divides `MyRangeMeters` by + `240.0` to compare against an sCoord distance. + - `ch.cs:807,848` — the user-facing expression functions + `coordinatedistancewithz`/`coordinatedistanceflat` literally multiply + the internal `d`-distance by `240.0` to report meters to scripts. + + So: **1 sCoord unit = 240 meters is CONFIRMED, not inferred** (superseding + the prior trace note's "one inference from one line" caveat). Worked + examples: `NavCloseStopRange` default `0.00833333333333333` = 1/120 = + **2 m**; `AttackDistance` default `0.0208333333333333` = 1/48 = **5 m**; + `UsePortalDistance` default `0.0166666666666667` = 1/60 = **4 m**; the + Portal2/UseNPC candidate-match radius constant `0.0104166` (`e9.cs:87`, + `fa.cs:82`) = **2.5 m**. + +### 1.4 Worked decodes of real files + +**`bunny_stuck_jump.nav`** (384 bytes, a hand-authored anti-stuck macro — +notable in its own right, see §3.2): + +``` +uTank2 NAV 1.2 <- header +4 <- route type 4 = Once +5 <- 5 waypoints +3 <- node 1: type 3 = Pause +59.3058208465576 <- EW (meaningless: at-derived, = player pos at save) +-28.5630541483561 <- NS +0.0500250021616618 <- Elev +0 <- discarded placeholder +2000 <- Pause duration, ms +4 <- node 2: type 4 = ChatCommand +59.3058208465576 / -28.5630541483561 / 0.0500250021616618 / 0 <- same, meaningless +/ub face 270 <- chat text (a third-party plugin's face-heading command) +3 / (coords) / 0 / 2000 <- node 3: Pause 2000 ms +4 / (coords) / 0 / /ub jumpw 250 <- node 4: ChatCommand "/ub jumpw 250" +3 / (coords) / 0 / 2000 <- node 5: Pause 2000 ms +``` + +Route: Pause 2 s → `/ub face 270` → Pause 2 s → `/ub jumpw 250` → Pause 2 s, +Once (consumes itself). This is direct field evidence for the finding in +§3.2: VTank's native waypoint types 3-9 give an author no built-in +generic-stuck recovery, so real users route around it with ChatCommand +nodes that invoke a *different* plugin's jump command, gated by Pause nodes +for timing. Every waypoint's header coordinate is identical across the +route (confirming the "at" position quirk from §1.2 — this was saved from +one standing position). + +**`deathnav.nav`** (284 bytes) — a Point-based example, Once route: + +``` +uTank2 NAV 1.2 +4 <- Once +4 <- 4 waypoints +0 / 58.5531522115072 / 84.7372886339823 / 2.70833571751912E-05 / 0 <- Point 1 +0 / 58.5493430455526 / 84.7770081520081 / -0.00038958340883255 / 0 <- Point 2 +0 / 58.5451157569885 / 84.821087773641 / -0.00184791684150696 / 0 <- Point 3 +3 / 58.8850743492444 / 84.9831307411194 / -0.00372291654348373 / 0 / 10000 <- Pause 10 s +``` + +Three real, load-bearing Point coordinates (a short walk, e.g. away from a +corpse) followed by a 10-second Pause whose own header coordinate is simply +wherever the character stood after the last Point — consistent with §1.2. + +## 2. Route types and waypoint execution semantics + +### 2.1 The cycle-advance driver (`ca`, wraps the whole route) + +`ca` (`ca.cs`) itself implements `bz` over the **entire route** and is what +`fd` (the mover) is actually given as its target (`cLogic.cs:507,569`: +`new g8(0, "NavCloseStopRange", "NavFarStopRange", new ca(PluginCore.dz))`). +`fd` never sees individual waypoints; each tick it asks `ca` for "current +position" (`ca.i()`, `ca.cs:56-75`) and "current distance" (`ca.n()`, +`ca.cs:84-346`), and `ca.n()` is where all route-type advancement logic +lives. + +`ca.n()` walks a **while loop**: as long as the current waypoint's own +distance is below `NavCloseStopRange`, it advances the index (per the rules +below), calls the new waypoint's `.i()` reset hook, and re-checks — so a +tick can silently skip through several already-reached waypoints in one +call (`ca.cs:196-343`). A "still executing" waypoint (its `bz.g()` returns +true — mid-Pause, mid-portal-wait, etc.) is treated as an artificial huge +distance (`999999.0`, `ca.cs:442-447`) so the loop never tries to advance +past it. + +| Mode | Enum | Index advance rule | Exhaustion behavior | Cite | +|---|---|---|---|---| +| Circular | `eNavType.Circular` (1) | `index++`, wraps to `0` at the end (or `index--` wrapping to `Count-1` if `o.m` "reverse" is set) | never exhausts | `ca.cs:122-146,196-250` | +| Linear | `eNavType.Linear` (2) | `index++` until the last node, then flips `o.m=true` and starts decrementing back to `0`, flipping again at `0` — a ping-pong | never exhausts (bounces forever) | `ca.cs:147-177,252-306` | +| Once | `eNavType.Once` (4) | always operates on index `0`; on arrival, `RemoveAt(0)` — **the route list is mutated/consumed** | when the list empties, distance reports `0.0` and `PluginCore.PC.m()` fires (route-complete notification) | `ca.cs:178-190,308-343` | +| Target | `eNavType.Target` (3) | not index-based — single `gl` node tracks a live object id; see §2.3/§4 | position/distance become "invalid" if the target object or the follower's own single node disappear | `ca.cs:58-68,87-97` | + +`x.a(bool)` (`x.cs:81-94`) is a route-edit-in-progress flag: while editing +(`true`), waypoint mutations skip the "notify + reset index" side effects +(`x.cs:96-129`); on commit (`false`), the route data (`m.d()`), the UI grid +(`a0.l()`), and the current index (`o.k()`) are all refreshed at once. This +is the batching seam a Route-tab "Apply"/multi-edit UI hangs off of. + +The user-visible `NavPrio`/`LootPrio`/`NavLootPrio` hotkeys (`da.cs:803-805`) +and the `NavPriorityBoost`/`LootPriorityBoost` settings are what move the nav +rule earlier in `cLogic`'s list — see §5. + +### 2.2 Per-waypoint-type execution semantics + +Every waypoint's `bz.g()` (`m()`/`p()` depending on class) answers "is this +node still busy" each tick; `bz.f()` answers "what is my distance"; `bz.e()` +answers "what is my position." The five `at`-derived types share one +run-once gate: `at.p()` (`at.cs:43-61`) calls the subclass's `e()` setup +exactly once, then calls `f()` every tick until `f()` first returns `false`, +at which point the node is **permanently done** for this visit (a later +`o()`/`bz.i()` reset call — issued when the route revisits the node on a +Circular/Linear lap — re-arms it, `at.cs:69-73`). + +| Type | Arrival/completion condition | Timeout / retry | Notes | +|---|---|---|---| +| **Point** (`fn`) | `bz.g()` always `false` (never "busy") — distance is a plain live 3D calc via `f9.a()`; the cycle-advance loop treats it as arrived once within `NavCloseStopRange` | none | Simplest type — no other state. `fn.cs:79-89,115-119` | +| **Portal** (`fq`) | `bz.g()` (arrival-flag `m_e`) is normally `true`; goes `false` for exactly one tick right after a `TryingPortal` lock expires without ever having entered portal space, so the route advances past a portal that silently failed to trigger | Inside `UsePortalDistance`, sends `UseItem` once (guarded by `ItemUse` lock, 3 s) then holds a 30 s `TryingPortal` lock while waiting for the client to actually enter portal space | `fq.cs:94-141` | +| **Recall** (`a4`) | `bz.g()` (`m()`) is `true` ("busy") until the caster is confirmed stationary (moved `<0.01` sCoord ≈ 2.4 m since last check) **and** combat mode is forced to Magic via `ga.a(Magic,0,anyItem:true)` (the FCM path from the prior trace note), at which point the spell is cast and the node reports itself no-longer-busy on the *next* tick regardless of cast outcome | none — no cast-failure retry | Distance is a fixed `0.5` sCoord constant (not a real distance), so this node never blocks the cycle-advance "close enough" test on proximity, only on the busy flag. `a4.cs:82-123` | +| **Pause** (`f4`) | `f()` returns `Now < deadline` (busy while the timer hasn't elapsed); `e()` arms the deadline as `Now + durationMs` | none | Pure timer. `f4.cs:70-78` | +| **ChatCommand** (`e`) | `e()` arms a 200 ms guard timer; `f()` stays busy through that guard, then busy again while `Actions.BusyState != 0`, then sends the command via `f9.j(text)` and completes | none (no confirmation the command "worked") | `e.cs:69-87` | +| **OpenVendor** (`v`) | **Does not wait for the vendor window to actually open.** `f()`: if `Actions.VendorId == target` already, stays busy (blocks the route while that exact vendor is open — this only matters on a *later* re-arrival, see below); otherwise resolves the object, and unconditionally returns `false` (done) after issuing one `UseItem` gated at ≥2 s since the last attempt. On a fresh node visit this fires-and-forgets a single open attempt and the cycle-advance loop treats the node as arrived (distance-when-not-busy is a constant `0.0`, `at.q()`) — **the route advances to the next waypoint whether or not the vendor actually opened.** | 2 s internal retry gate, but only relevant while the node stays busy from a *prior* successful open | `v.cs:76-104`, `at.cs:31-41` | +| **Portal2 / PortalByName** (`e9`) | Two-state machine: state **a** — waits out the `ItemUse` lock, re-finds the target by name+`ObjectClass`+nearest-of-the-`d`-filter match (radius `0.0104166` ≈ 2.5 m, `item.c()==0` gate of undetermined meaning — see §7), sends `UseItem`; state **b** — waits `PluginCore.@do >= startFrame + 2` (a **rendered-frame counter**, not a timer!) then checks the client is out of portal space and ≥`0.0625` sCoord (=15 m) from the pre-portal origin; if too close, warns "came out of portal space too close to the origin point" and restarts state a | none beyond the 2-frame + 15 m re-check loop | `e9.cs:94-143` | +| **UseNPC** (`fa`) | Re-finds the NPC each tick by the same name+class+2.5 m nearest-match; sends one `UseItem`; completes when a `ChatTextInterceptEventArgs` handler observes color-3 text starting with `"{name} tells you, "` **or** color-0 text starting with `"{name} gives you"` | none (waits forever for the chat line) | `fa.cs:65-107,160-177` | +| **Checkpoint** (`gr`) | Blocks (`p()` returns `true`) while within `NavCloseStopRange` of the stored coordinate by the mover's own tracking (`f9.a`) **but** the object-table's independently-tracked position (`fu.z`, via `PluginCore.dz.q.f(PluginCore.dn).z`) disagrees (still `>= NavCloseStopRange` from the target). This is the **only general-purpose stuck/anti-wedge mechanism in the whole nav engine** — see §3.2. | If the two trackers keep disagreeing for **15 s**, presses the forward key (`br.ai`) once, releases it 100 ms later, and bumps the global casting-suspend counter (`ga.h()`/`ga.e()`, the same counter used to pause all other logic rules mid-cast) around that single nudge | `gr.cs:94-196` | +| **Jump** (`di`) | Delegates entirely to the `bi` state machine (§3.3); `e()`/`f()` just start/poll it | none beyond `bi`'s own internal turn/charge/land phases | `di.cs:60-73` | + +### 2.3 Target (Follow) route (`gl`) + +`eNavType.Target` routes hold exactly one `gl` node tracking a live object +id (`x.a(int,string)`, `x.cs:47-54`). `gl` is the ONLY waypoint type with two +distinct behavior modes gated by the `FollowAroundCorners` setting: + +- **Off**: position is simply the target's live position each tick + (`gl.cs:130`), distance is a straight 3D calc (`gl.cs:151`). +- **On**: `gl` records a breadcrumb trail of the target's positions + (deduping points closer than `0.0004` sCoord ≈ 0.1 m apart, `gl.cs:103-106`), + and walks that trail backward from the follower's current position, + discarding any breadcrumb segment already passed within `0.01` sCoord + (≈2.4 m) of the segment **and** within `1.0` sCoord (=240 m) of the target + overall (`gl.cs:98-116`) — i.e. it hands the mover the *nearest un-passed + breadcrumb*, not the target's raw live position, so the follower cuts + corners around the same path the target actually walked rather than + beelining through walls. This runs on a 70 ms-budgeted timer tick + (`gl.cs:67-79`, `d8.a("...", 70.0)`), independent of the main logic-tick + cadence. + +## 3. Movement + +### 3.1 Autorun vs. held keys, turning, stop ranges (`fd`) + +`fd` is a single per-tick steering function (`fd.a()`, `fd.cs:311-386`) +reused by three different `g8` instances (§5): the main nav route +(`NavCloseStopRange`/`NavFarStopRange`), a corpse-approach rule +(`CorpseApproachRange-Min/Max`), and the combat target-approach rule +(`AttackDistance`/`ApproachDistance`, backed by `eb` rather than a waypoint). +Every tick: + +1. Compute `num` = target's own reported distance (`bz.f()`), `num2` = + bearing to target (`f9.b(sCoord,sCoord)`), `value` = signed-magnitude + heading delta (`f9.b(double,double)`, always the *smaller* of the two + possible arcs, 0-180°). +2. **If the target node itself reports "busy"** (`bz.g()`, e.g. a Pause + counting down, a portal-use in flight, an unconfirmed vendor-open): every + held key and autorun is released and the tick returns immediately — the + character stands still while the waypoint's own action executes + (`fd.cs:322-328`). +3. **Else if `f9.j()`** (wraps `ad.a(PluginCore.dz.az)` — the exact predicate + is not independently confirmed, but its usage pattern strongly suggests + "chat input box has focus," since held movement keys would otherwise type + into an open chat box — see §7): releases strafe keys; if the heading + delta exceeds **4°**, stops all movement and re-issues an absolute + `Actions.FaceHeading()` server command at most once per 0.7 s (a snap-turn, + not a held key); if aligned, engages the **creep** state (§3.1.1) + (`fd.cs:329-347`). +4. **Else** (normal open-world steering): picks one of two strafe keys via + `f9.c(heading, bearing)` (a "nudge and see which way shrinks the error" + test, `f9.cs:1061-1068`) and holds it to gently curve the character while + walking forward, rather than snap-turning; then gates forward movement on + a **two-tier** heading tolerance keyed off distance: + `num > 0.0125` (≈3 m): allow forward unless `|value| > 45°`; + `num ≤ 0.0125`: allow forward unless `|value| > 15°` + (`fd.cs:348-379`). MossTank's `Steer()` reproduces this exact 3 m / + 45° / 15° structure. +5. If none of the above (aligned within 4°, any distance): stop strafing, + move forward (`fd.cs:380-385`). + +#### 3.1.1 The creep sub-band (SetAutorun vs. held Shift+Forward) + +`fd.a(bool,double)` (`fd.cs:112-177`) is the single mover-state toggle, +distinguishing two DIFFERENT movement styles by whether the reported +distance is inside the constant `fd.a = 1.0/160.0` (≈1.5 m): + +- **Inside 1/160 ("creep")**: holds the Shift key (`br.c8`) together with + Forward (`br.ai`) — i.e. a *walking*, not running, forward hold. If the + character is in Peace mode at this point, the mover force-switches combat + mode to Magic via `ga.a(Magic,0,anyItem:true)` (the same FCM saga as + Recall) and warns *"Idle peace selected with low waypoint minimum + distance. Will switch to magic mode."* if `IdlePeaceMode` is set + (`fd.cs:118-139`). +- **Outside 1/160 ("run")**: `SetAutorun(true)` (`fd.cs:154-158`). + +Both styles **re-assert themselves every 4 seconds** while active +(`fd.a = TimeSpan.FromSeconds(4.0)` set in the constructor, re-armed at +`fd.cs:151-152,156-157,160-174`) — a periodic keepalive re-press of the held +key or re-call of `SetAutorun(true)`, the closest thing to an anti-desync +mechanism the general mover has (it is not a stuck *detector*, just an +unconditional re-assert). There is no equivalent of VTank's own 1/160 creep +sub-band or the 0.7 s `FaceHeading` snap-turn in MossTank's `Steer()` — see +§6. + +### 3.2 Stuck detection / recovery — there is exactly one mechanism + +There is **no string "Stuck" or "Unstick" anywhere in the decompiled tree**, +and no generic "player hasn't moved in N seconds, do something" watchdog. +The *only* built-in anti-wedge behavior is the Checkpoint waypoint's 15 s +dual-position-disagreement nudge (§2.2). A **Point** waypoint that the +player physically cannot reach (geometry-wedged, stuck on a rock) has **no** +native recovery at all — the mover will simply hold its steering forever. + +This is corroborated by real user data: `bunny_stuck_jump.nav` (§1.4) is a +hand-authored Once route that exists *purely* to unstick the bot, and it +does so by chaining `ChatCommand` nodes into a **different, external** +plugin's jump command (`/ub jumpw 250` — not VTank's own `Jump` waypoint +type), bracketed by `Pause` nodes for timing. In practice, route authors +route around VTank's missing general stuck-recovery by hand, using +ChatCommand + a third-party utility, rather than relying on anything native. + +### 3.3 Jump execution (`bi`) + +`di.a(TextReader)`/`bi.a(double heading, bool shiftKey, double chargeMs, +bi.d direction)` (`bi.cs:496-516`) drives a small state machine +(`bi.cs:389-545`, states enumerated `a`-`i`): + +1. **Turn-to-heading** (if start heading differs ≥3° from the target — note + this is a *separate*, tighter constant than `fd`'s general 4° tolerance): + repeatedly calls `Actions.FaceHeading()` every 2000 ms until aligned + (`bi.cs:74-93`). +2. **Charge**: holds Forward (or the configured Strafe-Left/Strafe-Right key, + `bi.a(bi.d)` → `MovementForward`/`MovementStrafeLeft`/`MovementStrafeRight`, + `bi.cs:431-441`) plus optional Shift, plus the Jump key, for the + configured duration **clamped to at most 2000 ms** + (`bi.cs:502-504,524-526`) — a real, verifiable ceiling MossTank does not + visibly reproduce (see §6). Aborts mid-charge if `Actions.ChatState` + becomes true (`bi.cs:162-166`). +3. **Release**: releases held keys in order (jump, strafe/forward, shift) + with the state machine tracking which keys are actually down + (`bi.j.a/b/c` flags, `bi.cs:167-218`). +4. **Wait for landing**: polls up to a **15 s** timeout comparing the + captured pre-release position (`d`-record) against the live position + with a `1/120` sCoord (2 m) settle threshold (`bi.cs:358-362`); + `PluginCore.dz.aw.d()` (the `bi.d()` "is busy" accessor) reports + completion. + +## 4. Follow/stick and target approach + +VTank reuses **one** mover class (`fd`) and **one** waypoint contract +(`bz`) for three conceptually different "walk toward a moving/fixed thing" +situations, each wired through its own `g8` instance in `cLogic.cs`: + +| Situation | `g8` settings (min/max range) | `bz` backing | Cite | +|---|---|---|---| +| Nav route (Point et al.) | `NavCloseStopRange`/`NavFarStopRange` | `ca` (the whole route) | `cLogic.cs:507,569` | +| Nav route, **Target** mode (Follow) | same range settings | `gl` (single object-follow node, §2.3) | `x.cs:47-54`, `gl.cs` | +| Corpse approach (loot) | `CorpseApproachRange-Min/Max` | `fg` (not read in this pass — a corpse-specific advancer) | `cLogic.cs:492,535` | +| Combat target approach | `AttackDistance`/`ApproachDistance` | `eb` (tracks `PluginCore.dz.p.a.b`, the active combat target's live position) | `cLogic.cs:559`, `eb.cs:55-79` | + +`eb` is deliberately simple relative to `gl` — a straight 2D/3D distance and +bearing to the current combat target's live position, no breadcrumb/corner +logic, no busy flag (`m()` is always `false`) — because in combat the +target is expected to be visible with a mostly-open line of movement, unlike +a nav Follow target that may be walking around a building. Both share `fd` +for the actual steering, which is the real "shared with combat" seam: **the +turning/creep/autorun machinery in §3.1 is identical code for nav-following +and combat-approaching** — only the position/distance provider differs. + +## 5. Priority and interaction with other rules + +### 5.1 Full `cLogic` rule order (`cLogic.cs:459-577`) + +``` +START + SpellCompMin-Critical + (critical spell components) + Recharge-Norm-* + BuffSelf (a0) + fz: normal rebuff-timer buff + SpellCompMin-Normal +POSTBUFF + DispelSelf (c8) / UseDispelItem (cx) + Recharge-Helper-* (fb, gu) + DispelAllies (af) + CraftFood (a9) + RefillPetCharges (dq, Normal) +POSTHELPER + Autofellow (g5) +POSTAUTOFELLOW + OpenDoor (b7) <-- door/lockpick, BEFORE all loot/nav/attack +PREPRIORITYLOOTACTIONS + priority loot actions (er/aj/ar, gated EnableLooting+LootPriorityBoost) +POSTPRIORITYLOOTACTIONS +PREPRIORITYLOOT + priority corpse-approach (g8, CorpseApproachRange, gated LootPriorityBoost) + priority salvage/loot (bj/d0/a1) +POSTPRIORITYLOOT +PREPRIORITYNAV + priority nav route (g8 "mr", NavCloseStopRange/NavFarStopRange, + gated NavPriorityBoost) <-- toggled by /vt nav priority equivalent +POSTPRIORITYNAV +PREATTACK + Attack (b4) +POSTATTACK +PREIDLESTATUS + idle spell comps / craft / pet refill (idle variants) +PREIDLELOOTACTIONS + idle loot actions (+ cm(0) IdlePeace pre-chain) +POSTIDLELOOTACTIONS +PREIDLELOOT + idle corpse approach (g8, CorpseApproachRange, + cm(0) pre-chain gated + on the SAME proximity-band custom delegate as the + mover itself) + idle salvage/loot (+ cm(0)) +POSTIDLELOOT +PREIDLEBUFF + idle buff top-off (fz, gated IdleBuffTopoff) +POSTIDLEBUFF +PRETARGETAPPROACH + target approach (g8 "g11", AttackDistance/ApproachDistance, + cm(0) pre-chain) +POSTTARGETAPPROACH +PREIDLERECHARGE + Recharge-NoTarg-* +POSTIDLERECHARGE +PRENAVROUTE + main nav route (g8 "g12", NavCloseStopRange/NavFarStopRange, + cm(0) + pre-chain gated on the mover's own proximity-band delegate) +POSTNAVROUTE + ba (unidentified, low priority) +END +IdlePeace standalone (cm) <-- last resort: drop to peace if nothing else claimed it +``` + +Rule dispatch is **first-match-wins in list order** (`cLogic.cs:222-235`) — +"Priority" on `ILogicRule` is purely an index, not a numeric comparison. +This means the ONLY way navigation runs earlier than loot/attack is the +duplicate, `NavPriorityBoost`-gated `g8` instance placed physically earlier +in the list (`PREPRIORITYNAV`) — there is no dynamic re-sorting. + +### 5.2 IdlePeace pre-chain and interruption + +`cm` (IdlePeace) is registered **six times**: once as a `LogicRulePreChain` +pre-action on ReadScroll/StackCram/Salvage idle rules, idle corpse approach, +idle loot, target approach, and the main nav route, plus once standalone at +the very end (`cLogic.cs:530-577`, corroborated by the prior trace note). +Pre-chain semantics: when the *main* rule (e.g. main nav route) is picked as +the tick's winner, its pre-action (`cm`) runs **instead**, for that one +tick, if `cm` itself is currently valid; the following tick, `cm` is no +longer valid (already at peace) so the main rule finally runs — i.e. "go to +peace first, then act," spread over two ticks minimum. `cm`'s own gate is +just `IdlePeaceMode` setting + `CombatMode != Peace` +(prior trace note, `cm.cs:66,70`); everything else suppressing it is +**structural** — it only runs where explicitly chained ahead of an idle rule. + +Combat/casting suspends the *entire* rule loop globally +(`ga.u` counter, `cLogic.cs:214`, incremented/decremented at `gj.cs:211/226` +and `gj.cs:198` respectively) — this is the same counter Checkpoint's 15 s +stuck-nudge bumps around its single key-press (§2.2), meaning a Checkpoint +nudge briefly pauses buffing/looting/attacking too. + +Loot and combat interrupt/resume nav implicitly through list order and +locks, not through any nav-specific pause flag: `OpenDoor` (b7) takes +`ActionLockType.Navigation`/`ItemUse`/`DoorOpening` locks while opening a +door (`b7.cs:106,113-128,213-224`), and `g8.b()` (the Navigate rule's +`ValidNow`) explicitly refuses to run while `Navigation`, +`SpreadLockTargetRequested`, or `DoorOpening` locks are held +(`g8.cs:91-103`) — so door-opening always wins over the mover for as long +as its locks are held, and a higher-priority loot/attack rule simply never +lets control reach the nav-route rule that tick (first-match-wins). + +## 6. MossTank gap analysis + +Files reviewed: `src/AcDream.Plugins.MossTank/Navigation.cs`, +`VtankNavRouteSerializer.cs`, `MossTankRouteProfileStore.cs`, and their +tests (`tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs`, +`VtankNavRouteSerializerTests.cs`). + +**Can a real `.nav` file load today? Yes.** `VtankNavRouteSerializer.TryLoad` +implements the exact header/route-type/waypoint-record grammar in §1, +including the correct discard of the placeholder line, the correct +per-type payload for all ten waypoint types, and — notably — correctly +special-cases Portal2/UseNPC to overwrite the (meaningless) outer header +coordinate with the embedded `d`-record's own coordinate (`VtankNavRouteSerializer.cs:151-157`). +It is wired to actual import via `MossTankRouteProfileStore.TryImportLegacy`, +which reads a `.nav` text file from plugin storage by filename. Manual +verification against `bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips +cleanly against the documented grammar. + +Ranked by impact (highest first): + +| # | Gap | Ground truth | acdream (`Navigation.cs`) | Impact | +|---|---|---|---|---| +| 1 | **OpenVendor does not wait for confirmation in VTank, but MossTank does.** | `v.f()` fires one `UseItem` and reports itself done on the very first tick regardless of outcome (§2.2) — the route advances immediately, vendor-open success or not. | `TickUse`'s OpenVendor path waits for `ActiveVendorObjectId == waypoint.ObjectId`, retrying every 2 s up to a 30 s timeout (`Navigation.cs:730-736,805-826`). | **High** — behaviorally the single biggest divergence found: real VTank routes with an OpenVendor node effectively "fire and forget," while acdream's port will block the whole route for up to 30 s if the vendor never opens (e.g. NPC out of range, wrong id). Likely a deliberate improvement, but it is not the same routine and should be a conscious, documented choice per this project's "no workarounds/redesigns without flagging the tradeoff" rule. | +| 2 | **Lockpick selection strategy differs.** | `b7.a()` (`b7.cs:52-81`) only considers items the user has explicitly tagged in the "AssistItems" list as `fs.k`, and picks the tagged item with the **lowest** remaining uses (`bc.aa`) — i.e. a manually-curated list, consumed lowest-charge-first. | `SelectLockpick` (`Navigation.cs:515-549`) scans **all** owned items carrying a `LockpickPublicFlag` bit (auto-detected by item data, no user list) and picks the **highest**-bonus item (`LockpickModifierProperty`). | **Medium-high** — opposite selection heuristic (auto-detect + best-tool vs curated-list + use-up-worst-first) means a different physical item gets consumed first, and acdream needs no manual "tag this as a lockpick" step VTank required. Not a bug per se, but a real, evidenced behavioral difference worth a deliberate call-out. | +| 3 | **No creep sub-band or discrete snap-turn.** | Inside 1/160 sCoord (≈1.5 m), `fd` switches from held-key strafing to a walk-speed Shift+Forward creep, with periodic (0.7 s) absolute `FaceHeading()` snap-turns when misaligned, and force-switches combat mode to Magic if in Peace at this range (§3.1.1). | `NavigationController.Steer()` (`Navigation.cs:611-635`) has exactly one steering mode: continuous held-key `TurnLeft`/`TurnRight`/`Forward` with `Run: true` always set, for every distance band. No creep, no FaceHeading, no combat-mode interaction. | **Medium** — likely an intentional simplification (acdream's movement primitive may not need VTank's turning workaround), but it means acdream never walks (vs. runs) on final approach and never forces Magic mode near a tight waypoint, which was one of VTank's real, user-visible quirks (the "Idle peace selected with low waypoint minimum distance" warning has no acdream analogue at all). | +| 4 | **Recall's "must be stationary" + forced Magic-mode gate is not visibly reproduced.** | `a4.m()` refuses to cast until the caster has been stationary (movement `<0.01` sCoord ≈2.4 m) since the last check, and force-switches combat mode to Magic via the FCM sequence before casting (§2.2). | `TickRecall`/`SubmitRecall` (`Navigation.cs:850-914`) call `Automation.Magic.Cast(...)` directly with no visible stationary check or explicit combat-mode sequencing in this file. | **Medium** — could not determine whether `Automation.Magic.Cast` internally handles combat-mode sequencing at a lower plugin-abstraction layer (out of scope for this file); if it does not, casting Recall while still moving, or from Melee/Missile mode, would diverge from ground truth. | +| 5 | **Jump-charge duration is not clamped to VTank's 2000 ms ceiling.** | `bi.a(...)` clamps any requested charge duration to at most 2000 ms (`bi.cs:502-504,524-526`) — this appears to be a deliberate ceiling on how long the jump key can be held. | `RouteWaypoint.JumpChargeMilliseconds` is clamped to `[0, 10_000]` on load (`MossTankRouteProfileStore.cs:428-431`) and not further bounded in `TickJump`. | **Low-medium** — a route (VTank-authored or hand-edited) requesting >2000 ms would charge a jump far longer in acdream than real VTank/retail ever would; low likelihood in practice since real `.nav` files were themselves produced under the 2000 ms VTank ceiling, but a hand-edited or generated route could exceed it. | +| 6 | **Portal2/UseNPC candidate filter omits the ground truth's `item.c()==0` gate.** | `e9.g()`/`fa.g()` only consider candidates where `item.c() == 0` (§7 — exact meaning undetermined, plausibly "not dead"/"visible") in addition to name+class+proximity. | `TryFindObject` is opaque from this file (defined elsewhere in the plugin abstraction); could not confirm whether an equivalent filter exists. | **Low** — flagged for follow-up rather than asserted as missing. | +| 7 | **Chat-color gate on UseNPC's "got a response" detection is dropped.** | `fa.a(ChatTextInterceptEventArgs)` only accepts color-3 "tells you" or color-0 "gives you" lines (`fa.cs:160-177`). | `HasNpcResponse` (`Navigation.cs:829-848`) matches on text content and sender name only, with no color/channel check, plus an extra `Sender.Equals(npcName)` branch not present in ground truth. | **Low** — small false-positive risk (any channel's text matching the phrase would complete the node), unlikely to matter in practice given the fairly specific phrase match. | +| 8 | **Door frame-count debounce vs. time-based retry.** | Portal2's post-use verification waits `PluginCore.@do >= startFrame + 2` — at least two *rendered frames*, not a duration — before checking arrival (`e9.cs:126-139`). | acdream's equivalents are all elapsed-seconds based (`UseRetrySeconds`, etc., `Navigation.cs:181` and throughout). | **Low** — a frame-based debounce doesn't map cleanly onto acdream's tick model in the first place; noted for completeness, not actionable. | + +Correctly and precisely ported (confirmed, not a gap — listed since they +were non-obvious and worth recording as verified rather than re-litigated): +the exact 3 m/45°/15° steering tolerance tiers (§3.1 point 4); the +Checkpoint 15 s stuck-nudge threshold (`CheckpointRetrySeconds = 15d` +exactly matches `gr.cs`'s 15-second gate, using an analogous +live-vs-server-confirmed dual-position-source design); the Portal2/UseNPC +2.5 m reacquire radius (exactly `0.0104166 × 240`); the Portal-exit "too +close to origin" 15 m threshold (exactly `0.0625 × 240`); the door +identify/open ranges and lockpick excess-threshold *default numbers* +(20 m / 4 m / `-50`, byte-for-byte matches of `DoorIDRange`/`DoorOpenRange`/ +`DoorLockpickDiffExcessThreshold` in `uTank2.Resources.defaultsettings.usd`); +and the Circular/Linear/Once index-advance semantics of §2.1. + +## 7. Could not determine + +- **`f9.j()`'s exact predicate.** Used by `fd` to switch from held-key + strafing to FaceHeading-snap-turn + creep (§3.1 point 3). Wraps + `ad.a(PluginCore.dz.az)`; the naming and the fact that `bi.cs` separately + and directly checks `Actions.ChatState` for its own, unrelated jump-abort + logic makes "chat box has input focus" a strong hypothesis (holding + movement keys while a chat box is focused would type into it instead of + moving), but the `ad` class itself was not read to confirm. +- **`item.c() == 0` filter in `e9.g()`/`fa.g()`** (Portal2/UseNPC candidate + matching, `e9.cs:77-78`, `fa.cs:72-73`). Plausibly a "not dead"/"is + visible" flag on the world-object wrapper `fu`, not confirmed. +- **Exact default for `ApproachDistance`.** The `defaultsettings.usd` text + format's field grouping (`d`/value/`s`/description/`i`/category) was + reverse-engineered from context around `NavCloseStopRange` et al., but the + apparent default read for `ApproachDistance` (`0`) would make the + combat-approach `g8` instance's "in band" test degenerate (`fd.f()` would + treat almost any positive distance as "outside band"), which doesn't match + the feature clearly being functional in practice. Either the pairing was + misread for this one key, or `ApproachDistance` is normally set by the + user/character-file rather than left at its shipped default. Not resolved. +- **`PluginCore.PC.m()`** — fired when an `Once` route empties itself + (`ca.cs:340`). Presumably a "route complete"/UI-refresh notification; not + traced to its definition. +- **`ba` (`cLogic.cs:575`)** — a rule registered at the very end of the + main list, after `POSTNAVROUTE`, before the closing `END` sentinel and the + standalone `IdlePeace`. Not opened in this pass; low priority since it + sits after every rule this doc concerns itself with. +- **Exact left/right assignment of the `br.ah`/`br.aj` strafe-key pair** + (`fd.cs:350-359`) — confirmed *that* one of the pair is held based on + `f9.c()`'s sign, not confirmed *which enum member maps to which physical + strafe direction*, since `br` (`br.cs`) is a plain sequential int enum + with no name-to-action lookup table in the files read for this pass.