acdream/docs/research/vtank-kb/06-navigation-and-nav.md
Erik f680bf234a merge(vt): slice 1 Part A — VTank .usd/.utl drop-in and metaf .af for metas and routes (review-closed)
Campaign VT slice 1 Part A: the .usd document model + 137-setting
serializer with declared type tags and exact compare, metaf .af reader/
writer for metas and nav routes with real byte identity against the
owner's fixtures, .utl gate fixes, the VtankProfiles host storage
(ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores
to real VTank files with one-time JSON migration. Two Opus lenses, three
fix rounds, two narrow re-reviews, final re-check: MERGE-READY.
Contract-doc ledger conflict resolved by keeping the campaign branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 02:25:28 +02:00

44 KiB
Raw Blame History

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, 526 .nav files, among 1,594 files of all types in that directory) 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 (uTank2/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

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 (meters per sCoord unit) is confirmed independently at four 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.
    • uTank2/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 (ca.cs:335); PluginCore.PC.m() (ca.cs:340) fires on any consumption, not only on exhaustion 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, if the object resolves and is ObjectClass.Vendor, returns false (done) after issuing one UseItem gated at ≥2 s since the last attempt; a missing object (v.cs:87-92) or a non-vendor (v.cs:93-97) instead warns and returns true, leaving the node busy. 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:84-131
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 that breadcrumb point 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 = unsigned-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 , 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 1.0/160.0 (≈1.5 m), tested by the predicate method fd.a(double) (fd.cs:103-110):

  • 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 by ≥0.01° from the target, bi.cs:78 — a far tighter constant than fd's general 4° tolerance; the first draft's "3°" was wrong): 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; the 15 s timeout itself is armed at bi.cs:344); 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 at six sites (eight new cm(...) instantiations: cLogic.cs:530,531,532,540,548,560,570,577): 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, and the correct per-type payload for all ten waypoint types. Portal2/UseNPC now keep both coordinate triples on RouteWaypoint — the (meaningless, per §1.2) outer header coordinate in Position and the embedded d-record's own real target coordinate in ReferencePosition (VtankNavRouteSerializer.cs:127-145; Campaign VT slice-1 Part A fix round — a prior port overwrote Position with the d-record instead of keeping both, which made a .af round trip of the same waypoint lossy). Navigation.TickUse searches for the live world object using ReferencePosition, matching retail's "real target coordinate" role for that field. 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 — i.e. the object is neither contained nor equipped (fu.c() returns its Container or Wielder instance id, fu.cs:66-87) 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.
9 .af (metaf) cannot represent a strafe jump's direction at all. The binary .nav format's Jump record ends with one combined line encoding both charge-ms and a direction digit ∈ {3=Forward,4=StrafeLeft,5=StrafeRight} (§1.2 row 9, di.cs:116-172). metaf's NJump class (metaf_monolithic.py:11708-11821, both ImportFromMetAF/ExportToMetAF) has no direction field whatsoever — only x/y/z, heading, holdShift, and delay-ms. RouteWaypoint.JumpDirection survives a .nav⇄model round trip exactly, but a route saved to .af and reloaded always comes back Forward regardless of what it held before the save, because the .af text itself never carried the value. Medium — a real, unavoidable format limitation (not a porting gap): confirmed by reading metaf's own class end to end, not inferred. MetafSerializer's .af writer does not claim otherwise and the reader does not force-assign Forward (it leaves the model's own default), but the value is still lost across a save-as-.af/reload cycle for StrafeLeft/StrafeRight waypoints.

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() — RESOLVED (citation pass 2026-09-06): it wraps ad.a(PluginHost) (ad.cs:153-164), true when a VirindiViewService HudTextBox holds focus (ad.cs:166-173) or Actions.ChatState is set — "a text-input field has keyboard focus". The chat-focus guess was right but too narrow.
  • item.c() == 0 — RESOLVED (citation pass 2026-09-06): fu.c() (fu.cs:66-87) returns property 218103810 (PropertyInstanceId.Container) else 218103851 (Wielder) else 0, dispatched as case 2:/case 3: at g6.cs:1486-1530. So item.c() == 0 means the object is free-standing in the world — neither in a container nor equipped — exactly the filter that keeps Portal2/UseNPC from matching inventory items.
  • 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.