diff --git a/docs/research/vtank-kb/02-scheduler-and-actions.md b/docs/research/vtank-kb/02-scheduler-and-actions.md new file mode 100644 index 00000000..dc0ad538 --- /dev/null +++ b/docs/research/vtank-kb/02-scheduler-and-actions.md @@ -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 A_0, MyList 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(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. diff --git a/docs/research/vtank-kb/03-combat.md b/docs/research/vtank-kb/03-combat.md new file mode 100644 index 00000000..c7b095f4 --- /dev/null +++ b/docs/research/vtank-kb/03-combat.md @@ -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 0–3 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 2–3 +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; `""` 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 `""` 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_` 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 1–2. `"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 1–3 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.