docs(vt): VT1 catalog 06 — VTank navigation engine and the .nav format

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 19:46:04 +02:00
parent 289c6706ba
commit 1a35b83cf8

View file

@ -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 `^(?<mspart>[0-9]+\.[0-9]{4})(?<mvpart>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.