docs(vt): fold the citation-pass corrections into KB 06 (0.01° turn threshold, OpenVendor busy paths, cm sites vs instantiations, resolved f9.j and item.c meanings, path/count nits)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 20:19:19 +02:00
parent d8f85848b6
commit caf7307522

View file

@ -5,7 +5,7 @@ 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.
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
@ -69,7 +69,7 @@ Every waypoint, regardless of type, starts with a **fixed 5-line header**
| 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. |
| 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
@ -120,12 +120,12 @@ for 3/4/5, since `e9`/`fa` also extend `at`.
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
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`.
- `sCoord.cs:42` — converts a cell's height field with `hf2.k() / 240f`.
- `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
@ -213,7 +213,7 @@ past it.
|---|---|---|---|---|
| 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` |
| 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
@ -244,10 +244,10 @@ Circular/Linear lap — re-arms it, `at.cs:69-73`).
| **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` |
| **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:94-196` |
| **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`)
@ -262,7 +262,7 @@ distinct behavior modes gated by the `FollowAroundCorners` setting:
(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
(≈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
@ -282,7 +282,7 @@ reused by three different `g8` instances (§5): the main nav route
Every tick:
1. Compute `num` = target's own reported distance (`bz.f()`), `num2` =
bearing to target (`f9.b(sCoord,sCoord)`), `value` = signed-magnitude
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
@ -314,7 +314,8 @@ Every tick:
`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):
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
@ -357,8 +358,9 @@ ChatCommand + a third-party utility, rather than relying on anything native.
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):
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,
@ -373,7 +375,8 @@ bi.d direction)` (`bi.cs:496-516`) drives a small state machine
(`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`);
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.
@ -472,7 +475,8 @@ 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`
`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).
@ -528,7 +532,7 @@ Ranked by impact (highest first):
| 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. |
| 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. |
@ -547,16 +551,16 @@ 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.
- **`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