docs(vt): VT1 catalog 02 scheduler/actions and 03 combat from the VTank decompile

02: the single-winner priority list (24 rule classes, 45 instantiations),
the 293 ms heartbeat + event poke, the force-combat-mode gate and its
stuck-state recovery, IdlePeace in full, MossTank gap. Two draft errors
corrected by the lead against the source: GoToPeaceModeToUseKits exists
(a5.cs:121, defaultsettings.usd:931) and the fallback-wand list is
Items-page insertion order (eq.cs:83-94, PluginCore.cs:8422-8434).
03: target acquisition/selection, monster rules, weapon/damage/ammo, attack
execution, debuffs, pets, MossTank gap. Spot-checked: the hardcoded
debuff-kind order (hi.cs:123-168) and quality-before-UseArcs (hi.cs:509-535).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 19:29:22 +02:00
parent 68abeffb37
commit b4cb516fac
2 changed files with 1436 additions and 0 deletions

View file

@ -0,0 +1,857 @@
# VTank KB 02 — Scheduler and Actions
Oracle: `refs/vtank/decompiled/` (obfuscated VTank 2.x decompile via ILSpy;
identifiers are single/double-letter, strings and method *shapes* are
intact). All citations are `file:line` against that tree. No decompiled
source is pasted verbatim below — every claim is paraphrased and cited so
it can be checked against the file directly.
Comparison target: `src/AcDream.Plugins.MossTank/` at the tree checked out
in worktree `eloquent-hugle-42119e` (commit `c406942ef` and prior).
---
## 0. Class-name key (obfuscated → role)
Established once here; used by name for the rest of the doc.
| Obfuscated | Role | Notes |
|---|---|---|
| `PluginCore.dz` (type `s`, `s.cs`) | The "god object" — one field per subsystem manager | `s.cs:9-90` lists ~50 fields `a`..`ay` |
| `dz.d` | `cLogic` — the scheduler itself | field `d`, `s.cs:14` |
| `dz.o` | `ga` — combat-mode/wield/equip manager, action-lock table, log sink | field `o`, `s.cs:34`; `ga.cs` |
| `dz.p` | `dz` (yes, class `dz.cs` reused as a field type) — target lock / attack-plan selector | field `p`, `s.cs:40`; `dz.cs` |
| `dz.q` | `g6` — WorldFilter/object-classification cache | field `q`, `s.cs:42` |
| `dz.k` | `eq` — a tracked-item-id list (populated elsewhere; consumed by wand selection) | field `k`, `s.cs:22` |
| `dz.m` | `da` — profile data (buff list, craft list, nav route) | field `m`, `s.cs:24` |
| `dz.r` | `fo` — corpse/loot cursor | field `r`, `s.cs:44` |
| `dz.s` | `hv` — loot-item picker | field `s`, `s.cs:46` |
| `dz.l` | `ai` — helper-buff/recharge-other spell cache | field `l`, `s.cs:26` |
| `dz.al` | `cRechargeManager` — self mana/stam/health-stone recharge executor | field `al`, `s.cs:64` |
| `dz.ak` | `bf` — fellowship auto-follow manager | field `ak`, `s.cs:66` |
| `dz.an` (via `h1`) | `fp` | pet-count / summon helper |
| `dz.v` | `f0` — waiting-lock table keyed by object (corpse/door) | field `v`, `s.cs:52` |
| `dz.n` | `hr` — key-press (forward/turn) driver | field `n`, `s.cs:30` |
`o.n` (an `ActionLockType`-keyed timer table inside `ga`) is the shared
per-action cooldown/mutex mechanism — almost every rule's `ValidNow` checks
`o.n.b(ActionLockType.X)` (locked → not valid) and every rule's `Running`
body that fires a request calls `o.n.a(ActionLockType.X, TimeSpan...)` to
arm a cooldown. This is VTank's equivalent of MossTank's per-controller
`canAct`/cooldown fields.
---
## 1. The macro loop
### 1.1 What drives a tick
The scheduler `cLogic` (`uTank2/cLogic.cs`) is **not** driven by a fixed
per-frame `Tick()` call from the host. It is driven by two independent
`System.Windows.Forms.Timer` wrappers (class `ey`, `ey.cs`), whose common
tick source is Decal's `RenderPreUI` hook incrementing a static frame
counter (`ey.c()`/`ey.b()`, `ey.cs:70-79`); each `ey` instance also
de-duplicates so its own handler fires **at most once per distinct render
frame** (`ey.cs:172-177`).
| Timer | Interval | Armed | Handler | Purpose |
|---|---|---|---|---|
| `f` | 293 ms, re-armed every fire | Only while macro running (`StartMacro`, `cLogic.cs:327-328`) | `b()``f.a(293); TryPokeMacro();` (`cLogic.cs:141-152`) | Heartbeat: re-evaluate rules |
| `g` | 3203 ms, fixed | Always (constructed once, `cLogic.cs:61-63`) | `a(object,EventArgs)` → if macro **not** running, evaluate the `m` list (`cLogic.cs:175-181`) | Background pass while macro is stopped |
`TryPokeMacro()` (`cLogic.cs:163-173`) only actually re-runs the rule pass
if the macro is running (`dz.o.c`), a start is not already in progress
(`!k`), and the frame-counter has advanced since the last run (`j !=
ey.b()`) — i.e. it coalesces multiple timer/event firings within the same
render frame into one pass.
`SchedulePoke()` (`cLogic.cs:154-161`) is the fast-wake path: two Decal
game-state trackers, `gj` (spell-cast completion, `gj.cs`) and `gs`
(attack/missile completion, `gs.cs`), each fire a `b`-enum "completed/idle"
event that `cLogic` subscribes to (`cLogic.cs:52-59, 125-139`); on that
event `SchedulePoke()` stamps the dedup counter to the *current* frame and
re-arms `f` for **1 ms**, forcing a near-immediate re-poke on the very
next frame rather than waiting up to 293 ms. In effect: state changes
(a cast finishes, an attack resolves) wake the scheduler immediately; the
293 ms timer is the fallback heartbeat for everything else (vitals
ticking down, a target coming into range, a corpse timing out).
### 1.2 The rule pass itself
`a(MyList<ILogicRule> A_0, MyList<ILogicRule> A_1)` (`cLogic.cs:183-279`)
is the actual pass, called as `a(l, n)` from `TryPokeMacro` and as
`a(m, null)` from the 3203 ms background timer. Per pass:
1. Clear two per-tick flags (`dz.o.s`, `dz.o.t` — a "did-nav-already-decide-
not-to-move" flag and the corpse-wait latch) — `cLogic.cs:289-293`.
2. If Meta is enabled and the macro isn't stopped (`!dz.o.f()`), evaluate
the Meta FSM (`dz.at.h()`, `a7.e()`) — `cLogic.cs:188-196`. (Meta is a
separate subsystem from the action list below; not covered by this
doc.)
3. If `A_1` (the **independent** list, normally `n` = `{ h1 }`, see §2.1)
is non-null, set `Running = ValidNow` on **every** item in it,
unconditionally — `cLogic.cs:198-213`. These do **not** compete for the
single winner slot below; they run in parallel with whatever the main
loop picks.
4. If the macro is stopped, return (the log line, the priority-list scan,
and the "clear rules"/"activate rule" steps below are all skipped) —
`cLogic.cs:214-217`.
5. Walk `A_0` (the main list `l`) top to bottom in **insertion order**
(the `int Priority` on each rule is a monotonically increasing debug id,
never sorted on) and take the **first** rule whose `ValidNow` is true —
`cLogic.cs:218-239`. This is a strict "exactly one winner" scan: no
further items are even evaluated once one is picked (`goto IL_0323`).
6. Every rule in `A_0` that is not the winner gets `Running = false`; the
winner gets `Running = true``cLogic.cs:243-257`. Setting `Running`
is how a rule actually *does* its work (see §2's `Running` column);
`ValidNow` is pure precondition-checking with no side effects other than
caching the choice it made for `Running` to re-use (most classes stash
the choice — a target id, an item id, a spell — in a private field
during `ValidNow` and consume it in `Running`).
7. Every 30 s (wall clock, guarded by a `DateTimeOffset` stamp), force a
GC — `cLogic.cs:268-277`. Not scheduler logic, but explains an
occasional frame hitch on a live VTank session.
### 1.3 Run macro / Stop macro / death
`StartMacro()` (`cLogic.cs:295-338`):
- Refuses if the extension failed to initialize (`PluginCore.ds` false),
or the character isn't fully logged in (`PluginCore.dn == 0` or
`!dz.a0.dt`) — posts "Please wait until you are fully logged in." and
(in the second case) immediately calls `StopMacro()` on itself.
- Sets `dz.o.c = true` (the single "is the macro running" flag almost
every `ValidNow`/`Running` body reads), clears the loot-wait table
(`dz.o.j`), resets the equip-retry counter (`dz.o.r = 0`), clears every
`ActionLockType` cooldown (`dz.o.n.b()`), resets Meta, arms the 293 ms
timer and starts it, disables every item in the macro-disabled Always
Rules list `m` (they're only meaningful while stopped — see §1.1), and
does one immediate `TryPokeMacro()`.
`StopMacro()` (`cLogic.cs:340-365`), wrapped in a bare `try/catch` that
**silently swallows any exception** — a stop can partially fail with no
visible error:
- Sets every rule in `l` to `Running = false`, clears `dz.o.c`, stops the
293 ms timer, unchecks the UI "Run Macro" box, and if a spread-lock
target list was active, clears it and re-sends an empty target list to
the host (`PluginCore.PC.a(new MyList<int>(100))`).
- Does **not** reset the equip-retry counter or Meta state itself (those
are handled by `StartMacro` on the next run) and does **not** stop or
reset navigation, crafting, dispel, or loot — the rule instances are
simply never evaluated again until `StartMacro` re-enables `dz.o.c`.
Death: `StopMacroOnDeath` is read elsewhere in `PluginCore.cs` (not in
`cLogic`) — `PluginCore.cs:4155` gates a `StopMacro()` call on
`dz.o.c && f3.k("StopMacroOnDeath")`. This doc's oracle is the scheduler;
the death-detection trigger itself lives in the main plugin class, not
`cLogic`.
### 1.4 Pause conditions
There is no single "pause" flag distinct from "stopped." The closest
equivalent is the per-`ActionLockType` cooldown table (`dz.o.n`), which
individual rules use to make themselves temporarily invalid (busy-state,
navigation lock, item-use lock, door-opening lock, salvage lock, spread-
lock-target-requested, corpse-open-attempt, recharge-level-boost per
vital, random-helper-buff-lock, buff-cast-recast) — see the "Suppressors"
column in §2. `ga.a(CombatState,...)` (the shared wield/mode gate, §3)
also returns `false` outright whenever `Actions.BusyState != 0`
(`ga.cs:1454-1458`), which starves every rule that routes through it
(BuffSelf, DispelSelf, DispelAllies, RechargeOther, UseHealersHeart,
RandomHelper, Attack's own wield step) for that tick without any explicit
"paused" bookkeeping.
---
## 2. The complete action list
### 2.1 Two tracks
VTank schedules rules on two separate tracks:
- **Main track (`l`)** — the priority list below; exactly one rule's
`Running` fires per tick (§1.2 step 5-6).
- **Independent track (`n`)** — evaluated every tick *in addition to*
whichever main-track rule wins, with no competition at all
(`cLogic.cs:198-213`). By default `n` holds exactly one rule:
**SummonPet** (`h1`, `h1.cs`) — valid when combat and pet-summoning are
both enabled, the Summoning skill is trained, the class is not on its
own internal 32555ms cooldown gate (`an.a(-32555)`, `h1.cs:44-47`), a
summonable pet item is present (`dz.o.j()`), and the pet-count check
`bm.a()` passes; `Running(true)` uses the found item
(`f9.p(this.m_a)`). SummonPet requires **no target at all** — it can
fire purely because combat is enabled, independent of whatever the main
track is doing that tick.
- There is also a **macro-disabled Always Rules list (`m`)**, evaluated
only by the slow 3203 ms timer while the macro is **stopped**
(`cLogic.cs:175-181`, §1.1). By default it holds one gated rule:
`RefillWieldedMana` (`a0`, §2.3) behind the `ManaChargesWhenOff` setting
— VTank keeps a worn mana stone charging on its own cadence even with
the macro off.
### 2.2 Main-track priority order
All 39 entries of `l`, in the exact order `InitializeDefaultLogicRules`
(`cLogic.cs:433-578`) adds them. Sentinel rows are markers only (their
`ValidNow` is hard-coded `false`, `LogicRuleSentinel.cs:11`); they never
win, but their names are the section headers VTank's own debug UI/log
uses and are reproduced here for that reason. "Gate" is the
`ISettingDelegate[]` requirement wrapper (`LogicRulePreChain`) that must
*all* be true before the wrapped rule's own `ValidNow` is even consulted
(`LogicRulePreChain.cs:18-32`); "Fallback" is a `cm(0)` (IdlePeace) or
similar rule wired as the `LogicRulePreChain`'s own sub-action, which
`Running(true)` on the wrapper will start automatically if the wrapped
rule/its own reqs fail but the fallback's reqs pass
(`LogicRulePreChain.cs:34-75` — a wrapper's `Running=true` setter tries
each fallback in order first, then the primary).
| # | Rule (log name) | Class | Gate | Fallback | Cite |
|---|---|---|---|---|---|
| 1 | Sentinel START | — | — | — | `cLogic.cs:459` |
| 2 | SplitPeas (SpellCompMin-Critical) | `as` | — | — | `cLogic.cs:460` |
| 3 | CraftFood (kit/food counts, non-idle) | `a9` | — | — | `cLogic.cs:461-469` |
| 4 | RechargeSelf2 (Recharge-Norm-*) | `cr` | — | — | `cLogic.cs:470` |
| 5 | RefillWieldedMana | `a0` | — | — | `cLogic.cs:471` |
| 6 | BuffSelf (RebuffTimeRemainingSeconds) | `fz` | — | — | `cLogic.cs:472` |
| 7 | SplitPeas (SpellCompMin-Normal) | `as` | — | — | `cLogic.cs:473` |
| 8 | Sentinel POSTBUFF | — | — | — | `cLogic.cs:474` |
| 9 | DispelSelf | `c8` | — | — | `cLogic.cs:475` |
| 10 | UseDispelItem | `cx` | — | — | `cLogic.cs:476` |
| 11 | UseHealersHeart | `fb` | — | — | `cLogic.cs:477` |
| 12 | RechargeOther | `gu` | — | — | `cLogic.cs:478` |
| 13 | DispelAllies | `af` | — | — | `cLogic.cs:479` |
| 14 | CraftFood (all recipes/spells, count=1) | `a9` | — | — | `cLogic.cs:480` |
| 15 | RefillPetCharges (PetRefillCount-Normal) | `dq` | — | — | `cLogic.cs:481` |
| 16 | Sentinel POSTHELPER | — | — | — | `cLogic.cs:482` |
| 17 | FellowshipManager | `g5` | — | — | `cLogic.cs:483` |
| 18 | Sentinel POSTAUTOFELLOW | — | — | — | `cLogic.cs:484` |
| 19 | OpenDoor | `b7` | — | — | `cLogic.cs:485` |
| 20 | Sentinel PREPRIORITYLOOTACTIONS | — | — | — | `cLogic.cs:486` |
| 21 | ReadScroll (priority) | `er` | EnableLooting, LootPriorityBoost | — | `cLogic.cs:487` |
| 22 | StackCram (priority) | `aj` | same | — | `cLogic.cs:488` |
| 23 | SalvageItems (priority) | `ar` | same | — | `cLogic.cs:489` |
| 24 | Sentinel POSTPRIORITYLOOTACTIONS | — | — | — | `cLogic.cs:490` |
| 25 | Sentinel PREPRIORITYLOOT | — | — | — | `cLogic.cs:491` |
| 26 | Navigate → corpse (priority) | `g8`+`fg` | EnableLooting, LootPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:492-497` |
| 27 | OpenCorpse (priority) | `bj` | LootPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:498-502` |
| 28 | LootCorpse (priority) | `d0` | EnableLooting, LootPriorityBoost | — | `cLogic.cs:503` |
| 29 | CorpseWait (priority) | `a1` | same | — | `cLogic.cs:504` |
| 30 | Sentinel POSTPRIORITYLOOT | — | — | — | `cLogic.cs:505` |
| 31 | Sentinel PREPRIORITYNAV | — | — | — | `cLogic.cs:506` |
| 32 | Navigate → nav route (priority) | `g8`+`ca` | NavPriorityBoost, SetWaitingOnCorpseId | — | `cLogic.cs:507-512` |
| 33 | Sentinel POSTPRIORITYNAV | — | — | — | `cLogic.cs:513` |
| 34 | Sentinel PREATTACK | — | — | — | `cLogic.cs:514` |
| 35 | **Attack** | `b4` | — | — | `cLogic.cs:515` |
| 36 | Sentinel POSTATTACK | — | — | — | `cLogic.cs:516` |
| 37 | Sentinel PREIDLESTATUS | — | — | — | `cLogic.cs:517` |
| 38 | SplitPeas (SpellCompMin-Idle) | `as` | — | — | `cLogic.cs:518` |
| 39 | CraftFood (idle counts) | `a9` | — | — | `cLogic.cs:519-527` |
| 40 | RefillPetCharges (PetRefillCount-Idle) | `dq` | — | — | `cLogic.cs:528` |
| 41 | Sentinel PREIDLELOOTACTIONS | — | — | — | `cLogic.cs:529` |
| 42 | ReadScroll (idle) | `er` | (none) | **IdlePeace** | `cLogic.cs:530` |
| 43 | StackCram (idle) | `aj` | (none) | **IdlePeace** | `cLogic.cs:531` |
| 44 | SalvageItems (idle) | `ar` | (none) | **IdlePeace** | `cLogic.cs:532` |
| 45 | Sentinel POSTIDLELOOTACTIONS | — | — | — | `cLogic.cs:533` |
| 46 | Sentinel PREIDLELOOT | — | — | — | `cLogic.cs:534` |
| 47 | Navigate → corpse (idle) | `g8`+`fg` | EnableLooting, SetWaitingOnCorpseId | **IdlePeace** (gated: not yet close) | `cLogic.cs:535-543` |
| 48 | OpenCorpse (idle) | `bj` | EnableLooting, SetWaitingOnCorpseId | **IdlePeace** | `cLogic.cs:544-548` |
| 49 | LootCorpse (idle) | `d0` | (none — own `EnableLooting` check) | — | `cLogic.cs:549` |
| 50 | CorpseWait (idle) | `a1` | (none) | — | `cLogic.cs:550` |
| 51 | Sentinel POSTIDLELOOT | — | — | — | `cLogic.cs:551` |
| 52 | Sentinel PREIDLEBUFF | — | — | — | `cLogic.cs:552` |
| 53 | BuffSelf (IdleBuffTopoffTimeSeconds) | `fz` | IdleBuffTopoff | — | `cLogic.cs:553-556` |
| 54 | Sentinel POSTIDLEBUFF | — | — | — | `cLogic.cs:557` |
| 55 | Sentinel PRETARGETAPPROACH | — | — | — | `cLogic.cs:558` |
| 56 | Navigate → nearest monster | `g8`+`eb` | EnableCombat | **IdlePeace** (gated: not yet close) | `cLogic.cs:559-563` |
| 57 | Sentinel POSTTARGETAPPROACH | — | — | — | `cLogic.cs:564` |
| 58 | Sentinel PREIDLERECHARGE | — | — | — | `cLogic.cs:565` |
| 59 | RechargeSelf2 (Recharge-NoTarg-*) | `cr` | — | — | `cLogic.cs:566` |
| 60 | Sentinel POSTIDLERECHARGE | — | — | — | `cLogic.cs:567` |
| 61 | Sentinel PRENAVROUTE | — | — | — | `cLogic.cs:568` |
| 62 | Navigate → nav route (idle) | `g8`+`ca` | (none) | **IdlePeace** (gated: not close+not recalling) | `cLogic.cs:569-573` |
| 63 | Sentinel POSTNAVROUTE | — | — | — | `cLogic.cs:574` |
| 64 | RandomHelper | `ba` | — | — | `cLogic.cs:575` |
| 65 | Sentinel END | — | — | — | `cLogic.cs:576` |
| 66 | **IdlePeace** (terminal catch-all) | `cm` | — | — | `cLogic.cs:577` |
`IdlePeace` (`cm`) is instantiated **8 times total** — the terminal
catch-all plus 7 fallback children wired directly into other rules'
`LogicRulePreChain` — see §4.
### 2.3 Per-action detail
Each entry: precondition (`ValidNow`), what `Running(true)` does, and
timeouts/retries/suppressors. "Suppressor" = an `ActionLockType` (or
equivalent) whose lock, once armed, makes `ValidNow` false until it
expires.
**SplitPeas — `as` (`as.cs`)**
Precondition: not `ItemUse`-locked, in Peace mode already
(`dz.z.d() == o.b.a`, `as.cs:72`), `SplitPeas` setting on, and a
craftable split found under the given component-minimum setting
(`SpellCompMin-Critical`/`-Normal`/`-Idle`) either for the special
`[All Peas]` group or a specific reagent — `as.cs:66-131`. `Running(true)`:
if not already in Peace mode, request Peace (`f9.a((CombatState)1)`);
else perform the split — `as.cs:153-168`. No explicit timeout; re-attempts
every tick it's the winner. Warns once per scan if no splitting tool is
present (`as.cs:94, :120`).
**CraftFood — `a9` (`a9.cs`)**
Precondition: not `ItemUse`-locked, in Peace mode, `AutoCraftItems` on,
and a recipe/spell in the profile craft list (`dz.m.h`/`dz.m.g`) resolves
to an executable craft — `a9.cs:101-140`. The idle-tier instance passes an
explicit `fs[6]` type filter and `A_2:true`, which switches the per-type
count from a flat `1` to the matching `IdleCraftCount_*` setting
(kits/food × health/stamina/mana) — `a9.cs:82-98`. `Running(true)`: same
Peace-mode-first gate as SplitPeas, then execute the craft —
`a9.cs:160-176`.
**RechargeSelf2 — `cr` (`cr.cs`)**
Precondition: not `ItemUse`-locked; `cg.c/b/a` (external helper, not this
file) report a vital below its Norm-tier or NoTarg-tier threshold setting
`cr.cs:72-92`. On an exception in that check, sets an internal
error-state flag and still reports valid (`cr.cs:86-90`). `Running(true)`:
logs `(RechargeSelf2) Running (errorstate ...)`, then calls
`dz.al.Recharge(...)` for whichever vital tripped — `cr.cs:112-135`. **No
target requirement of its own** — the Norm/NoTarg split is entirely a
function of *where in the list* it's placed (before vs. after the Attack
rule), not a parameter difference beyond which three setting keys it
reads.
**RefillWieldedMana — `a0` (`a0.cs`)**
Precondition: not `ItemUse`-locked; delegates to `dz.ac` (an item-mana
tracker) — `a0.cs:63-77`. `Running(true)`: recharges the wielded item if
`dz.ac.h()`, else (if not `ManaStoneUse`-locked) uses a mana stone via
`dz.ab.e()``a0.cs:97-111`.
**BuffSelf — `fz` (`fz.cs`)**
Precondition: not `ItemUse`-locked, `EnableBuffing` on, and
`dz.k.a(seconds, isIdleTopoff, out shouldForce)` (the buff-plan builder)
reports work — `fz.cs:69-90`. The `RebuffTimeRemainingSeconds` normal-tier
call passes `A_2:true`("idle" flag inverted per-arg — see cite) while the
idle-tier call (`IdleBuffTopoffTimeSeconds`) passes `A_2:false`; both add
`BuffCastRecast_Seconds` on top of the raw threshold if the
`BuffCastRecast` lock is armed — `fz.cs:80-89`. `Running(true)`: routes
through `ga.a((CombatState)8, 0, true)` (the shared wield/mode gate, §3)
and, once ready, arms `BuffCastRecast` and asks the buff-plan builder to
actually cast (`dz.k.a()`) — `fz.cs:113-121`.
**DispelSelf — `c8` (`c8.cs`)**
Precondition: not `ItemUse`-locked, `CastDispelSelf` on, "Eradicate Life
Magic Self" known and its difficulty passes `c4.a(...)``c8.cs:64-85`.
`Running(true)`: through the shared wield/mode gate, cast the spell on
self — `c8.cs:113-121`.
**UseDispelItem — `cx` (`cx.cs`)**
Precondition: `UseDispelItems` on, not `ItemUse`-locked, and — gated by
two difficulty tiers (`c4.a(400)`, `c4.a(350)`) — one of a fixed item list
is in inventory: Rune of Dispel, Society/Black Market Gem of Dispelling
(tier 400), or Rune of Dispel, Chocolate Gromnie, Condensed Dispel
Potion, Gem of Stillness (tier 350) — `cx.cs:65-116`. `Running(true)`:
uses the found item and arms `ItemUse` for `dz.o.k` (a configured
duration) — `cx.cs:139-145`.
**UseHealersHeart — `fb` (`fb.cs`)**
Precondition: `UseHealersHeart` on, not `ItemUse`-locked, "The Healer's
Heart" or "Legendary Seed of Mornings" present and off cooldown
(`dz.aa.a`), Healing/Arcane-Lore-equivalent skills above fixed thresholds
(245/105), and a helper-buff spell resolves — `fb.cs:69-115`.
`Running(true)`: through the wield/mode gate (target = the item's owner,
not self — `A_2:false`), use the item then apply the resolved buff —
`fb.cs:140-146`.
**RechargeOther — `gu` (`gu.cs`)**
Precondition: not `ItemUse`-locked; a helper-recharge request (`gh`, built
from three `Recharge-Helper-*` settings clamped to 100) resolves via
`dz.l.b(...)``gu.cs:71-82`. `Running(true)`: through the wield/mode
gate, execute via `dz.l.a(...)``gu.cs:109-129`.
**DispelAllies — `af` (`af.cs`)**
Precondition: `UseDispelDrum` on, not `ItemUse`-locked, an Awakener or
Attenuated Awakener drum present and off cooldown with matching
skill/training thresholds, and a fellow member is found carrying
qualifying debuffs of quality > 250 and difficulty ≤ 350 within a 5-hour
window — `af.cs:75-158`. `Running(true)`: through the wield/mode gate
(target = the drum's owner), use the drum on the chosen ally —
`af.cs:184-189`.
**CraftFood (default) — `a9` again**, same class as above, constructed
with no type filter (`m_c == null`), which additionally lets it craft
*any* known helper-buff spell in the profile if no recipe matched
(`a9.cs:127-138`) — this instance runs *after* DispelAllies, before
RefillPetCharges (Normal).
**RefillPetCharges — `dq` (`dq.cs`)**
Precondition: not `ItemUse`-locked, `EnableCombat` on, a wielded-pet-
device candidate under the given `PetRefillCount-*` threshold found via
`ga.e(...)` classification, and "Encapsulated Spirit" present —
`dq.cs:38-75`. `Running(true)`: if not in Peace mode, request Peace
first; else select the device, use the spirit item on it, restore
selection — `dq.cs:99-113`.
**FellowshipManager — `g5` (`g5.cs`)**
Precondition/Running: entirely delegated — `ValidNow` is `dz.ak.h()`,
`Running(true)` calls `dz.ak.g()``g5.cs:28-58`. `dz.ak` (`bf.cs`) is
out of scope for this doc (fellowship auto-follow subsystem).
**OpenDoor — `b7` (`b7.cs`)**
Precondition: `OpenDoors` and `EnableNav` on; scans all doors, adds
in-range ones to a navigation wait-set (`dz.v`), defers if navigation is
already pending; then, among doors within `DoorOpenRange`, requires (for
a locked door) `Lockpick` known and skill above the difficulty +
`DoorLockpickDiffExcessThreshold`, and a usable lockpick found via a
by-`ObjectClass.Door`-filtered inventory scan (`a()`, `b7.cs:52-81`) —
`b7.cs:83-172`. `Running(true)`: for a locked door, use the lockpick on it
(`ItemUse`/`Navigation`/`DoorOpening` locks armed for 0.5s/5s/5s); for an
unlocked door, walk up (`f9.p`) then, once adjacent, `ApplyItem` (the
door itself) — `b7.cs:206-226`.
**ReadScroll / StackCram / SalvageItems — `er`, `aj`, `ar`**
`ReadScroll` (`er.cs:63-90`): not `ItemUse`-locked; scans a cached
scroll-item dictionary (`dz.o.i`), drops entries no longer in the
worldfilter classification set (`dz.q.y`), and picks the first remaining
one the loot-item picker (`dz.s`) accepts. `Running(true)` re-uses the
scroll (`f9.p`) and arms `ItemUse` for `dz.o.k``er.cs:118-137`.
`StackCram` (`aj.cs`): delegates entirely to `dz.x` (`el.cs`, out of
scope) — `ValidNow = dz.x.c()`, `Running(true) → dz.x.d()`. `SalvageItems`
(`ar.cs`): `EnableLooting` on; if `Salvage` lock already armed, valid
immediately (continuing a multi-tick salvage); else requires a "Ust"
(salvage tool) present and `dz.u.j()` (has salvageable items) —
`ar.cs:63-81`; `Running(true)` re-uses the Ust and calls `dz.u.i()`
`ar.cs:105-111`.
**Navigate (generic) — `g8` (`g8.cs`) + a goal provider (`bz` interface)**
`g8` is a thin wrapper around `fd` (`fd.cs`), parameterized with two
setting-key names (a close-stop and a far-stop range) and a goal object.
Precondition: not already decided-no-move this tick (`dz.o.s`), `EnableNav`
on, not `Navigation`/`SpreadLockTargetRequested`/`DoorOpening`-locked, and
the wrapped `fd.e()` (goal non-null and not "reached", `fd.cs:274-281`)
`g8.cs:80-104`. `Running`: delegates start/stop to `fd.c(bool)`
(`g8.cs:124-138`); the real per-tick walking logic (face-heading, forward-
press via key-driver `dz.n`, autorun toggle, a periodic ~0.7s repress) is
in `fd.a()` (`fd.cs:311-386`) — described fully in §4 because it is also
where the IdlePeace / nav-min-distance interaction lives. Five goal
providers are wired in: `fg` (`CorpseApproach`, corpse-priority and
corpse-idle tiers), `eb` (`MonsterApproach`, target-approach tier), and
`ca` (nav-route follow, nav-priority and nav-route tiers — its own
`FriendlyName` is literally the string `"?????"`,
`ca.cs:415-417` — a cosmetic bug shipped in retail VTank, not a decompiler
artifact).
**LootCorpse / CorpseWait / OpenCorpse — `d0`, `a1`, `bj`**
`OpenCorpse` (`bj.cs`): `EnableLooting` on; valid immediately if
`ItemUse`-locked (so the pass "continues" while an open is in flight);
else requires the corpse cursor (`dz.r`) to accept the given max-distance
and not already be a "waiting" corpse (`dz.r.e()`) — `bj.cs:63-79`.
`Running(true)`: opens the corpse and arms `ItemUse`/`Navigation`/
`CorpseOpenAttempt` for `CorpseOpenTimeoutSeconds``bj.cs:104-113`.
`LootCorpse` (`d0.cs`): `EnableLooting` on, corpse cursor's item index at
its item count (`m == j` — fully enumerated), corpse marked "waiting"
(`dz.r.e()`), and the loot-item picker (`dz.s`) has a decision
(`dz.s.e()`) — `d0.cs:63-79`. `Running(true)`: executes the loot decision,
arms `ItemUse`/`Navigation` for 0.75s — `d0.cs:105-111`. `CorpseWait`
(`a1.cs`): `EnableLooting` on, not `ItemUse`-locked, corpse cursor
"waiting" (`dz.r.e()`) — `a1.cs:63-71`; `Running(true)`: if the loot
picker's own wait-check (`dz.s.f()`) also agrees, closes the corpse
(`dz.r.b()`) — `a1.cs:95-101`.
**Attack — `b4` (`b4.cs`)**
Precondition: not `ItemUse`-locked, `EnableCombat` on, and the target-lock
class `dz.p.c()` finds an in-range target and picks a spell/attack for it
(§3 describes `dz.p.c()`/`dz.p.a()` at a summary level — the target-
selection and damage-formula internals are out of scope for this
scheduler doc) — `b4.cs:63-76`. `Running(true)`: if neither casting
(`dz.h.e()`) nor busy on an item (`dz.aa.e()`), execute the chosen attack
via `dz.p.a()``b4.cs:99-105`; on `Running(false)` (losing the winner
slot, e.g. to a higher-priority rule), it explicitly tears down door/
salvage cursors (`dz.ar.d()`, `dz.w.c()`) and clears any spread-lock
target list — `b4.cs:108-114`.
**RandomHelper — `ba` (`ba.cs`)**
Precondition: `RandomHelperBuffs` on, not `ItemUse`-locked, not
`RandomHelperBuffLock`-locked, not currently casting (`dz.h.e()`), a
fellow player within **0.075 landblock units (≈18 m)**, and — trying up
to 100 random `(target, spell)` draws from a fixed 11-spell Tier-I list
(Endurance/Regeneration/Rejuvenation/Armor/five elemental protections/
Acid Protection, all literally "... Other I") — an uncast combination
found — `ba.cs:25-40, 82-125`. `Running(true)`: through the wield/mode
gate, cast the chosen spell on the chosen target, arm
`RandomHelperBuffLock` for `RandomHelperIntervalSeconds``ba.cs:146-157`.
Both target and spell are chosen with `System.Random`, not a
deterministic scan — VTank's RandomHelper is genuinely random by design.
**SummonPet — `h1`** — see §2.1 (independent track).
**Dead code — `be` (`be.cs`)**: an unreferenced class named
`"RechargeSelf"` (not `"RechargeSelf2"`), same constructor shape and
near-identical body to `cr`, including its own copy of the stuck-combat-
state handling for level-boost spells. No `new be(` appears anywhere in
the decompiled tree — it is superseded, unreachable code, not a rule any
current VTank build ever runs. Flagged here so it is not mistaken for a
27th live rule.
---
## 3. Combat-mode and equipment sequencing
### 3.1 The shared gate: `ga.a(CombatState, int, bool, eDamageElement,
ePrismaticDamageBehavior, int)` (`ga.cs:1421-1568`)
Every rule that needs to cast a buff/heal/recharge/dispel spell — BuffSelf,
DispelSelf, DispelAllies, RechargeOther, UseHealersHeart, RandomHelper —
calls through this **one** subroutine (via an overload chain,
`ga.cs:1417-1447`) instead of switching mode itself. Per call:
1. **Busy check.** If `Actions.BusyState != 0`, return `false`
immediately — `ga.cs:1454-1458`.
2. **Resolve the primary item to wield.**
- Start from the explicit override `A_1` (an item id), if the caller
passed one, and validate it's owned by the player and currently
wieldable (`b(WorldFilter[num])`); if invalid, fall back to `0` and
log a warning — `ga.cs:1459-1470`.
- **Prefer whatever is already wielded**: if auto-select is on (`A_2`)
or no override was given, *and* something is already wielded whose
weapon-class already maps to the requested `CombatState` (via
`a(ObjectClass)`, `ga.cs:1573-1607`), keep using that item instead of
switching — `ga.cs:1471-1475`.
- Otherwise, call the private `a()` helper (`ga.cs:1398-1411`): scan
`dz.k.j()` (a tracked item-id list — see §0; **its population order
is not established from this file alone**, see §6) for the first
owned item classified `fi.b` ("is a wand"); if none, post **"You
must add at least one wand to your profile."**, `StopMacro()`, return
`false``ga.cs:1477-1481`.
3. **Resolve a secondary/held item** the same way (`A_5`), and whether a
spell/element re-selection is needed (`bv.a(...)`, an
element/prismatic-compatibility check) — `ga.cs:1482-1499`.
4. **If anything needs to change** (wrong item wielded, wrong secondary
held, or a spell re-selection is due):
- Refuse if a shared busy-lock (`dz.w.c()`) fails — `ga.cs:1502-1505`.
- **If not already in Peace mode**, this is where the stuck-state
recovery lives: increment a retry counter `r`; once `r` reaches
`DropToPeaceModeRetryCount`, lock `ItemUse`, re-resolve *any* wand
from the profile, force-equip it, log **"Warning: Macro detected
bugged combat state. Attempting to wield an item to clear it."**,
reset `r`, and return `false``ga.cs:1506-1523`. Otherwise, just
request Peace mode (`f9.a((CombatState)1)`) and return `false`,
incrementing nothing further this tick — `ga.cs:1524-1526`.
- Once in Peace mode (`r` reset to 0, `ga.cs:1527`): if the target item
is a wand type (`fi.h`) *and* a secondary must also change, unequip
the current primary first if one is worn, else equip the secondary
`ga.cs:1528-1547`; else if the primary itself needs to change,
equip it — `ga.cs:1548-1551`; else if only the spell/element
selection needs updating, do that (`bv.a()`) — `ga.cs:1552-1554`.
- Any of the above branches returns `false` for this tick — the wield
step always costs at least one extra tick before it can report ready.
5. **Once everything matches**, recompute the `CombatState` implied by
whatever is now wielded and, if it differs from `Actions.CombatMode`,
request that mode (again gated by the same busy-lock) and return
`false``ga.cs:1556-1564`.
6. **Only when wield + secondary + spell + mode all already match** does
the gate return `true``ga.cs:1565`. Callers only execute the actual
cast/use inside their own `if (ga.a(...)) { ... }` branch — see e.g.
`c8.cs:114-121`, `af.cs:185-189`, `ba.cs:152-156`.
`ga.a(ObjectClass)` (`ga.cs:1573-1607`) is the class→mode map:
`MeleeWeapon → Melee(2)`, class `9` (missile launcher) `→ Missile(4)`,
class `31` and everything else `→ Magic(8)` (the default).
### 3.2 (a) Buffing with a wand — which wand?
**Wielded first.** If auto-select is on (or no explicit item was
requested) and the currently-wielded item is already the right *class*
for Magic mode, VTank keeps using it rather than re-equipping every tick
(`ga.cs:1471-1475`). Only when nothing suitable is wielded does it fall
back to the first `fi.b`-classified item found while scanning `dz.k.j()`
(`ga.cs:1398-1411`). **`dz.k.j()` is Items-page insertion order** (lead
verification 2026-09-06): `eq.j()` (`eq.cs:83-94`) walks `m_e` in list
order and emits each distinct item id (skipping `-1` and self); `m_e` is
appended by `eq.a(c)` (`eq.cs:54-57`, a plain `List.Add`), whose only
producers are the Items-tab Add buttons — `PluginCore.cs:8422-8434`
(`dz.k.a(new eq.c(item, spell))`, one row per element for "Add", a single
`-1`-spell row for "Add (no buffs)") — and the profile loader. So the
first wand a player added is the fallback caster. (`ga.cs:1462-1464` is
the wielded-first branch; the line numbers `1471-1475` above refer to the
same function's error path.)
### 3.2 (b) Fighting with the rule weapon
Handled separately from the buff/heal gate above: `Attack` (`b4`)
delegates target selection **and** weapon/mode resolution together to
`dz.p` (`dz.cs`) — `dz.p.c()` (`ValidNow`, picks a target + attack plan
within `AttackDistance`, `dz.cs:664-667`) and `dz.p.a()` (`Running`,
executes the chosen plan, `dz.cs:201-206`). The internal weapon-choice/
damage-formula logic inside `dz.cs`'s target-sort and `hi` attack-plan
classes is out of scope for this scheduler doc.
### 3.2 (c) Kits/food — `GoToPeaceModeToUseKits`
`GoToPeaceModeToUseKits` **is a real VTank setting** (lead verification
2026-09-06; an earlier draft of this section wrongly reported it absent):
`a5.cs:121` reads `f3.k("GoToPeaceModeToUseKits")` and, when it is on and
the character is not in Peace, the kit-use rule (`a5`) requests Peace at
`a5.cs:123` and does nothing else that tick; the setting is declared in
`refs/vtank/uTank2.Resources.defaultsettings.usd:931` (and repeated at
`:1913`). It is the same class-local "Peace first, then act" pattern that
CraftFood inlines at `a9.cs:168-171` and SplitPeas at `as.cs:160-164`,
not a shared subroutine — eight such open-coded drop-to-peace sites exist
(`cm.cs:101`, `a5.cs:123`, `a9.cs:170`, `as.cs:162`, `dq.cs:106`,
`bv.cs:196`, `dz.cs:514`, `ga.cs:1526`); the only call into the client's
`SetCombatMode` is `f9.cs:378`. Full trace:
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` (local).
### 3.2 (d) IdlePeace
See §4 in full.
### 3.3 Stuck-combat-state recovery
One mechanism, `ga.cs:1506-1523` (§3.1 step 4), reused by every rule that
routes through `ga.a(...)`: after `DropToPeaceModeRetryCount` consecutive
ticks stuck outside Peace mode while trying to wield something, VTank
stops trying the *specific* requested item and instead force-equips
*any* valid wand from the profile, with the exact chat line **"Warning:
Macro detected bugged combat state. Attempting to wield an item to clear
it."** This is the only explicit "timeout" in the wield/mode path — there
is no separate wall-clock timeout, only a per-`ga.a(...)`-call tick
counter (`r`, reset to 0 the moment Peace mode is reached).
---
## 4. IdlePeace (`cm`, `cm.cs`) in full
**Precondition** (`cm.cs:62-75`): `IdlePeaceMode` setting is on, **and**
`Actions.CombatMode != Peace`. That's the entire `ValidNow` — no busy
check, no cast check, no target check of its own.
**Running(true)** (`cm.cs:96-103`): logs `(IdlePeace) Running` and calls
`f9.a((CombatState)1)` directly — **not** through the shared `ga.a(...)`
gate. This means IdlePeace does not go through the wand-preference or
stuck-state-recovery logic in §3.1 at all; it just asks for Peace mode
every tick it wins.
**Position in the list.** IdlePeace is instantiated 8 times
(`cLogic.cs:530-577`): once as the true terminal catch-all (last item in
`l`, after even RandomHelper), and 7 more times as the `LogicRulePreChain`
*fallback* wired onto other rules — ReadScroll/StackCram/SalvageItems
(idle tier), OpenCorpse (idle tier), the corpse-approach Navigate (idle
tier), the target-approach Navigate, and the nav-route Navigate. Because
`LogicRulePreChain.Running=true` tries its fallback children *before* its
primary action (`LogicRulePreChain.cs:53-67`), this means: while
approaching a corpse, a monster, or a nav waypoint, if the *approach
itself* isn't valid this tick but IdlePeace's own gated sub-condition is
(see below), the character drops to Peace mode as part of that specific
rule's turn — long before the terminal catch-all at the very end of the
list is ever reached.
**Interaction with nav minimum distance — the actual mechanism lives
outside `cm.cs`, in `fd.a(bool, double)` (`fd.cs:112-177`)**, which is the
per-tick movement driver for every `g8`-wrapped Navigate rule (§2.3). Read
carefully:
- `fd` computes whether the character is "close enough" to stop pressing
forward (`a(double)` — true when distance < `1.0/160.0` landblock units,
`fd.cs:103-110`, the `nav minimum distance` referenced in the campaign
brief).
- If that "stop" condition is true **and** `Actions.CombatMode == Peace`
(`fd.cs:129`): if `IdlePeaceMode` is on, log **"Warning: Idle peace
selected with low waypoint minimum distance. Will switch to magic
mode."** (`fd.cs:133`), then attempt to force Magic mode via
`dz.o.a((CombatState)8, 0, true)` (the shared gate, §3.1) — and if that
fails, cancel the "stop" decision for this tick, i.e. keep walking
instead (`fd.cs:135-138`).
- In other words: **`fd`, not `cm`, is what refuses to let the character
sit in Peace mode with an extremely tight waypoint-stop distance** — it
actively pushes into Magic mode instead, once per approach, with a
one-line warning. `cm`'s own `ValidNow`/`Running` never reference nav
distance at all; this override only fires while a `g8`/`fd` Navigate
rule is actually driving movement, and IdlePeace being wired as that
same rule's fallback (§ above) is *not* the same code path — the
fallback IdlePeace still just asks for Peace via `f9.a((CombatState)1)`,
unaware of `fd`'s own override, which runs on a completely separate
tick of `fd.a()` inside the wrapping `g8`'s own `Running` handling.
**Cadence.** No dedicated cooldown of its own; it re-requests Peace mode
every tick it wins (bounded only by the scheduler's own 293 ms heartbeat /
event-driven fast-poke, §1.1).
---
## 5. The MossTank gap
Files read: `MossTankPanel.cs` (`OnTick`, `TickAutomaticBuffing`,
`TickRandomHelper`), `CombatController.cs` (`OnTick`, `TickEquipment`,
`TryEquipIfNeeded`), `BuffCasterPreparer.cs`, `MacroIdleModeArbiter.cs`,
`DispelController.cs`, `VitalRecharge.cs`/`VitalPlan.cs`, `Navigation.cs`.
### 5.1 Structural model difference
VTank's `l` is a declarative list of independent `ILogicRule` objects; one
pass picks the first whose `ValidNow` is true and marks it `Running`, and
every other rule is explicitly told `Running = false` (§1.2). MossTank's
`OnTick` (`MossTankPanel.cs:3732-3936`) is an imperative chain of ~16
named controllers, each called unconditionally every tick with a `canAct`
argument that is the logical AND of "not owned by any higher controller
so far." The net *suppression* behavior is similar (a higher controller
owning the tick prevents a lower one from acting), but it is a different
mechanism: VTank rules that lose the pick are actively silenced
(`Running=false`, which several rules use to do cleanup —
e.g. `b4.cs:108-114` tears down cursors on losing the slot); MossTank
controllers that are not granted `canAct` simply never got permission to
begin with; there is no equivalent "you just lost the turn, clean up"
signal threaded through the chain.
### 5.2 IdlePeace / MacroIdleModeArbiter: covered, but not the nav-distance
override
`MacroIdleModeArbiter` (full file read) is a faithful, deliberately
improved single-owner port of the *terminal* IdlePeace idea — its own doc
comment explains it fixed a real gap where `CombatController`'s no-target
branch could never reach idle-peace once combat policy was disabled
(`MacroIdleModeArbiter.cs:5-13`, `CombatController.cs:270-280`). But
**there is no port of `fd.cs:129-138`'s nav-minimum-distance override**:
`grep` across `Navigation.cs`, `MacroIdleModeArbiter.cs`, and
`CombatController.cs` for "IdlePeace"/"low waypoint"/"minimum distance"/
"NavMinDistance" finds nothing outside the arbiter's own setting name.
A MossTank user with `IdlePeaceMode` on and a very tight waypoint stop
distance would, on retail VTank, see a one-time warning and an automatic
push into Magic mode near that waypoint; MossTank has no equivalent — it
would presumably just alternate between Peace (arbiter) and whatever mode
navigation itself wants, with no warning and no forced-Magic override.
### 5.3 RandomHelper: round-robin + best-known-tier vs. random + fixed
Tier-I
VTank's `ba` (`ba.cs:114-125`) makes a **random** `(target, spell)` draw
(`System.Random`, up to 100 tries) from a **hardcoded Tier-I** spell list
(literally "... Other I" for all 11 buffs — it never casts a higher known
tier even if the caster knows one). MossTank's `TickRandomHelper`
(`MossTankPanel.cs:4047-4122`) instead does a **deterministic round-robin
scan** (`_randomHelperCursor`) and picks the caster's **best known tier**
of each buff family (`.OrderByDescending(Quality).ThenByDescending(Tier)`,
line 4095-4097). Both are defensible design choices, but they are
observably different: retail VTank always buffs allies with the weakest
version of Tier-I self-buffs, cycling randomly; MossTank always buffs
with the strongest known tier, cycling in a fixed order. The 18 m range
constant (VTank's hardcoded `0.075` landblock units) is correctly ported
(`< 18d`, line 4071, with an explicit comment tying it to the conversion).
### 5.4 BuffCasterPreparer vs. `ga.a(...)`: same shape, one real
difference
`BuffCasterPreparer` (full file read) is a close, well-documented port of
§3.1's shared gate: prefer the already-wielded caster
(`TryResolveCaster`, `BuffCasterPreparer.cs:158-169`, matching
`ga.cs:1471-1475`), else the first profiled caster by a **deterministic
name-then-object-id order** (`BuffCasterPreparer.cs:171-188`) rather than
VTank's `dz.k.j()` scan order (whose actual ordering semantics are
unestablished, §6 — so this may or may not be a behavioral difference;
it is at minimum a *documented, reproducible* order where VTank's is not).
It reproduces the exact chat line "You must add at least one wand to
your \[Items\] profile." (`BuffCasterPreparer.cs:201`, cf.
`ga.cs:1471, 1481, 1517` verbatim except for the bracketed word), and
`CombatController.TryEquipIfNeeded` (`CombatController.cs:820-847`)
reproduces the stuck-combat-state recovery with the same
`DropToPeaceModeRetryCount` semantics and an equivalent warning message
(`CombatController.cs:841-843`). The one structural difference: VTank's
gate is retried every scheduler tick (bounded only by the 293 ms
heartbeat, §1.1); `BuffCasterPreparer`'s mode-request retry is explicitly
throttled to a fixed 2-second wall-clock cadence
(`BuffCasterPreparer.cs:33, 229-255`) with its own doc comment explaining
why ("a buff pass is not scanning for targets every 0.25 s"). This is a
deliberate, documented pacing change, not an oversight — flagged here
because it means the *retry-count* semantics (`DropToPeaceModeRetryCount`)
are shared, but the *wall-clock time* to exhaust them is not: VTank can
exhaust its retry budget in under a second at a 293 ms cadence, while
`BuffCasterPreparer` takes `DropToPeaceModeRetryCount × 2` seconds.
### 5.5 Two-tier vital recharge: present, but merged differently
VTank re-checks self-vital recharge at two separate priority tiers
(`cr` "Recharge-Norm-*" before Attack, "Recharge-NoTarg-*" after target-
approach fails, §2.2 rows 4 and 59) — two independent settings, gated
purely by *list position* relative to Attack. MossTank's `VitalPlan.
Threshold` (`VitalPlan.cs:79-88`) keeps both `Normal*` and `NoTarget*`
settings but combines them as `Math.Max(Normal, NoTarget)` regardless of
whether a target is present, then only the `noTarget` bool
(`VitalRechargeController.Tick`'s third parameter,
`VitalRecharge.cs:887, 933-939`) selects which pair applies. This
converges on similar behavior in the common case (both thresholds usually
agree in intent — top off more eagerly while idle) but is not a literal
port: VTank's two rules can disagree in *which* vitals they check (a
Norm-tier check only fires while attack is even reachable in the list;
the NoTarg-tier check is a wholly separate, later evaluation with its
own three setting keys) whereas MossTank always evaluates one combined
threshold per vital per tick.
### 5.6 SummonPet's independent scheduling: not reproduced
VTank's `n` list (§2.1) runs SummonPet (`h1`) on every tick regardless of
what the main list is doing, and its own precondition needs no target —
only `EnableCombat` + `SummonPets` + skill + item + its own cooldown
gate. MossTank's pet automation (`PetAutomation.Tick`, called from
`CombatController.OnTick` only after `_targetId != 0`,
`CombatController.cs:270-280, 298-308`) is reachable **only once a
hostile target is already selected**. A player relying on VTank's
"summon before you need it" behavior (summon while wandering, combat
enabled, no target yet) would not see the same behavior in MossTank —
the pet is only summoned once combat has already found something to
fight.
### 5.7 Order differences that are cosmetic, not behavioral gaps
- OpenDoor **is** ported (`Navigation.cs:412+`, `TickDoor`), including the
lockpick-vs-open branch and range/threshold settings — not a gap.
- The stuck-combat-state recovery message and mechanism **are** faithfully
ported (§5.4) — not a gap.
- The overall high-level ordering (critical-craft-ish → vital → buff →
dispel → mana-recharge → crafting/idle-crafting → loot → inventory →
navigation → random-helper → combat, with idle-peace last) tracks
VTank's real priority reasonably closely; the one clear ordering
inversion found is RefillWieldedMana (VTank: before BuffSelf,
`cLogic.cs:471-472`) vs. mana-recharge (MossTank: after
`TickAutomaticBuffing`, `MossTankPanel.cs:3792-3814`) — a player would
notice this only in the edge case where both a buff and a wielded-item
mana-recharge are simultaneously due.
### Five most important differences a player would notice
1. **No nav-minimum-distance → forced-Magic-mode override** (§5.2): VTank
warns and forces Magic mode near a very tight waypoint stop distance
when Idle Peace is on; MossTank has no such override.
2. **RandomHelper is deterministic + best-known-tier, not random +
fixed Tier-I** (§5.3): visibly different buff choices and cadence
pattern on fellow players over a long session.
3. **SummonPet requires a target in MossTank; VTank does not** (§5.6): a
MossTank character will not pre-summon a pet while wandering with no
enemy yet found.
4. **BuffCasterPreparer's mode-retry cadence is a fixed 2 s wall-clock
pace vs. VTank's ~293 ms scheduler-tick pace** (§5.4): the *number* of
retries before the stuck-state recovery fires is the same setting, but
MossTank takes several times longer to reach it.
5. **RefillWieldedMana runs after buffing in MossTank, before it in
VTank** (§5.7): only visible when both are simultaneously due, but is
a genuine, checkable ordering inversion.
---
## 6. Could not determine
- ~~`dz.k.j()` ordering~~ — RESOLVED (lead, 2026-09-06): Items-page
insertion order; producers are `PluginCore.cs:8422-8434` and the profile
loader. See §3.2(a).
- ~~`GoToPeaceModeToUseKits` absent~~ — RESOLVED: it exists at `a5.cs:121`
and `defaultsettings.usd:931`. See §3.2(c).
- **`dz.p` / `hi` (the Attack rule's target-selection and attack-plan
execution internals)** were read only at the level needed to describe
scheduling (`dz.cs:664-667, 201-206`); the damage-formula, spell-choice,
and melee/missile-vs-magic decision logic inside `hi`/`dz.cs`'s target
sort were not traced — out of scope for a scheduler/action-list doc,
likely covered by a combat-math KB doc.
- **`gj`/`gs` (the two Decal state trackers that drive `SchedulePoke`'s
fast-wake path)** were read only enough to confirm what they represent
in shape (spell-cast completion and attack/missile completion,
`gj.cs`/`gs.cs` headers); their own internal state machines were not
traced.
- **Meta FSM** (`dz.at.h()`, `a7.e()`, `cLogic.cs:191-196`) is explicitly
out of scope — it is evaluated once per pass alongside the main rule
list but is its own subsystem (VTank's expression-language meta layer),
not one of the 24 action classes catalogued here.

View file

@ -0,0 +1,579 @@
# VTank knowledge-base 03 — Combat
Research-only. Oracle: `refs/vtank/decompiled/` (obfuscated VTank 2.x
source; class/field names below are the decompiler's raw identifiers —
see the class map). Cross-checked against
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` §2.1
(marked disagreements are called out explicitly; everywhere else the
two sources agree). No code was changed to produce this document.
## Class map (for anyone grepping the decompiled tree later)
VTank's god object is `PluginCore.dz`, an instance of class `s`
(`refs/vtank/decompiled/s.cs:7-114`). Its combat-relevant children:
| Field (`dz.X`) | Type / file | Role |
|---|---|---|
| `p` | `dz` (`dz.cs`) | Target scan + candidate ranking, debuff-spell/item resolution |
| `e` | `d1` (`d1.cs`) | Monster Rules table (`MyMonsters`) + per-creature rule cache |
| `o` | `ga` (`ga.cs`) | Creature-info dict, current/last target id, blacklist list, wield-item info, pet pick, `CombatState` classifier |
| `w` | `bo` (`bo.cs`) | Melee/missile attack executor (swing timing, hit/miss chat parsing) |
| `h` | `gj` (`gj.cs`) | Spell-cast state machine ("SpellCaster": gesture echo → result text) |
| `y` | `e0` (`e0.cs`) | GameInfoDB client: monster/species auto-damage tables, heal-kit/grenade dbs |
| `an` | `fp` (`fp.cs`) | Blacklist-attempt-count manager (temporary skip) |
| `am` | `b8` (`b8.cs`) | Ghost-monster detector (permanent client-side deletion) |
| `f` | `ak` | Spell name → `MySpell` lookup |
| `i` | `fk` | Wand/spell-equivalent + element→spell-name resolution (War/Arc/Ring/Streak/Vuln) |
| `j` | `dm` | Per-target recast-timer tracker |
| `d` | `cLogic` (`cLogic.cs`) | Primary rule scheduler (293 ms tick) |
| `m` | `da` (`da.cs`) | Character profile loader; owns the settings DB (`.c["MyMonsters"]`) |
`f7` (`f7.cs`) is not a `dz` field — it is the transient
"attack candidate" record built fresh for every monster on every scan.
`hi` (`hi.cs`) is the transient "what to do this tick" decision object,
recreated each attack tick inside `dz.b(double)`.
---
## 1. Target acquisition
**Scan cadence.** The primary macro loop (`cLogic`) arms a 293 ms
repeating timer (`m_a = 293`) at construction and starts it from
`StartMacro` (`refs/vtank/decompiled/uTank2/cLogic.cs:11,52-65,327-328`).
Every tick, if not already mid-poke, `TryPokeMacro` walks the ordered
rule list and re-evaluates `ValidNow` on each rule top to bottom,
running the first one whose `ValidNow` is true
(`cLogic.cs:163-173,183-260`). The "Attack" rule (`b4`) is one of
those rules: its `ValidNow` calls `dz.p.c()` — the full target scan —
on **every** 293 ms tick, gated only by `EnableCombat` and the
`ItemUse` action lock (`refs/vtank/decompiled/b4.cs:62-76`). There is
no separate, slower "target scan" timer; scanning and rule evaluation
share the same 293 ms cadence. A second `cLogic` timer at 3203 ms
(`m_b`) exists but only forces a GC pass and is unrelated to combat
(`cLogic.cs:37,61-70,266-277`).
**What counts as a target.** `dz.p.c()` calls `dz.p.b(AttackDistance)`,
which enumerates every object of `ObjectClass.Monster` known to the
world filter (`dz.q.a(ObjectClass.Monster)`) and builds one `f7`
candidate per object via `f7.a(fu, maxDist, minDist, targetLockArg)`
(`refs/vtank/decompiled/dz.cs:664-704,706-730`; `refs/vtank/decompiled/f7.cs:247-297`).
A candidate is rejected (`f7.n = false`) at the first failing gate, in
this order (`f7.cs:254-296`):
1. No cached creature-info record for the guid (`CreatureInfoMissing`).
2. The creature-info record is itself invalid, i.e. `hj.a()` true (`CIInvalid`).
3. Its matched Monster Rule priority is negative (`NegativePriority` — see §3).
4. Distance from the player exceeds the scan's max (`DistanceTooFar` — normally `AttackDistance`, but the pet-pick and "target lock follow" call sites pass their own max, `dz.cs:952,971,1092-1093`).
5. Distance is below the scan's min (`DistanceTooNear` — normally `AttackMinimumDistance`).
6. It needs a debuff but the monster's rule forbids attacking without one first (`DebuffPassWithNoAttack` — the two-arg `dz.a(int,f7)`/`dz.a(int,bool,d1.a)` gate at `dz.cs:561-607`; only reached when the rule's own "needs a specific debuff and it's not up" test at `dz.cs:561-585` is true **and** the rule is a pure-debuff row with no independent Attack/Ring flag).
A candidate that passes fills in distance (`f7.e`), heading delta to
the target (`f7.f`), the monster-rule debuff-urgency score (`f7.g`,
see §2), whether it *is* the last-attacked target (`f7.l`, guid equals
`ga.e`), whether it *is* the currently-attacked target (`f7.k`, guid
equals `ga.d`), whether it is the in-game selection **and**
`TargetLock` is on (`f7.j`), and whether it is blacklisted
(`f7.m`, guid is in `ga.f`) (`f7.cs:283-296`; `ga.cs:56-64` for the
field types).
**Ranges.** All distance-shaped settings (`AttackDistance`,
`AttackMinimumDistance`, `RingDistance`, `ApproachDistance`,
`ArcRange`, `TargetSelectAngleRange`, `PetCustomRange`) are compared
directly, unconverted, against `f7.e`
(`dz.cs:666,716-726`; `ga.cs:1081-1086`). The only place a `*240`
conversion appears is the monster-rule expression keyword `range`,
which exists purely for user-facing text
(`refs/vtank/decompiled/cl.cs:159-163`) — this confirms the raw
setting values and `f7.e` share one internal unit where 240 units ≈ 1
meter, but exact numeric defaults could not be reliably decoded from
`uTank2.Resources.defaultsettings.usd` (see §9).
**Ghost detection — two independent, differently-scoped mechanisms.**
VTank does not have one "ghost" concept; it has two counters fed by
different signals with different consequences:
| | Blacklist (`dz.an` = `fp`) | Ghost delete (`dz.am` = `b8`) |
|---|---|---|
| Increment trigger | A physical **missile** attack reports "hit the environment" (`refs/vtank/decompiled/bo.cs:110-116`) | (a) repeated retries of the cast gesture before the "You say" echo ever arrives (`refs/vtank/decompiled/gj.cs:319-334`); (b) a cast enters its result-wait state and **times out with no fail/success/kill text at all**, single-target spells only (`gj.cs:468,477-485`) |
| Force-trip (bypasses count) | An explicit "permanent fail" cast-result text (spell inapplicable to the creature) calls `fp.a` directly (`gj.cs:401-413`) | — |
| Reset | Any confirmed melee/missile hit-message (`bo.cs:117-120`); a spell "kill" or "success" result text (`gj.cs:391-398,442-452`); entering the cast-result-wait state resets the ghost counter unconditionally (`gj.cs:222-223`) | separately, an HP-tracker path (below) |
| Threshold | `BlacklistMonsterAttemptCount` (`fp.cs:79`) | `GhostMonsterSpellAttemptCount` (`b8.cs:111`) |
| Consequence | Marks the creature-info record blacklisted for `BlacklistMonsterTimeoutSeconds` — a **temporary** skip in future scans; the object is not touched (`fp.cs:86-100`) | Deletes the client-side world object outright (`f9.f(guid)`), only if it is still `ObjectClass.Monster` and `DeleteGhostMonsters` is on — **permanent** for that object (`b8.cs:111-120`) |
A **second, independent** ghost path exists purely from HP-bar
staleness: if `DeleteGhostMonstersByHPTracker` is on, the macro is
running, and there is a currently-HP-tracked monster
(`dz.ao.b() != 0`) whose species is recognized in the damage db, then
once **both** "time since the tracker last updated" and a second
tracker timestamp exceed `GhostDeleteHPTrackerSeconds`, that object is
deleted the same way, independent of any spell attempts
(`b8.cs:77-99`). This ticks on its own ~6.3 s timer (`b8.cs:16-19`),
not the 293 ms combat tick.
**Target lock.** `TargetLock` only changes whether "is the in-game
selection" (`f7.j`) counts as true when filling a candidate
(`f7.cs:293`, third argument threaded from `dz.cs:717,730`). It is
**not** a first-refusal filter at the candidate-building stage — its
effect on final target choice is a low-priority tie-breaker inside
selection (§2).
## 2. Target selection
After building candidates, `dz.p.b` picks one target via a single
linear pass that keeps a running "best so far" (`this.a`), never a
full sort (`dz.cs:706-730` header, comparison chain `dz.cs:740-920`).
The comparison chain, evaluated **in this exact order** for every
candidate against the current best:
1. **Priority** (the matched Monster Rule's priority, §3) always wins
first — *unless* `DebuffEachFirst` is `"All"`, in which case a
candidate that still needs a debuff outranks one that doesn't
**even across a priority difference**, and priority is only
compared once both share the same debuff-need state
(`dz.cs:740-751`, flag2 gate).
2. Within a priority tie: if `DebuffEachFirst` is `"All"` **or**
`"Priority"`, a candidate needing a debuff outright beats one that
doesn't (`dz.cs:770-781`, `flag` gate). `DebuffEachFirst == "One"`
skips this step entirely — VTank's own §2.1 phrasing ("one target,
priority group, or all targets before attack") maps to these three
values.
3. **Debuff-urgency score** (`f7.g`, from `dz.p.b(guid,f7)`
`dz.cs:163-199`): a small 03 integer — +1 if a Vuln debuff
matching the *currently chosen attack element* is not yet applied
(recast timer still running), **and** +2 if either Imperil (when
fighting with a melee/missile weapon) or Magic Yield (when fighting
bare-handed/with magic, i.e. `CombatState == Magic`) is not yet
applied. Higher score wins outright; this is evaluated for **every**
candidate regardless of `DebuffEachFirst`, and only checks these
two/three specific debuffs, not the full monster-rule debuff set.
4. **Target lock** (`f7.j`): if one candidate is the in-game selection
under `TargetLock` and the other isn't, it wins (`dz.cs:788-793`).
5. **Wield-match** (only when *both* candidates are within
`TargetSelectAngleRange` distance of the player): counts how many
of {weapon, offhand} would need to change to engage each candidate
and prefers fewer changes — i.e. avoid re-wielding between two
nearby targets (`dz.cs:794-824`).
6. **Sticky last target** (`f7.l`, guid equals the previously-attacked
guid `ga.e`): prefer the target already being attacked over a new
one (`dz.cs:825-830`).
7. Only now does `TargetSelectMethod` (§ below) decide.
**MossTank disagreement:** `CombatController.cs:1707-1798` clusters
candidates by max priority, then gives `TargetLock` (line 1740) and
the previously-attacked target (line 1757, explicitly commented as
modelling `ga.e`) **unconditional first refusal** ahead of any
ranking — i.e. steps 4 and 6 above are promoted ahead of steps 23
instead of following them. Steps 2 (`DebuffEachFirst`) and 3
(debuff-urgency score) have no equivalent in `CombatController.cs` at
all. See §8 gap #1.
**The three `TargetSelectMethod` values**, read once per scan as
`f3.f("TargetSelectMethod")` (`dz.cs:722`), decide only the final tie
(step 7) — for the vast majority of contested scans with more than one
same-priority monster this is reached only after every prior step
above ties:
| Value | Name (VTank UI) | Rule (`dz.cs:831-915`) |
|---|---|---|
| 1 | Distance | Sort by distance (`f7.e`) first, heading delta (`f7.f`) as the tiebreak |
| 2 | Angle | Sort by heading delta first, distance as the tiebreak |
| 3 | Both (hybrid) | If **both** compared candidates are within `TargetSelectAngleRange` distance of the player, sort by angle first (distance tiebreak); if **both** are beyond it, sort by distance first (angle tiebreak); a candidate within the cutoff always beats one beyond it, regardless of angle/distance values |
Despite its name, `TargetSelectAngleRange` is compared directly
against the **distance** field `f7.e`, never against `f7.f` (the
angle) — this is a real quirk of the retail setting, not a
misreading; MossTank's `CombatController.cs:1774` reproduces this
correctly (`candidate.Target.Distance <= _settings.TargetSelectAngleRange`).
## 3. Monster rules
The `MyMonsters` table backs `d1` (`d1.cs`). Its 21 columns, recovered
from the schema listed in
`refs/vtank/decompiled/uTank2.Resources.defaultsettings.usd:48-70` and
cross-checked against the column→field wiring in
`d1.a.a(cw)`/`a(a)` (`d1.cs:58-119`) and the incremental-migration
names in `refs/vtank/decompiled/da.cs:280-322`:
| # | Column name | `d1.a` field | Type | Meaning |
|---|---|---|---|---|
| 0 | MonsterName | `u` | string | Rule name; `"<DEFAULT>"` is the fallback row |
| 1 | AttackPriority | `a` | int | -1 (never attack/never targeted) .. 4 |
| 2 | DamageType | `b` | `eDamageElement` | Primary attack element, or `Auto`/`Harm` |
| 3 | WeaponToUse | `f` | int | Wielded-item object id override (0 = auto) |
| 4 | Imperil | `g` | bool | Cast Imperil Other I |
| 5 | Vuln | `h` | bool | Cast Vuln matching the *attack* element |
| 6 | Yield | `i` | bool | Cast Magic Yield Other I |
| 7 | GravityW | `k` | bool | Cast Gravity Well |
| 8 | Attack | `!t` | bool (stored inverted) | Attack the monster at all |
| 9 | Ring | `j` | bool | Use ring/void-curse attack magic |
| 10 | Broadside | `l` | bool | Cast Broadside of a Barn |
| 11 | Fester | `m` | bool | Cast Fester Other I |
| 12 | WeakeningCurse | `n` | bool | Cast Weakening Curse I |
| 13 | FesteringCurse | `o` | bool | Cast Festering Curse I |
| 14 | Corruption | `p` | bool | Cast Corruption I |
| 15 | DestructiveCurse | `q` | bool | Cast Destructive Curse I |
| 16 | Corrosion | `r` | bool | Cast Corrosion I |
| 17 | Streak | `s` | bool | Prefer streak-shape attack spells |
| 18 | SecondaryVuln ("Ex. Vuln") | `c` | `eDamageElement` | Extra Vuln element beyond the natural one |
| 19 | SecondaryEquip ("Offhand") | `e` | `eSecondaryEquipTypeOrObjectID` | Offhand item selection mode/id (exact enum values not recovered, §9) |
| 20 | PetDamageType | `d` | `eDamageElement` (default `PAuto`) | Preferred pet damage element for this monster |
**Matching semantics.** `d1.a(fu)` walks the table top-to-bottom,
skipping the `"<DEFAULT>"` row, and returns the **first** row whose
expression matches; the default row is the fallback only when nothing
else matched (`d1.cs:415-448`). A per-row/per-target match result is
cached for the session unless the expression used a *volatile* token
(see below), in which case it's re-evaluated every call
(`d1.cs:389-405`; the cacheability flag comes from `cl.a`'s `out bool`).
**Expression grammar** (`cl.cs`), a small infix language with a
shunting-yard evaluator over doubles and strings:
- Literals: numbers, and quoted/bare strings.
- Operators: `&& || == < > >= <= != #` (regex match, string only)
`+ - * / %`, with `(` `)` grouping (`cl.cs:279-304` precedence table,
`cl.cs:306-446` evaluator).
- Built-in identifiers, each a function of the candidate monster
(`cl.cs:87-182`): `true`, `false`, `name`, `typeid`
(`PropertyInt` `bc.cp`), `species` (species-table name via
`dz.y.b`), `maxhp` (`dz.y.c`, from the damage/species db), `range`
(distance × 240, **volatile**), `hasshield` (any armor-class item on
the target, **volatile**), `metastate` (**volatile**).
- `setting_<Name>` reads any VTank setting by name at evaluation time
(**volatile**) (`cl.cs:184-216`).
- If the final expression value is numeric, non-zero means match; if
it resolves to a string, VTank instead compares that string
case-insensitively to the monster's own name (`cl.cs:247-262`) — so
a bare string literal like `"Drudge"` is itself a valid "expression."
- Parse or evaluation errors are caught, logged, and treated as
cacheable non-matches (`cl.cs:263-274`).
**MossTank comparison:** `MonsterExpression.cs` and `MonsterRules.cs`
are a faithful, well-cited re-derivation of this exact grammar,
identifier set, and volatility/caching model (`MonsterExpression.cs:69-117,151,214-229`;
`MonsterRules.cs:104-150`) — no material gap found here.
## 4. Weapon and damage choice
**Auto damage database.** `dz.y` (`e0`) wraps a community
`gameinfodb.ugd` file (falling back to an embedded
`defaultinfodb.ugd`), auto-updated from
`auth.virindi.net/plugins/gamedb/get2.php`
(`e0.cs:53-79,435-441`). `e0.d(monsterName)` resolves the auto-damage
element preference list with a two-step fallback: an explicit
per-monster row in `MonsterDamageOverrides`, else the monster's
species row in `SpeciesDamages` (via `SpeciesMembers` for the
name→species id lookup); if neither exists it returns an **empty**
list, not a guessed default (`e0.cs:327-349`). When a Monster Rule's
`DamageType` is `Auto`, `f7.a()`'s private element resolver takes the
list's **first** entry as the debuff/attack element, or leaves it
`None` if the list is empty (`f7.cs:200-227`).
**MossTank disagreement:** `VtankDamageDatabase.cs:12-21,28-44` falls
back to a hardcoded 7-element guess order
(`Pierce, Bludgeon, Slash, Acid, Electric, Cold, Fire`) when a monster
is in neither local table, where retail simply has no auto-element
for that monster (no Vuln cast, `f7.h == None`). See §8 gap #4.
**Ammo/prismatic.** For bow-class weapons, `ga.a(fi,eDamageElement)`
checks whether ammunition of the requested element is actually in
inventory via `bv.b(fi, element, 1, ePrismaticDamageBehavior.Any)`
(a small per-tick cache keyed by launcher-shape) before allowing that
element to be used, logging a warning and refusing otherwise
(`ga.cs:1211-1241`); a mirrored `b(fi,eDamageElement)` exists
immediately after (only partially read; not confirmed identical).
Launcher/projectile shape is carried as a 4-value enum `l`
(`l.a/b/c/d`) attached to debuff items and spells (`dz.cs:250,264,296`;
`f7.cs:122-146`) but its four case names could not be recovered from
the available source (§9).
**Offhand / re-wield.** The Monster Rule's `SecondaryEquip` column
(`eSecondaryEquipTypeOrObjectID`) selects the offhand item; the target
selector's wield-match tie-break (§2 step 5) actively tries to avoid
re-wielding weapon or offhand between two nearby targets, but does not
prevent it outright — a genuine priority/urgency difference always
forces a re-wield.
**MossTank comparison:** `VtankAmmunitionDatabase.cs` was not read in
detail for this pass; flagged for a follow-up doc rather than guessed
here.
## 5. Attack execution
**Melee/missile timing (`bo`, `refs/vtank/decompiled/bo.cs`).**
`bo.a(guid,power,spell)` (called only for physical attacks — `spell`
is always `null` here) selects the target in-game if not already
selected and arms the attack (`bo.cs:326-348`). A 263 ms timer
(`bo.cs:22,46-48`) drives the swing loop: while waiting to confirm the
in-game selection actually changed, it re-issues `SelectItem`; once
selected, the private swing method fires (`bo.cs:238-268`). That
method reads `DefaultMeleeAttackHeight`, and if `AutoAttackPower` is
on, applies the computed power (§ below) via `f9.a`
(`bo.cs:296-324`); it then sends the height-mapped key down+up pair
(`ha.a/b/c → br.aq/af/ae`, `bo.cs:172-181`) and locks
`ActionLockType.MeleeAttackShot` for 0.75 s (`bo.cs:322`). Hit/miss is
read back out of chat: a missed missile shot ("hit the environment")
feeds the blacklist counter, a matching damage-report line
(`^(Critical hit!)? ... for ... point(s) of ...!$`) resets it
(`bo.cs:110-120`, §1 table).
**Power/height and Recklessness (`hi.c`, called only for
`CombatState.Melee`/`Missile`, i.e. `val == 2`/`4`;
`hi.cs:650-680`).** Missile attacks always use power `1f`. Melee power
is a fixed decision table over: whether the chosen weapon is a
single-hand slash/pierce hybrid without an offhand melee weapon,
whether the weapon has a triple-slash attack type, and whether the
offhand is another melee weapon or a shield — producing one of
`{0f, 0.2f, 0.49f, 0.5f, 1f}` (`hi.cs:657-661`). If `UseRecklessness`
is on and the Recklessness skill is trained, the result is clamped to
`[0.11, 0.9]` (`hi.cs:663-673`; `bo.cs` applies the same clamp to the
`AutoAttackPower`-computed value). **MossTank's `AutoAttackPower.cs`
is a faithful, explicitly-cited port of this exact table and clamp**
(`AutoAttackPower.cs:55-83`, header comment names `hi.cs` directly) —
no material gap found.
**Magic attack selection (`hi.a`, the per-tick decision object,
`hi.cs:66-327`).** `CombatState` is derived from the *player's chosen
weapon's* `ObjectClass`: `MeleeWeapon → Melee(2)`,
`MissileWeapon → Missile(4)`, anything else (including bare hands and
wands/orbs) → `Magic(8)` (`ga.cs:1581-1616`; the numeric tags are
inferred from the `(CombatState)N` casts used at every call site, not
from an explicit enum declaration — see §9). When `CombatState ==
Magic`, `hi` first runs the fixed 12-step debuff-priority chain (§6),
then, only if none is due, the attack-spell branch:
- **Ring** is used when the monster rule's Ring flag is set **and**
either the nearby-monster count (`dz.p.c`, tallied during the scan
as "candidates within `RingDistance`" — `dz.cs:735-739`) meets
`MinimumRingTargets`, or the rule has no independent Attack flag at
all (`hi.cs:220-240`). If the ring spell needs scarab components and
they're in inventory, casts it directly; otherwise falls through to
bolt/arc.
- **Streak** is tried next when the rule's Streak flag is set and a
usable streak spell of the requested element exists; if none is
usable it logs a warning and falls back to bolt/arc
(`hi.cs:258-307`).
- **Bolt vs Arc** (`hi.a(eDamageElement,f7)`, `hi.cs:471-542`): looks
up a War-school bolt spell and an Arc spell for the element; if only
one exists, use it; **otherwise the higher-`Quality` spell wins
outright** — `UseArcs` is consulted **only when both spells tie in
Quality**, where `1` = prefer bolt, `2` = prefer arc only if
`f7.e >= ArcRange`, `3` = always prefer arc, default = prefer bolt.
**MossTank disagreement:** `AttackSpellCatalog.cs`'s `Preference()`
(lines 140-204) buckets candidates by `UseArcs`/`ArcRange`/streak
**before** ever comparing spell quality — Tier/Difficulty only break
ties *within* a bucket (`Compare`, lines 93-138). This means MossTank
will follow the `UseArcs`/range rule even when the character actually
knows a strictly higher-tier spell of the other shape, where retail
picks the higher-tier spell outright and only falls back to
`UseArcs` on an exact tie. See §8 gap #2.
**Spell fizzle / result handling (`gj`, the cast state machine,
`gj.cs:1-149,341-466`).** States: idle → waiting for the "You say ..."
gesture echo → waiting for a result chat line. Result-line
classification against four regex families (`refs/vtank/decompiled`
list source not fully traced, referenced as `l.g.{a,b,c,d}`):
"kill" (`d`, ends the target and clears the blacklist counter),
"permanent fail" (`b`, e.g. immune — force-trips the blacklist
immediately), "fail"/resist (`a`, plain reset, no penalty), "success"
(`c`, matched by spell name + optional target name — clears the
blacklist counter). A silent timeout with **no** result line at all
increments the ghost counter instead (§1).
## 6. Debuffs
**Fixed check order.** Debuff *choice* is not a sorted/scored list —
`hi`'s private decision method (`hi.cs:66-327`) tests exactly twelve
debuffs in this **hardcoded** order and dispatches the first one whose
recast timer has elapsed (within `DebuffPrecastSeconds` of expiring,
except Corruption/DestructiveCurse/Corrosion which require the timer
to have fully reached zero):
1. Magic Yield Other I (`hi.cs:123-129`)
2. Weakening Curse I (`130-136`)
3. Festering Curse I (`137-143`)
4. Corruption I (`144-150`, zero-tolerance)
5. Destructive Curse I (`151-157`, zero-tolerance)
6. Corrosion I (`158-164`, zero-tolerance)
7. Imperil Other I (`165-171`)
8. Vuln matching the **current attack element** (`172-178`)
9. Vuln matching the rule's **`SecondaryVuln`/"Ex. Vuln"** column, via
`f7.h` (`179-185`)
10. Gravity Well (`186-192`)
11. Broadside of a Barn (`193-199`)
12. Fester Other I (`200-206`)
Once a debuff is chosen, the actual spell/wand/item used to cast it is
resolved separately by `dz.a(MySpell,f7)` (`dz.cs:219-393`), which
picks among: the equivalent spell known by the character, a wielded
wand of matching family/quality, or a thrown "grenade" item — ranked
by the `dz.b` comparer using `DebuffSelectionMethod` (`"SpellLevel"`
compares item Quality first then item count/priority, `"Skill"` swaps
that order — `dz.cs:11-91`), with wand fallback and
`AllowDebuffFallback` gating whether a mismatched projectile-type item
may substitute at all (`dz.cs:233-393`). This per-debuff *item* choice
comparer is a completely separate mechanism from the fixed
*debuff-kind* order above.
**`DebuffEachFirst` reaches into target selection**, not just
scheduling — see §2 steps 12. `"One"` leaves target choice alone;
`"Priority"` makes debuff-need a tiebreak within a priority tier;
`"All"` makes it override priority itself until the need is resolved.
**Wand switching.** When the chosen debuff must be cast via a wand and
`SwitchWandsToDebuff` is on, VTank actually re-wields to a
matching-element wand for the cast (comparing the *target's* current
wielded-item CombatState against the *player's own* prospective
change) before casting, then restores afterward
(`dz.cs:486-508`).
**MossTank disagreement (highest-impact finding in this document):**
`DebuffSpellCatalog.cs`'s `OrderedFlags` (lines 21-34) declares the
order `Fester, Broadside, GravityWell, Imperil, Yield, Vulnerability,
WeakeningCurse, FesteringCurse, Corruption, DestructiveCurse,
Corrosion` — almost the **reverse** of retail's real order above
(retail's *last*-checked debuff, Fester, is MossTank's *first*).
Worse, MossTank does not implement retail's "check exactly one fixed
kind per tick, first due wins" model at all: it gathers **every** due
debuff into a candidate set and sorts it by `DebuffSelectionMethod`
(Skill/SpellLevel) then spell Tier/Difficulty, with `ActionOrder`
(the wrong-order array above) only as the final tiebreak
(`DebuffSpellCatalog.cs:93-118`). Retail's `DebuffSelectionMethod`
comparer (`dz.b`, §6 above) is a *per-kind item/spell choice*
mechanism in the real client, never a *cross-kind debuff-choice*
ranking — MossTank has repurposed it for a role retail never gives it.
Also, retail's natural-element Vuln (step 8) and the rule's own
`SecondaryVuln` Vuln (step 9) are two sequential, separately-ordered
checks; MossTank's `Required()` (lines 125-146) does add both as
distinct `DebuffIdentity` values, but assigns them the *same*
`ActionOrder` (5), so their relative order falls to
Tier/Difficulty/SpellId instead of retail's guaranteed
natural-before-extra sequence. See §8 gap #1.
## 7. Pets
`ga.j()` (`ga.cs:1076-1209`) is the pet-selection algorithm, run from
the always-on `h1` "SummonPet" logic rule
(`refs/vtank/decompiled/h1.cs:30-53`, gated on `EnableCombat`,
`SummonPets`, the Summoning skill being trained, and a
spell-cooldown/readiness check via `an.a(-32555)`/`bm.a()` whose exact
semantics were not traced further):
1. Pick a scan range: `PetCustomRange` if `PetRangeMode == 1`, else
`AttackDistance` (`ga.cs:1081-1085`).
2. Build `f7` candidates for every monster in range (bypassing the
min-distance/target-lock/priority-reject gates used for normal
attack scanning — only the base validity check `f7.o` is used);
among those whose matched Monster Rule has a `PetDamageType` other
than `None`, track the count and the single best one under a
**combined** "nearer distance AND higher rule priority" predicate
(`ga.cs:1090-1110`).
3. Refuse to summon (return 0) if no eligible monster exists, or if
the eligible count is below `PetMonsterDensity`
(`ga.cs:1111-1118`).
4. Among the player's own pet-capable items (`PluginCore.PC.ec`,
filtered to ones actually owned/wieldable with a known damage
element), score each by how well its element matches the target:
exact match to the rule's `PetDamageType` scores best, then a match
to the target's *actual chosen attack element*, then a match
anywhere in the target's auto-damage preference list (indexed, so
earlier list entries score better); ties prefer the pet with the
higher `a12.m` stat (not identified further) (`ga.cs:1119-1206`).
5. Return the winning pet's object id, or 0 for none.
The `h1` rule's `Running(true)` handler simply calls `f9.p(petId)`
use/summon that item (`h1.cs:74-81`). No MossTank pet-selection code
was located in this pass to compare against (`CombatController.cs`
was not searched exhaustively for a pet feature — flagged in §9).
## 8. MossTank gap ranking (highest player impact first)
1. **Debuff-kind ordering and selection model is structurally
different, not just re-ordered.** `DebuffSpellCatalog.OrderedFlags`
(`DebuffSpellCatalog.cs:21-34`) is close to the reverse of retail's
real fixed 12-step order (`hi.cs:123-206`), and MossTank scores
across debuff *kinds* using a comparer retail only ever uses to
choose *within* one kind (`DebuffSpellCatalog.cs:93-118` vs.
`dz.b`, `dz.cs:11-91`). Effect: a MossTank character debuffs
targets in a different sequence than retail VTank ever would,
which changes which debuff is up when an attack lands and can
waste casts on lower-value debuffs first.
2. **Target selection omits `DebuffEachFirst` and the debuff-urgency
score entirely, and inverts the priority of `TargetLock`/sticky-target.**
Retail interleaves debuff-need into priority/tie-break resolution
(`dz.cs:740-824`) with `TargetLock` and the sticky-last-target as
*low*-priority tiebreaks near the bottom of the chain; MossTank
(`CombatController.cs:1707-1798`) gives `TargetLock` and the
sticky-last-target unconditional first refusal within the top
priority tier and has no debuff-need or debuff-urgency signal at
all. Effect: MossTank can get "stuck" defending a locked/sticky
target far more rigidly than retail, and never re-prioritizes a
same-priority target that urgently needs a re-debuff.
3. **Arc vs. Bolt is chosen by `UseArcs`/range before spell quality,
not after.** Retail always prefers the higher-`Quality` known spell
and only falls back to the `UseArcs` rule on an exact tie
(`hi.cs:501-540`); MossTank's `Preference()` buckets by
`UseArcs`/`ArcRange` first and only uses Tier/Difficulty to break
ties inside a bucket (`AttackSpellCatalog.cs:140-204`). Effect: a
character who knows a much stronger bolt (or arc) than their
counterpart shape will still be forced into the weaker one whenever
the `UseArcs`/range rule says so.
4. **Unknown-monster auto-damage falls back to a guessed element
order instead of no auto-element.** Retail returns an empty
preference list when a monster is in neither `MonsterDamageOverrides`
nor `SpeciesDamages` (`e0.cs:327-349`), meaning no auto-Vuln is cast
for it; MossTank falls back to a fixed
`Pierce > Bludgeon > Slash > Acid > Electric > Cold > Fire` guess
(`VtankDamageDatabase.cs:12-21`). Effect: only matters for monsters
missing from MossTank's bundled tables, but produces a
confidently-wrong element choice rather than retail's "skip it"
behavior.
5. **No wield-match (weapon/offhand-thrash avoidance) tiebreak.**
Retail actively avoids re-wielding between two nearby same-priority
targets (`dz.cs:794-824`); no equivalent logic was found in
`CombatController.cs`. Effect: minor DPS/time loss from unnecessary
re-wields when several adjacent monsters need different weapons,
lower impact than 13 above.
`AutoAttackPower.cs` (melee power table) and `MonsterRules.cs` /
`MonsterExpression.cs` (rule expression grammar) were both checked in
detail and found to be faithful, well-cited ports with no material gap.
## 9. Could not determine
- Exact numeric default values for distance-shaped settings
(`AttackDistance`, `RingDistance`, `ArcRange`,
`TargetSelectAngleRange`, etc.) in
`uTank2.Resources.defaultsettings.usd` — the file appears to hold at
least two differently-typed tables under the same setting names
(one using small `0.02083...`-style doubles, a second using plain
integers like `16`/`48`), and the exact `y`/`bd` deserialization
schema needed to tell them apart was not available in this pass. The
*names* and *semantic meaning* of every setting cited above are
independently confirmed via call sites, not via this file.
- `eSecondaryEquipTypeOrObjectID`'s enum members (offhand selection
modes) — only its column position and type name were recovered.
- The `l` enum's four case names (`l.a/b/c/d`, launcher/projectile
shape attached to debuff items and ammo checks) — used but never
declared in the files read.
- `CombatState`'s enum declaration and full name — only inferred
from the numeric casts `(CombatState)2/4/8` at every call site
(`ga.cs:1596-1615` and callers); no explicit `enum CombatState { ... }`
was found in the files searched.
- `ga.a`'s exact per-field semantics beyond what call sites imply
(`j`, `e`, `g`, `l`, `m` — weapon-type flag, slash/pierce-hybrid
flag, triple-slash flag, an unidentified "l" counter used for
critical-hit chat correlation, and an unidentified "m" stat used as
a pet tiebreak).
- `bm.a()` and the `-32555` cooldown check gating the `h1` SummonPet
rule — not traced beyond their call site.
- The four cast-result regex families referenced as `l.g.a/b/c/d` in
`gj.cs` — their defining file was not located in this pass.
- Whether MossTank has any pet-selection logic at all;
`CombatController.cs` (2059 lines) was searched for combat-loop,
target-selection, and attack-execution code but not exhaustively for
a pet feature.
- `VtankAmmunitionDatabase.cs` and the full `ga.a`/`ga.b`
ammo-availability pair (`ga.cs` beyond line ~1245) were not compared
against retail in detail.