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>
44 KiB
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 aStreamWriter(x.cs:189, no explicitEncodingpassed) with\r\nline endings on Windows. No escaping of embedded newlines. -
Doubles are written with
Convert.ToString(double, InvariantCulture)(round-trip "R"-ish default formatting) and parsed withConvert.ToDouble(string, InvariantCulture)— plain decimal, no scientific notation guard (though .NET will happily parseE-notation on read, and a small elevation value like2.70833571751912E-05was 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 by240.0.uTank2/sCoord.cs:42— converts a cell's height field withhf2.k() / 240f.dz.cs:239(cited in the prior trace note) — dividesMyRangeMetersby240.0to compare against an sCoord distance.ch.cs:807,848— the user-facing expression functionscoordinatedistancewithz/coordinatedistanceflatliterally multiply the internald-distance by240.0to 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:
NavCloseStopRangedefault0.00833333333333333= 1/120 = 2 m;AttackDistancedefault0.0208333333333333= 1/48 = 5 m;UsePortalDistancedefault0.0166666666666667= 1/60 = 4 m; the Portal2/UseNPC candidate-match radius constant0.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:
glrecords a breadcrumb trail of the target's positions (deduping points closer than0.0004sCoord ≈ 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 within0.01sCoord (≈2.4 m) of the segment and within1.0sCoord (=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:
- 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°). - 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). - Else if
f9.j()(wrapsad.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 absoluteActions.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). - 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'sSteer()reproduces this exact 3 m / 45° / 15° structure. - 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 viaga.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." ifIdlePeaceModeis 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):
- Turn-to-heading (if start heading differs by ≥
0.01°from the target,bi.cs:78— a far tighter constant thanfd's general 4° tolerance; the first draft's "3°" was wrong): repeatedly callsActions.FaceHeading()every 2000 ms until aligned (bi.cs:74-93). - 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 ifActions.ChatStatebecomes true (bi.cs:162-166). - Release: releases held keys in order (jump, strafe/forward, shift)
with the state machine tracking which keys are actually down
(
bi.j.a/b/cflags,bi.cs:167-218). - Wait for landing: polls up to a 15 s timeout comparing the
captured pre-release position (
d-record) against the live position with a1/120sCoord (2 m) settle threshold (bi.cs:358-362; the 15 s timeout itself is armed atbi.cs:344);PluginCore.dz.aw.d()(thebi.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 wrapsad.a(PluginHost)(ad.cs:153-164), true when a VirindiViewServiceHudTextBoxholds focus (ad.cs:166-173) orActions.ChatStateis 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 property218103810(PropertyInstanceId.Container) else218103851(Wielder) else 0, dispatched ascase 2:/case 3:atg6.cs:1486-1530. Soitem.c() == 0means 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. Thedefaultsettings.usdtext format's field grouping (d/value/s/description/i/category) was reverse-engineered from context aroundNavCloseStopRangeet al., but the apparent default read forApproachDistance(0) would make the combat-approachg8instance'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, orApproachDistanceis normally set by the user/character-file rather than left at its shipped default. Not resolved. PluginCore.PC.m()— fired when anOnceroute 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, afterPOSTNAVROUTE, before the closingENDsentinel and the standaloneIdlePeace. 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.ajstrafe-key pair (fd.cs:350-359) — confirmed that one of the pair is held based onf9.c()'s sign, not confirmed which enum member maps to which physical strafe direction, sincebr(br.cs) is a plain sequential int enum with no name-to-action lookup table in the files read for this pass.