diff --git a/docs/research/vtank-kb/04-buffs-and-vitals.md b/docs/research/vtank-kb/04-buffs-and-vitals.md new file mode 100644 index 00000000..568c0e6c --- /dev/null +++ b/docs/research/vtank-kb/04-buffs-and-vitals.md @@ -0,0 +1,719 @@ +# VTank knowledge base 04 — buffs and vitals + +Research only. Oracle: `refs/vtank/decompiled/` (ILSpy decompile of +`utank2-i.dll`; obfuscated identifiers, intact strings/settings/spell +names). Builds on +`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` (rule-engine order, +`ForceCombatMode`, wand/caster selection) — that note's pins are not +re-derived here, only extended. All paths below are relative to +`refs/vtank/decompiled/` unless marked `acdream:`, which is relative to +the repo root. Identifiers are obfuscated single/double letters; this +doc never pastes decompiled source, only short identifiers and the +string literals VTank ships (spell/item/setting names survive +obfuscation intact). + +## 0. The moving parts, one paragraph each + +- **`fz.cs`** — the `BuffSelf` rule (`ILogicRule`). Registered twice in + `cLogic.cs`: once as the ordinary rebuff rule (threshold setting + `RebuffTimeRemainingSeconds`, considers consumable helper items) and + once as the idle top-off rule (threshold `IdleBuffTopoffTimeSeconds`, + does not consider consumables), see §1.2. +- **`eq.cs`** (`PluginCore.dz.k`, field `s.k` at `s.cs:29`) — the buff + *engine*: builds the self-buff candidate list (`b()`), the + item-enchantment candidate list (`g()`), runs the "is anything due" + scan (`a(out itemId, out consumableItemId)`), and owns the profile + store `m_e : MyList` where `c(int itemId, int spellId)` is one + profile row (ctor `eq.cs:31-35` maps first arg → field `b` = item id, + second arg → field `a` = spell id; `-1` spell id = "no buffs" row). +- **`ga.cs`** — `ForceCombatMode` (wand/shield/caster selection, + already pinned in the trace note) plus the class→`fs` item-type + table and the `fi` object-class-9 sub-tags used by the wand/shield + eligibility checks. +- **`cr.cs`** (`RechargeSelf2`) / **`cg.cs`** — the vitals *trigger*: + compares current Health/Stam/Mana percent against the three + `Recharge-*-*` settings and calls `cRechargeManager.Recharge(vital)`. +- **`cRechargeManager.cs`** — the vitals *dispatch table*: + `RechargeHandlerSet` (a settings TABLE), tried in list order per + (vital, stance) until one's `Activate()` succeeds. +- **`m.cs`, `g4.cs`, `a5.cs`, `dg.cs`** (+ `bn`/`hc`/`c2`/`f2` : + `dg`) — the seven `IRechargeHandler`s: Regular Spell, Recharge With + Food, Kit Recharge, and the four vital-transfer spells. +- **`he.cs`** — shared recharge-cast dispatch: arms the + `RechargeLevelBoost_*` action lock (via `RechargeBoostTimeSeconds`) + when not yet in Magic mode, casts once actually in Magic mode. + Estimates a transfer spell's HP yield by name suffix (§4.4). +- **`ai.cs`** (`PluginCore.dz.l`) — helper-heal target picker: tracks + fellows (server broadcast `0xF7B1`/event 704) and non-fellows + (client/server echo dispatch), casts the fixed Adja's + Gift/Replenish/Gift of Essence family. +- **`gu.cs`** (`RechargeOther`), **`fb.cs`** (`UseHealersHeart`), + **`af.cs`** (`DispelAllies`), **`ba.cs`** (`RandomHelper`) — the + four helper/ally logic rules. +- **`c8.cs`** (`DispelSelf`), **`cx.cs`** (`UseDispelItem`) — self + dispel. +- **`MySpell.HasScarabsInInventory`** (`uTank2/MySpell.cs:398-456`) — + the real blacklisted-component gate. +- **`e7.cs`** / **`hz.cs`** — the two "Choose Extra Buffs" pickers + (same XML, different title/target list): e7 adds to the whitelist + `dz.m.i`, hz adds to the blacklist `dz.m.j`. + +acdream counterparts, all under `src/AcDream.Plugins.MossTank/`: +`BuffPlan.cs`, `BuffProfile.cs`, `VitalRecharge.cs`, +`DispelController.cs`, `ItemManaRecharge.cs`, `SpellComponentPolicy.cs`, +and the driver in `MossTankPanel.cs` (`StartBuffPass`, `BuildPlan`, +`TryCast`, `TickAutomaticBuffing`, `TickRandomHelper`). + +## 1. Buff plan + +### 1.1 Candidate list (`eq.b()`, `eq.cs:134-236`) + +Retail's self-buff candidate list is a single **hardcoded, ordered** +sequence, not a generic "everything self-targeted" scan: + +| Order | Candidate(s) | Cite | +|---|---|---| +| 1 | `Creature Enchantment Mastery Self I` | `eq.cs:143` | +| 2 | `Focus Self I` | `eq.cs:144` | +| 3 | `Willpower Self I` | `eq.cs:145` | +| 4 | `Mana Conversion Mastery Self I` | `eq.cs:146` | +| 5 | `Life Magic Mastery Self I` | `eq.cs:147` | +| 6 | one self-buff per **trained skill** in `PluginCore.dz.q.x` (skill→spell-name map), only for skill classes 2 or 3 (i.e. an actual skill, not an attribute); unknown skill → warns "Unknown skill detected!" and drops it | `eq.cs:96-110,148-151` | +| 7 | `Strength/Endurance/Coordination/Quickness/Regeneration/Rejuvenation/Mana Renewal Self I` | `eq.cs:152-158` | +| 8 | `Impenetrability I` (physical bane, i.e. Armor Self's item-enchant sibling) | `eq.cs:159` | +| 9 | the elemental **prots** named by `BuffProfile-Prots`/`BuffProfile_Prots` (letter string `ALFCBPS`, or a numeric legacy mode) | `eq.cs:160-165` | +| 10 | the user's **extra self buffs** list (`PluginCore.dz.m.i`, populated by the `e7.cs` picker) | `eq.cs:166-169` | +| 11 | any **untargeted** profile row (`eq.m_e`, item id -1 entries with a spell whose `isUntargeted` is true) | `eq.cs:170-181` | + +Then two filters remove entries whose *family* is already covered by +something in the **blacklist** (`PluginCore.dz.m.j`, populated by +`hz.cs`, §1.7) — `eq.cs:182-197` — and two more strip whole +**schools** once the character exceeds `BuffWithUntrained-Item` / +`-Creature` / `-Life` level while untrained in that school +(`f9.c(eGameSkillID.*)` = trained check) — `eq.cs:198-234`. + +`eq.g()` (`eq.cs:238-261`) is the **item-enchantment** candidate list: +`Impenetrability I` targeted at self first, then the profile's +**banes** (`BuffProfile-Banes`/`BuffProfile_Banes`, same letter/legacy +scheme), then every **targeted** profile row (item id ≠ irrelevant, +spell not untargeted). It is gated the same way on +`BuffWithUntrained-Item` (`eq.cs:242-245`) — it returns an **empty** +list outright above that level while untrained, rather than filtering +per-row. + +**acdream (`acdream:src/AcDream.Plugins.MossTank/BuffProfile.cs`)** +takes a structurally different, description-driven approach: it groups +every known self-buff by spell family and classifies each family from +retail's own spell **description** text (regexes for "Increases the +caster's X", "Reduces damage… from X", the aura sentence, the bane +sentence, the three regen-rate sentences — +`BuffProfile.cs:79-142,204-291`), rather than a hardcoded name list. +This generalizes past retail's fixed candidate set (any self-buff the +character knows gets classified and offered, not just the ~20 hardcoded +lines), and is documented as deliberate (`BuffProfile.cs:47-76`). It +does not reproduce retail's list-*order* semantics directly; instead +`acdream:BuffPlan.cs:337-383` (`CastRank`) encodes school-then-family +ordering (Creature → Item → Life, with Creature sub-order +mastery→Focus→Self→Endurance→rest, Life sub-order +prots/other→regeneration-last) as a *documented reconstruction* of why +retail's literal list is ordered the way it is (§1.6 below covers a gap +in that reconstruction). + +### 1.2 The "due" test + +`fz.a()` (`ILogicRule.ValidNow`, `fz.cs:70-90`): + +1. `false` if the `ItemUse` action lock is held (`fz.cs:72-75`). +2. `false` if `EnableBuffing` is off (`fz.cs:76-79`). +3. `num = f3.j(m_c)` — the configured threshold in **seconds** + (`RebuffTimeRemainingSeconds` = 300 for the main rule, + `IdleBuffTopoffTimeSeconds` = 1200 for the idle rule; + `cLogic.cs:472,553`). +4. If the `BuffCastRecast` lock is held, `num += BuffCastRecast_Seconds` + (default 30) — i.e. right after a successful cast the *effective* + due-threshold is widened by 30s for the reset window + (`fz.cs:80-84`, and see §1.3). +5. Floor `num` at 10 seconds (`fz.cs:85-88`). +6. Delegates to `eq.a(num, m_d, out m_e)` — `m_d` is the ctor's + "consider consumables" flag: `true` for the main rule, `false` for + idle top-off (`fz.cs:21-27`, `cLogic.cs:472,553`). + +`eq.a(int seconds, bool considerConsumables, out bool isConsumable)` +(`eq.cs:573-582`) sets `this.b = seconds`, `this.c = considerConsumables`, +then calls the private `a(out itemId, out consumableItemId)` +(`eq.cs:477-565`) which walks the candidate lists in the exact order of +§1.1 and returns the **first** spell that is either: not currently +tracked as active at ≥ the candidate's quality with ≥ `seconds` +remaining (self-buffs, `eq.cs:481-503`; the "remaining" bookkeeping is +`eq.i()`/`eq.b(ActiveSpellInfo)`/`eq.a(ActiveSpellInfo)` reacting to +the client's tracked active-spell events, `eq.cs:362-475`), or a +targeted item-enchant row whose remaining time (`eq.j.b(itemId, +spell).TotalSeconds`) is under `seconds` (`eq.cs:510-527`), or — only +when `considerConsumables` is true and `ItemUse` is free — a known +**consumable buff item** (`PluginCore.dz.m.g`, populated at +identify-time, `PluginCore.cs:7524-7534,7628-7654`) not on cooldown +and not a fellowship spell while out of fellowship (`eq.cs:528-561`; +sets `m_h = true` to signal "this is a consumable", `eq.cs:554`). + +**acdream** (`acdream:BuffPlan.cs:104-213`) is a *snapshot* planner: +`Build()` reads skills/attributes/active enchantments **once**, decides +every wanted line, picks a tier, and returns the whole ordered queue. +`MossTankPanel.StartBuffPass` calls `BuildPlan` (→ `BuffPlan.Build`) +exactly once per pass (`acdream:MossTankPanel.cs:3607-3656,3679-3698`); +`TryCast` (`acdream:MossTankPanel.cs:4265-4303`) then walks the fixed +queue index-by-index and never re-invokes `BuildPlan` mid-pass. Retail +re-evaluates `eq.a()` **every logic tick** (~293 ms, +`uTank2/cLogic.cs:145`) against freshly-read server skill/quality data +— see §5 gap G1. + +### 1.3 The `BuffCastRecast` lock, and how a pass "ends" + +`fz.a(bool running)` (`ILogicRule.set_Running`, `fz.cs:111-122`) fires +once per successful pick: if the picked spell was a consumable +(`m_e`) **or** the character is already in (or can reach) Magic mode +(`ga.a((CombatState)8, 0, anyItem:true)` — a `ForceCombatMode` +probe/no-op call, not a real state change here), it arms the +`BuffCastRecast` action lock for `BuffCastRecastReset_Seconds` (default +30) and calls `eq.a()` (`eq.cs:584-619`) — which re-derives the pick +and either dispatches the spell cast (`this.m_a.h.a(spell, itemId)`) or +uses the consumable item (`f9.p(consumableItemId)` plus an `ItemUse` +lock of `PluginCore.dz.o.k`, `eq.cs:588-618`). + +There is **no explicit "pass" object** in retail: `BuffSelf` is a +single `ILogicRule` that the engine re-evaluates every tick; a "pass" +is just "the rule keeps being the first `ValidNow` rule in the list +until nothing is due any more" (first-match-wins per the trace note). +Nothing marks completion; the rule simply stops returning `true` from +`ValidNow` once every candidate in the ordered list is covered or +exhausted. + +**acdream** has an explicit pass object (`_queue`/`_queueIndex`, +`acdream:MossTankPanel.cs:3607-3656`) with its own progress/stall +tracking (`_castThisPass`, `_sinceProgress`) not present in retail — +a deliberate modernization, not a port; see §5. + +### 1.4 Force Buff / Cancel Force Buff + +`PluginCore.ForceBuff()` → `dz.k.i()` (`eq.cs:362-372`): for **every** +tracked active-spell entry, sets its "not due again until" timestamp +`a` to `DateTimeOffset.Now`. Since the due test compares +`(entry.a - Now).TotalSeconds >= threshold`, zeroing `a` makes every +currently active buff look like it is about to expire *right now* — +so the very next `eq.a()` call treats everything as due, without +touching the real `ExpireTime` (`d`). `CancelForceBuff()` → `dz.k.e()` +(`eq.cs:374-384`) restores `a = d` for every entry, i.e. undoes the +force by putting the real remaining time back. Both call into +`this.m_a.j.d()`/`.h()` (the buff-list UI refresh) afterward +(`eq.cs:371,383`; `PluginCore.cs:230-239`). + +**acdream**: `BuffPlan.Build(force:true, …)` (`acdream:BuffPlan.cs:104, +185-192`) skips the "already covered" `continue` outright when +`force` is set, which is functionally equivalent (recast everything at +best tier regardless of remaining duration) but is a **different +mechanism** — a build-time bypass of one `if`, not a timestamp-zeroing +trick against a live tracked-duration table. No functional gap +observed; noted for completeness since the two codebases reach the +same visible behavior by different means. + +### 1.5 Fizzle / failure handling + +Not modeled as retry logic anywhere in the traced files: `fz`/`eq` +issue one cast attempt (`m_a.h.a(spell, target)`, field `s.h : gj`) +and rely on the **next tick's** fresh `eq.a()` re-evaluation to notice +the spell is still not active and try again — there is no dedicated +"cast failed, retry N times" state machine in this family (contrast +with the raw drop-to-peace retry counters the trace note found +elsewhere, e.g. `ga.r`). `gj.cs:211/226` (`o.h()`) and `gj.cs:198` +(`o.e()`) are the cast-in-progress suspend counters already pinned in +the trace note; while `ga.f()` (casting-in-progress) is true the whole +logic engine bails (`cLogic.cs:214`), so a fizzle simply means the +suspend clears, the engine resumes, and `eq.a()` runs again from the +top of its candidate list. + +### 1.6 Blacklisted components — the *real* mechanism + +The obvious-looking gate inside `fk.c()` (`fk.cs:198-236`, +"`Fail_HasBannedComponent`") calls `b(MySpell)` — but `fk.b(MySpell)` +(`fk.cs:95-102`) is: + +``` +if (A_0 == null || !A_0.isValid) return false; +return false; +``` + +i.e. it **always** returns `false` regardless of input — the +`HasBannedComponent` diagnostic branch is dead code in this build. + +The real blacklist is `MySpell.HasScarabsInInventory` +(`uTank2/MySpell.cs:398-456`): it parses `BlacklistedSpellComps` into a +name dictionary `d` (`MySpell.cs:401-410`), then for each of the +spell's own **scarab-class** components (`d10.e.Contains("Scarab")`, +`MySpell.cs:432`) — if that scarab's name is in the blacklist, +**disable the spell entirely** (`MySpell.cs:443-446`); otherwise if +the character doesn't have enough of that scarab in inventory, disable +it too and warn once (`MySpell.cs:447-452`). This gate is read by +`fk.c()`'s "can I currently cast this" check (`fk.cs:219,189`), so it +applies to **every** spell cast attempt system-wide, not just buffs. + +**acdream** (`acdream:SpellComponentPolicy.cs`) checks **all** of a +spell's `FormulaComponentIds` (id or name match) against the setting +— broader in principle than retail's scarab-only filter, but in this +game era all physical spell components *are* scarabs/talismans, so +the practical difference should be negligible; flagged as +"could not fully confirm" in §6. + +### 1.7 Extra buffs / blacklist pickers (`e7.cs` / `hz.cs`) + +Both use the identical `SelfBuffChoiceView.xml` (list + search box, +`refs/vtank/decompiled/uTank2.ViewXML.SelfBuffChoiceView.xml:1-11`), +built from the same candidate set — every known spell, deduplicated by +family, that is `isUntargeted && !isOffensive && Duration >= 1s` +(`e7.cs:56-76`, `hz.cs:56-76`, identical). Clicking a row: `e7` +(title unset, "Choose Extra Buffs") adds the spell id to +`PluginCore.dz.m.i` — the **whitelist** consumed at `eq.b()` item 10 +above (`e7.cs:123-127`); `hz` (title "Choose Blacklisted Buffs") adds +to `PluginCore.dz.m.j` — the **blacklist** consumed by family-removal +at `eq.cs:182-197` (`hz.cs:123-127`). + +### 1.8 Fast-cast / movement trick + +`MySpell.IsInstantCast` (`uTank2/MySpell.cs:104-129`): `Difficulty < +50` → true; else `isUntargeted && !isFellowship && +EffectiveDurationMS >= 60000 && School.Id in {2 (LifeMagic), 4 +(CreatureEnchantment)}` → true; else `RealFamily in +{243..249, 639}` → true. Gated by setting `FastCastBuffs` +("Attempt to fastcast buff spells. May move the character during +casting.", `uTank2.Resources.defaultsettings.usd:1139-1143`) at +`gj.cs:171,233,237` — movement is only attempted for non-War/non-Void +schools while an instant-cast spell is in flight. + +**acdream** (`acdream:MossTankPanel.cs:4348-4360`, +`IsVtankInstantCast`) reproduces this **exactly**: same +difficulty<50 branch, same untargeted/non-fellowship/≥60s/school-{31 +(Creature),33 (Life)} branch (AC skill ids 31/33 = VTank's internal +School.Id 4/2, consistent with the `fk.cs` school-id map), same family +range `243..249,639`. This is a faithful 1:1 port — no gap. + +## 2. Item enchantments + +`PluginCore.l(int itemId)` (`uTank2/PluginCore.cs:8327-8445`) is what +runs when a wand/shield/weapon/pet is **added to the profile** (the +"Add" / "Add (no buffs)" buttons, `PluginCore.cs:1625`, +`eBuffedItemAddOptions` enum at `PluginCore.cs:613-617`). It reads the +item's `LongValueKey 218103822` mastery-mask value and auto-populates +default enchant rows: + +| `LongValueKey 218103822` value | Auto-added spells (StandardBuffs) | "No buffs" fallback | Cite | +|---|---|---|---| +| `1048576` or `33554432` | Aura of Defender, Blood Drinker, Swift Killer, Heart Seeker Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8355-8375` | +| `4194304` (excluded if `dz.o.c(itemId) == fi.h`) | Aura of Defender, Blood Drinker, Swift Killer Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8377-8397` | +| `16777216` | Aura of Defender, Hermetic Link, Spirit Drinker Self I | `eq.c(itemId, -1)` | `PluginCore.cs:8399-8419` | +| `2097152` (shield) | all 7 elemental prots (`eDamageElement 0..6`) + Physical (`Armor Self I`) | `eq.c(itemId, -1)` | `PluginCore.cs:8421-8438` | + +A **pet** item (detected via `LongValueKey 36` presence, +`PluginCore.cs:8332-8334`, or `ga.e(WorldObject)`) is always added as +`eq.c(itemId, -1)` — no-buffs — with a mastery-mismatch chat warning if +`ga.f(fu)` fails (`PluginCore.cs:8340-8348`). Only items whose +`LongValueKey 218103822` is one of the five values above, or a pet, +pass the "Add" button's eligibility filter (`PluginCore.l()`'s caller +`PluginCore.b()`, `PluginCore.cs:8448-8477`). + +`eq.g()` (§1.1) is the runtime consumer: `Impenetrability I` self-cast +first, then the profile's configured **banes** +(`BuffProfile-Banes`/`_Banes`), then every profile row whose item id +still exists in inventory (`f9.d(itemId)`) and whose spell id is not +`-1` (`eq.cs:510-527`) — a `-1`-spell row (from either the "no buffs" +button or a pet) never contributes a targeted cast; a warning fires if +the item itself has left inventory (`eq.cs:512-515`). + +Wielding: item-enchant casting always targets the item itself +(`d(itemId)`, `eq.cs:353-360`, which substitutes the currently-wielded +weapon's id, `this.m_a.ax.g()`, whenever the profile row's item id is +`-1`) — there is no separate "must be wielded first" gate inside `eq`; +wielding for combat is handled by `ForceCombatMode` (§(c) of the trace +note) before any attack, independently of the buff pass. + +**acdream**: no equivalent "auto-populate default enchant candidates +from a weapon's mastery mask when it's added to the profile" pipeline +was found among `BuffPlan.cs`/`BuffProfile.cs`/`MossTankPanel.cs`. +`BuffProfile.Build` only classifies the character's own **known +spellbook** self-buffs (attributes/skills/prots/regen/self-auras via +description parsing); it has no notion of a *profile item* or its +`LongValueKey 218103822` mastery mask at all. This is flagged as gap +G2 in §5 — either the mastery-driven default-enchant flow lives +somewhere outside the six files this doc was scoped to (a companion +"item profile" module not yet located), or it genuinely has no acdream +counterpart yet. + +## 3. Helper buffs + +### 3.1 `RandomHelper` (`ba.cs`) + +Gate (`ba.a()`, `ba.cs:83-126`): `RandomHelperBuffs` on, `ItemUse` lock +free, `RandomHelperBuffLock` action lock free (armed for +`RandomHelperIntervalSeconds`, default 5s, on fire — +`ba.cs:154`), not mid-cast (`this.m_a.h.e()`). Target: any `Player` +object other than self within `0.075` distance units +(`f9.a(item.k, PluginCore.dn, true) < 0.075`, `ba.cs:105`; using the +trace note's ≈240 m/unit this is **18 m**). Spell: **one random draw** +from a fixed 11-name stem list (Endurance/Regeneration/ +Rejuvenation/Armor/Blade·Bludgeoning·Cold·Fire·Lightning·Piercing·Acid +Protection, all " Other I", `ba.cs:29-40`), resolved to the character's +best known tier via `this.m_a.i.a(id, false)` (`fk.a(int,bool)` → +`fk.b` → private `fk.a(MySpell,bool)`, which walks the spell's +`RealFamily` for the highest-quality version the character can +currently cast — `fk.cs:130-141,170-196`). + +Quoting the loop verbatim in structure (not pasting code): it retries +**up to 100 times**, and **returns `true` as soon as a randomly-picked +stem resolves to no castable spell** (`g == null`) — `ba.cs:116-124`. +Only if every one of 100 random draws happens to resolve to a castable +spell does the loop exhaust and `ValidNow` return `false` +(`ba.cs:125`). `Running(true)` then only actually casts when `g != +null` (`ba.cs:152`), so a hit on the `g == null` branch makes the rule +fire for that tick and do **nothing** — a plausible retail +quirk/bug (the loop looks like it means to search *for* a castable +spell, but structurally fires early on the *opposite* condition). This +is quoted, not paraphrased away, per project convention on decomp +predicates; flagged again in §5/§6. + +**acdream** (`acdream:MossTankPanel.cs:3996-4071`, +`TickRandomHelper`): same distance constant (`< 18d`, +`MossTankPanel.cs:4019-4020`, matching retail's 0.075 unit band), same +11-name stem list verbatim (`MossTankPanel.cs:4026-4033`), same +"resolve to best known tier" (`OrderByDescending(Quality).ThenBy(Tier)`, +`MossTankPanel.cs:4044-4046`), same interval lock semantics +(`_randomHelperRemaining`, floor 0.25s, `MossTankPanel.cs:4062-4064`). +It does **not** reproduce the 100-random-draws/early-`true`-on-miss +structure: instead it round-robins **deterministically** through every +(player × stem) combination from a rolling cursor and only fires on +the first combination that is actually castable +(`MossTankPanel.cs:4034-4068`). This is a clear, intentional +divergence — arguably a fix for what §3.1 quotes as a retail +oddity — not a straight port; recorded here as a finding rather than a +gap since it is not obviously "worse" than retail, only *different*. + +### 3.2 `UseHealersHeart` (`fb.cs`) + +Narrow item-specific rule: requires `UseHealersHeart` setting, +inventory item named exactly `"The Healer's Heart"` or `"Legendary +Seed of Mornings"` (the latter takes priority if both present, +`fb.cs:85-96`), `Healing` skill (`CharFilterSkillType 33`) buffed ≥ +245 and `Arcane Lore` (`CharFilterSkillType 14`) buffed ≥ 105 +(`fb.cs:101-108`), and a fellow found under the `Recharge-Helper-HitP` +threshold via `PluginCore.dz.l.a(float)` (`ai.cs:387-395`, §3.4). Casts +by using the item on the target (`PluginCore.dz.aa.a(itemId, targetId)`) +then marks that target's health-vital cooldown (`PluginCore.dz.l.a(target, +CharFilterVitalType.Health)`, `fb.cs:141-145` — though `ai.a(int, +CharFilterVitalType)`, `ai.cs:430-432`, is itself an empty method body +in this build, so the cooldown mark is a no-op here). + +**acdream**: `TryHealersHeart` referenced from +`VitalRechargePlanner.TryPlanHelper` (`acdream:VitalRecharge.cs:85-140`, +call at `~line 124-131`) is folded into the general helper-recharge +path rather than kept as a standalone rule, matching the *effect* (item +name gate + skill gate + fellow-under-threshold target) as far as +inspected; full field-by-field parity (the two skill thresholds, the +priority order between the two item names) was not re-verified against +the acdream implementation body in this pass. + +### 3.3 `RechargeOther` (`gu.cs`) and the fellow tracker (`ai.cs`) + +`gu.a()`/`gu.a(bool)` (`gu.cs:70-130`) build a `gh{a=HitP%, b=Stam%, +c=Mana%}` from `Recharge-Helper-HitP/Stam/Mana` and delegate to +`ai.b(gh)` (any fellow/tracked player below any threshold?) for +`ValidNow`, and `ai.a(gh)` (actually cast) for `Running`. `ai.a()` +private (`ai.cs:246-375`) computes the worst tracked percent across +**two pools**: + +- **Fellowship members** (`ai.l`, keyed by id), refreshed by the + server's fellowship-vitals broadcast (message type `63408`, event + `704`, `ai.cs:145-178`) gated on the fellowship window being open + (`this.m_a`, toggled by message `63409` action `166`, + `ai.cs:122-143`) and the character actually being in a fellowship + (`j`, tracked via `ChangeFellowshipEventArgs`, `ai.cs:180-244`). + Entries older than `ai.b` seconds (10.0 default field value) are + ignored (`ai.cs:268`). Distance-gated per vital by + `HelperDistanceHitP/Stam/Mana` (`ai.cs:290,296,302`). +- **Non-fellowship tracked players** (`ai.m`), aged out after 2 + **minutes** of no update (`ai.cs:314-322`), entries considered + within `ai.c` seconds (10.0 default), each with its own **per-vital + 5-second (`ai.d`) re-cast cooldown** keyed by which vital was last + cast on that target (`ai.cs:354,360,366` checking `a11.i` against + the vital and `timeSpan2 < d`). + +Target spell family is **fixed**, not the profile's choice: Health → +`Adja's Gift`, Stamina → `Replenish`, Mana → `Gift of Essence` +(`ai.cs:81-83`), each resolved to the caster's best current-castable +tier via `e.i.c(spell)` (`fk.c`, `ai.cs:456-465`) before casting. + +**acdream** (`acdream:VitalRecharge.cs:85-200`ish, +`TryPlanHelper`/`Lowest`/`TryFindFamily`): same three fixed base spell +families (`SpellId.AdjaSGift`, `.Replenish`, `.GiftOfEssence`, +`VitalRecharge.cs:114-118`), same per-vital distance settings +(`HelperHealthDistance` etc.), same fellowship-membership gate +(`settings.HelpOthers && automation.Fellowship.IsInFellowship`, +`VitalRecharge.cs:102-106`). The non-fellowship "track any nearby +player's broadcast vitals and help them too" pool (`ai.m`) was not +located in the acdream implementation inspected here — flagged as a +possible gap (§5 G3) pending a closer read of `Fellowship`/tracked- +player plumbing outside the six named files. + +### 3.4 `DispelAllies` (`af.cs`) + +Requires setting `UseDispelDrum`, an inventory item named `"Awakener"` +or `"Attenuated Awakener"` (`af.cs:78-85`), and per-item skill gates: +`Awakener` needs `Item Enchantment` (`CharFilterSkillType 31`) +**Specialized** + `Healing` buffed ≥ 110; `Attenuated Awakener` accepts +Trained *or* Specialized Item Enchantment, same Healing floor +(`af.cs:87-105`). Target selection scans `ai.m` (the same tracked-player +pool as §3.3, but requiring an active *fellowship-visible* relation +via `dz.j.b(id, spell) > TimeSpan.Zero` and presence in `dz.j.b`, +`af.cs:112-116`) for the fellow whose **currently active vuln stack** +(all 7 elements' `Vuln` family, difficulty ≤ 350, quality-summed only +above 250 per element) is worst (`af.cs:117-141`), then uses the drum +item on them (`PluginCore.dz.aa.a(itemId, targetId)`). + +**acdream**: not investigated beyond confirming +`DispelController.cs:93` gates on `UseDispelDrum` and calls +`TryStartAllyDispel`; the vuln-severity target-scoring logic was not +diffed line-for-line against `af.cs:117-141` in this pass. + +## 4. Vitals + +### 4.1 The trigger (`cr.cs` / `cg.cs`) + +`cr` ("RechargeSelf2") is registered **three times** in `cLogic.cs` +(main position before `BuffSelf`, using `Recharge-Norm-*`; idle +position after target-approach, using `Recharge-NoTarg-*`; +`cLogic.cs:467,558`) — helper recharge (`gu`, §3.3) is the analogous +rule for `Recharge-Helper-*`. Gate: `ItemUse` lock free, then any of +`cg.c(healthSetting) || cg.b(stamSetting) || cg.a(manaSetting)` +(`cr.cs:75-91`) — note the deliberate **Health > Stamina > Mana** +short-circuit priority both in the gate and in dispatch +(`cr.cs:120-133`: Health checked first, Stamina second, Mana is the +`else` fallback). `cg.c/b/a` (`cg.cs:36-97`) each: read the setting as +a percent, compare against `100*current/max` for that vital, subtracting +`RechargeBoostAmount` first if the matching `RechargeLevelBoost_HP/ +Stam/Mana` action lock is armed (armed for `RechargeBoostTimeSeconds` +by `he.cs:49,65` whenever a recharge cast/use hasn't yet landed — +§4.4) — i.e. **the boost temporarily lowers the effective reading** so +`cr` keeps re-firing (looking urgent) while a recharge action is still +in flight, rather than a boost that raises urgency intrinsically. + +Default thresholds (`uTank2.Resources.defaultsettings.usd:203-267`): + +| Setting | Default % | +|---|---| +| `Recharge-Norm-HitP` | 75 | +| `Recharge-Norm-Stam` | 50 | +| `Recharge-Norm-Mana` | 50 | +| `Recharge-NoTarg-HitP` | 1 | +| `Recharge-NoTarg-Stam` | 1 | +| `Recharge-NoTarg-Mana` | 1 | +| `Recharge-Helper-HitP` | 20 | +| `Recharge-Helper-Stam` | 1 | +| `Recharge-Helper-Mana` | 1 | + +(`NoTarg` = idle recharge, effectively "only recharge idly once nearly +empty"; `Norm` = the in-combat/normal-stance thresholds, much more +eager.) + +### 4.2 The handler-set table (`cRechargeManager.cs`) + +Seven registered `IRechargeHandler`s (`cRechargeManager.cs:26-36`): +`m` (Regular Spell), `bn` (Health→Stamina), `hc` (Health→Mana), `c2` +(Mana→Health), `f2` (Stamina→Health), `a5` (Kit Recharge), `g4` +(Recharge With Food). Per (vital, stance) the ordered candidate rows +come from the `RechargeHandlerSet` settings TABLE +(`uTank2.Resources.defaultsettings.usd`, `RechargeHandlerSet` block), +parsed row = `(Vital 1/2/3=HitP/Stam/Mana, HandlerString, MinPercent, +MaxPercent, Stance 1=Other/2=MagicMode)`. Default table (26 rows, +decoded from the `.usd` TABLE literal): + +| Vital | Stance | Ordered handlers (name, min–max %) | +|---|---|---| +| Health | Other | Stamina→Health (0–15), Mana→Health (0–15), Regular Spell (0–15), Recharge With Food (0–15), Kit Recharge (0–100), Stamina→Health (0–100), Mana→Health (0–100), Regular Spell (0–100), Recharge With Food (0–100) | +| Stamina | Other | Kit Recharge (0–100), Regular Spell (0–100), Recharge With Food (0–100) | +| Mana | Other | Kit Recharge (0–100), Recharge With Food (0–100), Regular Spell (0–100) | +| Health | MagicMode | Recharge With Food (0–15), Kit Recharge (0–100), Recharge With Food (0–100), Stamina→Health (0–10), Regular Spell (0–100) | +| Stamina | MagicMode | Kit Recharge (0–100), Recharge With Food (0–100), Regular Spell (0–100) | +| Mana | MagicMode | Kit Recharge (0–100), Recharge With Food (0–100), Regular Spell (0–100) | + +Dispatch (`cRechargeManager.Recharge`, `cRechargeManager.cs:47-78`): +filter rows to the current stance, then walk in table order; a row is +attempted if percent is in `[Min,Max]` **or it is the last row in the +list** (guaranteed fallback attempt), and the first `Activate()` that +returns `true` wins. `GetCurrentStance()` (`cRechargeManager.cs:193-205`) +is simply `CombatMode == Magic ? MagicMode : Other`. + +**acdream** (`acdream:VitalRecharge.cs:36-83`, `VitalRechargePlanner`) +explicitly documents itself as "VTank's default `RechargeHandlerSet`, +including its stance- and current-percentage-dependent order" +(`VitalRecharge.cs:36-40`) and takes a `VitalRechargeMethod[]` per +(vital, magic-mode-bool, percent, table) — structurally the same +design as `cRechargeManager`; the concrete default table values were +not independently re-verified cell-by-cell against the table above in +this pass (§6). + +### 4.3 The seven handlers + +| Handler | Cast/use logic | Key gates | Cite | +|---|---|---|---| +| `m` (Regular Spell) | Health→`Adja's Intervention`, Stamina→`Robustification`, Mana→`Meditative Trance`; picks best of the named spell vs. the caster's best current tier via `he.a(name,out,out)` | spell known only | `m.cs:47-85` | +| `bn`/`hc`/`c2`/`f2` (`dg` base) | vital-transfer spells named "X to Y Self" | non-Health→Health direction additionally requires the *other* vital's own `Recharge-Norm-*` NOT be under threshold (`cg.a/b/c`, i.e. don't drain Stamina/Mana to top off Health if Stamina/Mana is itself already low); for the Health-restoring direction, estimates whether the transfer would net-gain HP via `he.a()`/`he.a(spell,vital)` before committing | `dg.cs:54-139` | +| `a5` (Kit Recharge) | picks the highest-suitability kit item classified `fs.a/c/e` (Health/Stam/Mana Kit) meeting `MinimumHealKitSuccessChance`; if `Recharge-Norm-Stam` (Healing skill, `CharFilterSkillType 21`) not Trained/Specialized → unusable; blocked in Magic mode unless `UseKitsInMagicMode`; requires ≥15 Stamina except for Stamina kits themselves | `GoToPeaceModeToUseKits`: if set and not currently at Peace, **switches to Peace and returns (does not use the kit this call)** — the kit use happens on a later tick once Peace is confirmed | `a5.cs:49-128` | +| `g4` (Recharge With Food) | picks a non-cooling-down food/potion item classified `fs.b/d/f` (Health/Stam/Mana Food); if the item has a cooldown and the character is busy, waits; otherwise uses it | none beyond item availability | `g4.cs:48-91` | + +`fs` item classification (`uTank2/PluginCore.cs:7445-7602`): built at +identify time from `WorldObject.Values`/`ObjectClass`/name; Health/ +Stam/Mana **Food** (`fs.b/d/f`) comes from `LongValueKey 89 == +2/4/6`; named Medicated/Eternal/Greater/Lesser Stamina/Mana **Kits** +map to `fs.c/e`; a generic Health Kit falls to `fs.a`; +`ObjectClass == 16` with `DoubleValueKey 137 != 1.0` → `fs.i` (the +worn-item mana-charge consumables, §4.5) vs. `== 1.0` with charge +fields present → `fs.g`. + +**acdream** (`acdream:VitalRecharge.cs`, `VitalRechargeMethod` enum +`RegularSpell/StaminaToHealth/ManaToHealth/HealthToStamina/ +HealthToMana/Kit/Food`, `VitalRecharge.cs:25-34`) mirrors the same six +handler shapes plus the constant `HealingSkill = 21` (matching +`CharFilterSkillType 21`, `VitalRecharge.cs:43`); the item +classification rules (kit/food name-or-flag matching) were not +independently re-verified line-for-line against `PluginCore.cs: +7445-7602` in this pass. + +### 4.4 `GoToPeaceModeToUseKits` sequencing and vital-transfer sizing + +`a5.b()` line 121-125: `if (GoToPeaceModeToUseKits && CombatMode != +Peace) { f9.a(Peace); return true; }` — this is a **raw** peace switch +(the "Family A" open-coded drop-to-peace pattern from the trace note), +not routed through `ForceCombatMode`; it fires once per tick until +Peace lands, then the *next* `a5.b()` call actually applies the kit. + +`he.a(MySpell,eRechargeVital_Single)` (`he.cs:95-155`) estimates a +transfer spell's **effective HP delivered**: base current-vital reading +(minus a flat 30 for Mana, floored at 0, `he.cs:100-108`), times a +tier-dependent multiplier/cap by name suffix (`" I"`→0.9×/cap 50, +`" II"`→1.0×/cap 100, `" III"`→1.1×/cap 150, `" IV"`→1.2×/cap 200, +`" V"`/`" VI"`/`"Incantation of "`/other→1.35–1.75×/uncapped). Used by +the `dg` base handler (§4.3) to decide whether a transfer nets a +positive HP gain before committing, and by the "estimate Heal Self +cost/tier" helper `he.a()` (`he.cs:74-93`, a name→flat-cost lookup for +`Heal Self I..VI`/`Adja's Intervention`/`Incantation of Heal Self`, +default 10). + +**acdream**: not independently re-verified (no `he.cs`-equivalent +sizing table was located during this pass; flagged §6). + +### 4.5 Mana recharge of wielded/worn items (`dy.cs` / `a0.cs`) + +`a0` ("RefillWieldedMana") gate/dispatch (`a0.cs:60-112`): first tries +`dz.ac.h()`/`.i()` (the `dy` worn-item queue, below); only if that has +nothing does it fall back to `dz.ab.d()`/`.e()` (a `ManaStoneUse` +action-locked path, `ev` class — not traced in this pass). + +`dy` (§0) tracks, per **wear/wield event** (`dy.b(int itemId)`, +`dy.cs:284-289`, subscribed via the client's `g6.o` wield map), a FIFO +queue `dy.c` of newly-equipped items still below full charge (dropped +once `q.a(item, actions)` reports it no longer needs charge, +`dy.cs:292-303`) and a parallel list `dy.a`/`dy.b` of qualifying +**charge consumables** classified `fs.i` +(`ObjectClass==16 && DoubleValueKey 137 != 1.0`, i.e. "not a +usable-food flagged item" — the gems/food-panel mana charges), +partitioned by whether they're stacked (`item2.g()`) — rebuilt whenever +the profile changes (`dy.b()` private, `dy.cs:50-73`) by scanning +`PluginCore.dz.q.p[worn-item-name]`, i.e. **the charge candidates are +keyed to the specific worn item's own name**, not a global pool. +`h()` = "queue non-empty AND a matching charge item exists" +(`dy.cs:309-315`); `i()` = `ApplyItem(chargeItemId, dy.c[0])` — a +direct item-on-item apply, **oldest-queued worn item first**, not +sorted by current charge percent (`dy.cs:318-322`). + +**acdream** (`acdream:ItemManaRecharge.cs`): `ItemManaRechargePlanner. +Plan` (`ItemManaRecharge.cs:17-54`) instead (a) matches charge +consumables by a **global** `ItemType & ManaStoneItemType` flag plus a +configured name allowlist (`consumableNames`, not auto-derived from +`fs.i`'s per-worn-item keying), and (b) picks the **lowest current +mana-percent** equipped item under the configured threshold, not the +oldest-queued one. Flagged as gap G4 in §5: retail's charge-item +pool is scoped per *specific worn item name* (`q.p[name]`), so a charge +consumable that only works on item A is never offered for item B; +acdream's flat name-allowlist + generic item-type flag does not encode +that per-item scoping, which could mean either mismatching a charge to +an incompatible worn item or failing to find a match retail would have +found via the correct per-name bucket. + +### 4.6 Dispel self / dispel items + +`CastDispelSelf` (`c8.cs`): requires `q.d("Chorizite") != 0` +(`c8.cs:72-75` — `g6.d(string)` sums a `LongValueKey` stack-count field +over owned items whose "identified name" equals the given string, +`g6.cs:222-211`ish; **`"Chorizite"` is read here as a literal +in-inventory item name gate**, not a debuff-detection API — see §6 for +residual uncertainty on why owning an item named "Chorizite" gates a +dispel-self spell), then the known `Eradicate Life Magic Self` spell +and a difficulty-vs-active-vuln check (`c4.a(mySpell.Difficulty)`, +`c8.cs:76-85`). + +`UseDispelItems` (`cx.cs`): if the worst active vuln difficulty is +≥400 (`c4.a(400)`), tries `Rune of Dispel` → `Society Gem of +Dispelling` → `Black Market Gem of Dispelling`, in that priority +(`cx.cs:74-91`); else if ≥350 (`c4.a(350)`), tries `Rune of Dispel` → +`Chocolate Gromnie` → `Condensed Dispel Potion` → `Gem of Stillness` +(`cx.cs:92-114`) — i.e. pricier items are reserved for the +worse-vuln tier, cheaper ones cover the lesser tier. + +**acdream** (`acdream:DispelController.cs`): `TryStartSelfDispel` +(`DispelController.cs:108-157`) gates on the exact same literal +`"Chorizite"` item-with-stack check +(`DispelController.cs:115-117`) — a faithful (if unexplained) port, +not a divergence. `TrySelectDispelItem` +(`DispelController.cs:159-173`) reproduces the two-tier +400/350-difficulty item lists via `HasVulnerabilityAtOrBelow` + +`HighDifficultyItems`/`NormalDifficultyItems`. Not independently +re-verified item-for-item against `cx.cs:74-114` in this pass. + +## 5. MossTank gap ledger (ranked by likely player-visible impact) + +| # | Gap | Retail cite | acdream cite | Impact | +|---|---|---|---|---| +| **G1** | `BuffPlan.Build` snapshots skills/attributes/active-enchantments **once** per pass and queues fixed spell picks; retail's `eq.a()` re-reads skill/quality **every logic tick** (~293 ms) and re-picks fresh, so a mid-pass skill raise (e.g. casting a mastery buff) immediately unlocks a higher tier for the *next* candidate in the *same* pass. acdream cannot realize that cascade — it locks in tier choices from before the pass started. | `eq.cs:477-527` (fresh per call), `uTank2/cLogic.cs:145` (293 ms tick) | `BuffPlan.cs:104-213` (one `Build` call), `MossTankPanel.cs:3607-3698` (`BuildPlan` called once at pass start, never mid-pass) | **High** for any character whose unbuffed skill sits close to a tier's difficulty threshold — retail casts a stronger buff line than acdream will, specifically for spells downstream of the Creature-Enchantment-Mastery→Focus/Willpower→Item-Enchantment/Life-Magic dependency chain the acdream code's own comment (`BuffPlan.cs:305-336`) describes but the implementation doesn't dynamically realize. | +| **G2** | Retail auto-populates default enchant-spell candidates (auras / prots / banes) for a wand/shield/pet the **moment it's added to the profile**, keyed off the item's `LongValueKey 218103822` mastery mask (§2). No equivalent "profile item → default spell rows" pipeline was found in `BuffPlan.cs`/`BuffProfile.cs`; `BuffProfile.Build` only classifies the character's **own spellbook**. | `PluginCore.cs:8327-8445` | not located in the six named files | **High** if genuinely absent — this is how a fresh VTank user gets working weapon/shield enchants with zero configuration; if it lives elsewhere in the plugin it should be cited, otherwise it's a real feature gap for item-enchantment buffing specifically (as opposed to self-buffing, which G1 covers). | +| **G3** | Retail's helper-heal target pool includes **non-fellowship tracked players** (`ai.m`, populated from general server/client vitals echo dispatch, independent 5s-per-vital re-cast cooldown, 2-minute staleness) in addition to fellowship members. acdream's `TryPlanHelper` gates entirely on `settings.HelpOthers && automation.Fellowship.IsInFellowship` and was not seen to consult a non-fellowship tracked pool. | `ai.cs:246-375` (two pools, `l` fellowship + `m` non-fellowship) | `VitalRecharge.cs:94-140` (`TryPlanHelper`, fellowship-gated only) | **Medium** — narrows "help nearby strangers" to "help fellowship only"; likely intentional scoping (`DoHelp`'s own retail description even says "The fellowship window must be open to help fellows", `uTank2.Resources.defaultsettings.usd:499-503`, suggesting the non-fellow pool may be a secondary/legacy path) but worth a deliberate decision rather than an unnoticed drop. | +| **G4** | Worn-item mana-charge matching: retail keys charge candidates to the **specific worn item's own name** (`q.p[itemName]`) and drains the **oldest-queued** worn item first; acdream uses a **global** consumable-name allowlist + generic `ManaStoneItemType` flag and picks the **lowest-mana-percent** item first. | `dy.cs:50-73,309-322` | `ItemManaRecharge.cs:17-54` | **Medium** — could either apply an incompatible charge to a worn item that doesn't accept it, or simply pick a different (lower-%) target than retail would on the same tick; low severity in practice since AC mana-charge items are broadly compatible by charge-type, but the per-item keying is a real mechanism difference. | +| **G5** | `ba.cs`'s `RandomHelper` fires `ValidNow = true` on the *first* of up to 100 random draws that resolves to an **uncastable** spell (doing nothing that tick), and only returns `false` if all 100 draws happen to be castable (§3.1). acdream instead deterministically round-robins every (player × stem) pair and only fires on an actually-castable one. | `ba.cs:116-125,152` | `MossTankPanel.cs:4034-4068` | **Low/positive** — acdream's version helps allies strictly more often and more predictably than retail's likely-buggy sampling loop; flagged as a behavioral divergence worth a conscious call (replicate the retail quirk vs. keep the fix) rather than an oversight, since the retail behavior reads as unintended. | + +## 6. Could not determine + +- **`g6.d("Chorizite")`'s in-game meaning.** Confirmed mechanically — + it sums a `LongValueKey`-backed count over owned items whose + identified name equals the literal string `"Chorizite"` + (`g6.cs:222-231`) — and confirmed that acdream ports the same literal + gate (`DispelController.cs:115-117`). Not resolved: *why* owning an + item named "Chorizite" is the prerequisite for casting `Eradicate + Life Magic Self` at all (no corroborating string or comment found in + the traced files; may be a VTank-internal placeholder/detection + object rather than a "real" tradeable AC item, or may key off a + specific quest/consumable whose purpose isn't documented in this + build's strings). +- **Exact retail `RechargeHandlerSet` table values vs. acdream's + ported default table** — the 26-row table was fully decoded from + `uTank2.Resources.defaultsettings.usd` (§4.2), but acdream's own + default table in `VitalRecharge.cs` was not diffed cell-by-cell + against it in this pass. +- **`he.a`'s HP-yield sizing table vs. any acdream equivalent** — no + matching sizing/estimate function was located in + `VitalRecharge.cs` during this pass; may exist under a name not + grepped, or may be a genuine gap (vital-transfer handlers casting + without an HP-net-gain pre-check). +- **`af.cs`'s vuln-severity scoring vs. acdream's `DispelController` + ally-dispel target selection**, and **`fb.cs`'s exact two-skill/ + two-item-name gates vs. `TryHealersHeart`** — both confirmed present + in acdream at the call-site level (§3.2, §3.4) but not diffed + field-by-field against the retail source in this pass. +- **VTank's own `ba.cs` 100-draw loop**: whether the `g == null` / + `return true` branch (§3.1, §5 G5) is a genuine retail bug or + deliberate (e.g. "yield the tick so some other rule can act" — + though nothing downstream appears to rely on that), left unresolved; + quoted verbatim rather than guessed at.