docs(vt): VT1 catalog 05 — VTank loot flow and the Classic Looter .utl format

VTClassic's .utl blocks, all requirement types, EarlyMatch/NeedsID
identify-avoidance, ComputedItemInfo, salvage-combine extra block; VTank's
host-side corpse selection (rare-first, fo.cs:436-446), approach/open/loot
rules, the ownership-denial chat regexes (fo.cs:71-73), timeouts. MossTank
gap ranked. Lead spot-check of those three claims.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 19:48:33 +02:00
parent cf04897694
commit 58483a1a53

View file

@ -0,0 +1,824 @@
# VTank knowledge base 05 — looting and `.utl`
Research only. No code changes. Decompiled source is the oracle; everything
below cites `file:line` against:
- `refs/vtank-classiclooter/decompiled/VTClassic/*.cs` and
`VTClassic.UTLBlockHandlers/*.cs` — VTClassic, the canonical loot-rule
plugin that reads/writes `.utl`. Real class names throughout (not
obfuscated).
- `refs/vtank/decompiled/*.cs` — the VTank host (`uTank2`), an obfuscated
ILSpy decompile (short letter-coded classes: `fo.cs`, `d0.cs`, `a1.cs`,
`g8.cs`, `el.cs`, `cLogic.cs`, `PluginCore.cs`, …). Field/method names are
ILSpy's synthetic `a`/`b`/`c…` — semantics below were derived by reading
call graphs and cross-referencing `uTank2.Resources.defaultsettings.usd`
setting names/descriptions, never guessed from names.
- `refs/vtank/decompiled/uTank2.LootPlugins/*.cs` — the public plugin
contract VTank exposes to a loot plugin (`LootPluginBase`, `GameItemInfo`,
`LootAction`, `ObjectClass`, …), shipped in the same assembly as the
obfuscated host.
- `src/AcDream.Plugins.MossTank/{Looting.cs,MossTankLootProfileStore.cs,
VtankLootProfileSerializer.cs,VtankLootRequirementEvaluator.cs}` and
`src/AcDream.Plugin.Abstractions/{LootClassifierPlugins.cs,
LootAutomation.cs}` — acdream's port.
- `docs/research/2026-07-29-vtank-plugin-automation-requirements.md` §1.5 —
prior secondary research; cross-checked below, no factual disagreement
found (this doc goes materially deeper: the `NeedsID` early-decision
optimization, the `pri`-field non-use, the `KeyExistsInt`/`KeyExistsDouble`
buff gate, and the exact `fo.cs` rare/fellow/priority mechanics are new
here).
---
## 1. The `.utl` format, exactly as VTClassic reads/writes it
`.utl` is a plain-text, line-oriented format. Every write goes through
`CountedStreamWriter` (`VTClassic/CountedStreamWriter.cs:6-221`), a
`StreamWriter` subclass that counts UTF-16 characters written so
length-prefixed blocks can self-report their byte length
(`CountedStreamWriter.cs:8-10,27-43`); `WriteLine` uses the writer's
`NewLine` (platform default, `\r\n` on Windows — VTank shipped
Windows-only). Reading is unbuffered `StreamReader.ReadLine()` /
`.Read(char[],int,int)` calls, so the format is a strict token stream: get
the read order wrong and the file desyncs silently.
### 1.1 Header and version
`cLootRules.Read` (`VTClassic/cLootRules.cs:71-105`):
| Case | Detection | Consequence |
|---|---|---|
| Versioned (v1) | first line literal `"UTL"` (`cLootRules.cs:78`) | next line = `UTLFileVersion` (int, must be `<= 1` or throws, `cLootRules.cs:80-84`); next line = rule count |
| Legacy (v0) | first line is NOT `"UTL"` | `UTLFileVersion = 0` (`cLootRules.cs:89`); that first line IS the rule count |
`UTLVersionInfo.VersionHasFeature` (`VTClassic/UTLVersionInfo.cs:7-20`) gates
two v1-only features by `eUTLFileFeature`
(`VTClassic/eUTLFileFeature.cs:3-7`): `RuleExpression` (a per-rule
free-text "custom expression" line, editor-only — see §2) and
`RequirementLengthCode` (every requirement payload is prefixed with its own
character count, so an unrecognized requirement type can be skipped without
understanding its payload). `MAX_PROFILE_VERSION = 1`
(`UTLVersionInfo.cs:5`) — v1 is the only version VTClassic itself ever
wrote; `cLootRules.Write` always emits header `"UTL"` / version `1`
(`cLootRules.cs:111-112`).
The write path (`cLootRules.Write`, `cLootRules.cs:107-125`) always writes:
`"UTL"``1` → rule count → each rule (§1.2) → the extra-block manager
(§1.4).
### 1.2 Rule block structure
One rule = `cLootItemRule` (`VTClassic/cLootItemRule.cs:87-162`). Read
order (feature-gated):
| Line(s) | Field | Notes |
|---|---|---|
| 1 | `name` | rule display name |
| 2 (v1 only) | `CustomExpression` | free text; **VTClassic writes it but its `Match`/`Classify` never read it** — editor-only round-trip field, not executed (`cLootItemRule.cs:34-56` has no expression evaluator) |
| 3 | `pri;act;type0;type1;…` | `;`-split: `pri` (int, `Priority()`), `act` (int cast to `eLootAction`), then one int per requirement (`eLootRuleType`) |
| conditional | `LootActionData` | only if `act == KeepUpTo`: one line, the keep-count (`cLootItemRule.cs:101-104`) |
| per requirement | length + payload (v1) / fixed lines (v0) | v1: one line = char count, then that many raw characters read via `inf.Read(char[],0,num)` (`cLootItemRule.cs:110-124`); v0: no length prefix — `iLootRule.Read` consumes its own fixed line count directly (`cLootItemRule.cs:125-129`) |
`LootRuleCreator.CreateLootRule` (`VTClassic/LootRuleCreator.cs:5-43`) maps
each `eLootRuleType` int to its class; an unrecognized type under v1 becomes
a `cUnsupportedRequirement` that stores the raw payload bytes verbatim and
re-emits them unchanged on write (`VTClassic/cUnsupportedRequirement.cs:1-49`)
— this is VTClassic's own forward-compatibility mechanism for a requirement
type added by a newer VTClassic build. Under v0 there is no such
mechanism: an unknown type throws (`cLootRules.cs:83` equivalent path is
absent for v0 — `LootRuleCreator` returning `null` with no length code
means the rule cannot be safely skipped).
### 1.3 Requirement types (`eLootRuleType`, `VTClassic/eLootRuleType.cs:3-37`)
| Value | Type | Read payload (line order) | `Match` semantics | `MayRequireID` |
|---|---|---|---|---|
| `-1` | `UnsupportedRequirement` | raw byte blob (length-prefixed) | always `false` (never matches; exists to preserve unknown data) | `false` |
| `0` | `SpellNameMatch` | regex | any item spell name matches regex (`SpellNameMatch.cs:27-38`) | `true` |
| `1` | `StringValueMatch` | regex, `StringValueKey` | regex matches `GetValueString(vk)` (`StringValueMatch.cs:34-38`) | `IsIDProperty(vk)` |
| `2` | `LongValKeyLE` | int keyval, `IntValueKey` | `GetValueInt(vk) <= keyval` | `IsIDProperty(vk)` |
| `3` | `LongValKeyGE` | int keyval, `IntValueKey` | `GetValueInt(vk) >= keyval` | `IsIDProperty(vk)` |
| `4` | `DoubleValKeyLE` | double keyval, `DoubleValueKey` | `(float)GetValueDouble(vk) <= (float)keyval` | `IsIDProperty(vk)` |
| `5` | `DoubleValKeyGE` | double keyval, `DoubleValueKey` | `(float)GetValueDouble(vk) >= (float)keyval` | `IsIDProperty(vk)` |
| `6` | `DamagePercentGE` | double keyval | **retired — `Match` unconditionally returns `false`** (`DamagePercentGE.cs:26-29`); `EarlyMatch` always reports a decided non-match | `false` |
| `7` | `ObjectClass` | `ObjectClass` enum | `item.ObjectClass == vk` | `false` |
| `8` | `SpellCountGE` | int keyval | `item.Spells.Count >= keyval` | `true` |
| `9` | `SpellMatch` | matchRegex, excludeRegex, count | count of spells matching `matchRegex` and (if excludeRegex non-blank) not matching it, `>= count` (`SpellMatch.cs:35-52`) | `true` |
| `10` | `MinDamageGE` | double keyval | `Damage - DamageVariance*Damage >= keyval` (min roll of the damage range) | `true` |
| `11` | `LongValKeyFlagExists` | int keyval, `IntValueKey` | `(GetValueInt(vk) & keyval) > 0` | `IsIDProperty(vk)` |
| `12` | `LongValKeyE` | int keyval, `IntValueKey` | `GetValueInt(vk) == keyval` | `IsIDProperty(vk)` |
| `13` | `LongValKeyNE` | int keyval, `IntValueKey` | `GetValueInt(vk) != keyval` | `IsIDProperty(vk)` |
| `14` | `AnySimilarColor` | R,G,B,maxHueDiff,maxSVDiff | any item palette's HSV within `maxHueDiff`/`maxSVDiff` of the target color (`AnySimilarColor.cs:31-50`) | `false` |
| `15` | `SimilarColorArmorType` | R,G,B,maxHueDiff,maxSVDiff,ArmorGroup name | same HSV test restricted to the palette-slot indices in `ColorXML.SlotDefinitions[ArmorGroup]` (`SimilarColorArmorType.cs:67-92`, XML loaded from `ColorSlots.{Default,User}.xml` next to the plugin DLL or the Decal registry `ProfilePath`, `ColorXML.cs:26-97`) | `false` |
| `16` | `SlotSimilarColor` | R,G,B,maxHueDiff,maxSVDiff,slot index | HSV test on one fixed palette slot | `false` |
| `17` | `SlotExactPalette` | slot, palette id | `(palette & 0xFFFFFF) == (target & 0xFFFFFF)` on one fixed slot (low 24 bits only — masks out the high palette-template byte) | `false` |
| `1000` | `CharacterSkillGE` | int keyval, `VTCSkillID` | live character's **buffed** skill (`ISkillInfo.Buffed`) `>= keyval`, read via COM `CharacterFilter.Underlying[eSkillID]` (`CharacterSkillGE.cs:32-47`) | `false` |
| `1001` | `CharacterMainPackEmptySlotsGE` | int keyval | `102 - (count of own items with no container-capacity AND not ObjectClass Container(10)/Foci(38))` `>= keyval` (`CharacterMainPackEmptySlotsGE.cs:28-47`) | `false` |
| `1002` | `CharacterLevelGE` | int keyval | `CharacterFilter.Level >= keyval` | `false` |
| `1003` | `CharacterLevelLE` | int keyval | `CharacterFilter.Level <= keyval` | `false` |
| `1004` | `CharacterBaseSkill` | `VTCSkillID`, minskill, maxskill | live character's **base** skill (`ISkillInfo.Base`) in `[min,max]` | `false` |
| `2000` | `BuffedMedianDamageGE` | double keyval | `ComputedItemInfo.BuffedAverageDamage >= keyval` (median of buffed min/max roll) | `true` |
| `2001` | `BuffedMissileDamageGE` | double keyval | `ComputedItemInfo.BuffedMissileDamage >= keyval` | `true` |
| `2003` | `BuffedLongValKeyGE` | double keyval, `IntValueKey` | `ComputedItemInfo.GetBuffedLogValueKey(vk) >= keyval` | `true` (hardcoded, ignores `IsIDProperty`) |
| `2005` | `BuffedDoubleValKeyGE` | double keyval, `DoubleValueKey` | `(float)ComputedItemInfo.GetBuffedDoubleValueKey(vk) >= (float)keyval` | `true` (hardcoded) |
| `2006` | `CalcdBuffedTinkedDamageGE` | double keyval | `ComputedItemInfo.CalcedBuffedTinkedDamage >= keyval` (buffed damage plus the retail tinker-iron/granite auto-imbue simulation, §2.2) | `true` |
| `2007` | `TotalRatingsGE` | double keyval | sum of gear-rating `IntValueKey`s `370,371,372,373,374,375,376,379 >= keyval` | `true` |
| `2008` | `CalcedBuffedTinkedTargetMeleeGE` | 3 doubles: target DoT, target melee-defense bonus, target attack bonus | tinker-point simulation that spends points on defense/attack/damage in that priority order until all three targets are met or points run out (`CalcedBuffedTinkedTargetMeleeGE.cs`, `ComputedItemInfo.CanReachTargetValues`, §2.2) | `true` |
| `9999` | `DisabledRule` | bool `b` (`"true"`/`"false"` string) | `Match` returns `!b`**when `b==true` (disabled) the rule can never match**; this is how VTClassic represents a disabled requirement inline rather than deleting it | `false` |
`EarlyMatch` (used only by `NeedsID`, §2.3) mirrors `Match` for every
ID-independent type (character-state and color rules decide immediately;
`hasdecision=true`); every ID-dependent type (`GameInfo.IsIDProperty` true,
plus the hardcoded-`true` buffed family, plus `SpellCountGE`/`SpellMatch`/
`SpellNameMatch`, which gate on the item's own "identified" flag
`IntValueKey 218103824` bit `1`, e.g. `SpellCountGE.cs:31-43`) reports
`hasdecision=false` until ID data exists.
### 1.4 The extra-block manager
`UTLFileExtraBlockManager` (`VTClassic/UTLFileExtraBlockManager.cs:11-107`)
is a second, independent length-prefixed key/value stream appended after
the rule list: `blockType` line, `length` line, `length` raw characters.
One handler type is registered today,
`UTLBlock_SalvageCombine` (`BlockTypeID => "SalvageCombine"`,
`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:28`); an unrecognized block
type is skipped by raw character count (`UTLFileExtraBlockManager.cs:83-87`)
— the same forward-compat pattern as `cUnsupportedRequirement`.
`CreateDefaultBlocks` (`UTLFileExtraBlockManager.cs:53-56`) always ensures a
`SalvageCombine` block exists even for a profile written before the block
existed, seeded with VTClassic's built-in defaults
(`UTLBlock_SalvageCombine.cs:30-59`: `DefaultCombineString = "1-6, 7-8, 9,
10"`, plus a fixed override table of 24 named gem/leather/ivory materials →
`"1-10"`, resolved through `GameInfo.GetMaterialID`).
`UTLBlock_SalvageCombine.Read/Write` (`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:194-236`):
internal format version (`1`), `DefaultCombineString`, count + (material
id, combine-string) pairs, then — **only if the stream has more data**
(`if (!inf.EndOfStream)`, line 207) — count + (material id, value-mode
target) pairs. This trailing section is itself an undocumented-but-present
forward-compat gate: a `.utl` written by an older VTClassic build that never
had "value mode" simply omits it, and `Read` tolerates that.
`ChooseBagsToCombine`/`TryCombineMultiple`
(`UTLBlockHandlers/UTLBlock_SalvageCombine.cs:124-192`) buckets same-material
bags by workmanship range (parsed by `ParseCombineSting`, comma/semicolon
separated `a-b` or single-value tokens; `GetRangeIndex` returns the bucket
index, or `-1`/`Count` for out-of-range), then per bucket: if a
`MaterialValueModeValues` target exists for that material, sums
`IntValueKey 19` (Value) across the bucket and returns the whole bucket if
it meets the target, else randomly probes 12 pairs whose summed
`IntValueKey 92` (Structure) is `< 100` and returns the first such pair; if
no value-mode target, greedily accumulates bags (by loop order, not sorted)
until summed `IntValueKey 92 >= 100` and returns that prefix.
### 1.5 Encoding and the `--` default-profile naming convention
VTClassic itself has no hardcoded `.utl` string anywhere — the extension is
plugin-declared: `LootCore.Startup` returns
`new LootPluginInfo("utl", new string[0])`
(`VTClassic/LootCore.cs:167-180`; `LootPluginInfo` ctor at
`uTank2.LootPlugins/LootPluginInfo.cs:9-20` lower-cases and strips a leading
dot). The VTank host has no `".utl"` literal anywhere in
`refs/vtank/decompiled` either — profile-directory listing is fully generic:
`PluginCore.aa()` (`uTank2/PluginCore.cs:7130-7154`) iterates
`dz.ah.a()` (the set of extensions every *registered* loot plugin declared)
and calls `Directory.GetFiles(dq, "*." + item)` per extension. So a
different loot plugin (Alinco3/GearFoundry, cited in the prior research doc)
could ship its own extension through the same seam.
The **hidden per-character default profile** follows a naming convention
shared across all four profile families (settings `.usd`, nav `.nav`, meta
`.met`, and by the identical pattern loot `.utl`): on character login,
VTank sets the "current" file name to
`"--" + CharacterFilter.Name + "_" + CharacterFilter.Server + ".<ext>"`
(`uTank2/PluginCore.cs:3863-3866` shows this exactly for `.usd`/`.nav`/`.met`
— there is no `.utl` line in that block, consistent with §1's finding that
the host never hardcodes the loot extension there; the load/save calls for
loot profiles go through the generic `GetLootProfile`/`LoadLootProfile`
API, `PluginCore.cs:394-404`, which defers to whatever the loaded loot
plugin's `LootPluginInfo` declared). Every profile-directory listing
excludes filenames starting with `"--"` from the visible dropdown
(`PluginCore.cs:7020,7072,7144,7182`) — the per-character default is a
*hidden* file, edited implicitly by "the current profile," never listed by
name. MossTank's own `ByCharacter = "By char"` sentinel
(`MossTankLootProfileStore.cs:14`) mirrors VTank's own `"[By char]"` /
`"[None]"` dropdown entries (`PluginCore.cs:7134,7174`,`7215`) by design,
not by coincidence — same UX shape, different storage keys (MossTank keys
its "by character" document off `_characterName` through
`MossTankLootProfileStore.ProfileKey(...,byCharacter:true)`
`MossTankLootProfileStore.cs:284-291`, a SHA-256'd host-storage key rather
than a `--Name_Server.utl` file on disk).
---
## 2. Rule evaluation
### 2.1 Order: first-match-wins, list order — NOT the `pri` field
`cLootRules.Classify` (`VTClassic/cLootRules.cs:22-36`) is a plain
`foreach (cLootItemRule rule in Rules)`; the **first** rule whose `Match`
returns true wins, returning that rule's `Action()`/`LootActionData`/name.
**The per-rule `pri` field (`cLootItemRule.Priority()`,
`cLootItemRule.cs:14,24-27`) is read from and written to the file
(`cLootItemRule.cs:99,138`) but is never consulted by `Classify`, `Match`,
or `NeedsID`.** Nothing in `VTClassic/*.cs` calls `Priority()` outside the
getter itself. It is a persisted, round-tripped, *unused-at-classification*
field — most plausibly an editor-only display/sort aid inherited from an
earlier VTClassic UI. Evaluation order is 100% determined by the rules'
position in the file/list.
### 2.2 `ComputedItemInfo` — how buffed/calced values are derived
`ComputedItemInfo` (`VTClassic/ComputedItemInfo.cs:7-249`) wraps one
`GameItemInfo` and adds spell-aware derived values:
- **`GetBuffedLogValueKey(IntValueKey)`** / **`GetBuffedDoubleValueKey(DoubleValueKey)`**
(`ComputedItemInfo.cs:192-248`): **only computed if the base key already
exists on the item** (`KeyExistsInt`/`KeyExistsDouble` gate,
`ComputedItemInfo.cs:205,234` — if the item has no base value for that
key at all, the buffed value is just the caller's default, spell bonuses
are **not** added). If the key exists, the raw value is summed
(int) or changed (double: additive unless the static table's `Change==1`,
in which case multiplicative, `ComputedItemInfo.cs:244`) with a bonus from
a hardcoded spell-id → (key, bonus) table seeded in the static
constructor (`ComputedItemInfo.cs:88-139` — Impen/Blood Drinker/Heart
Seeker/etc. tiers by spell id, e.g. spell `1616``+20` to
`IntValueKey 218103842` = Damage).
- **`BuffedAverageDamage`** (`ComputedItemInfo.cs:36-45`): median of the
buffed max damage and its variance-adjusted min
(`max - variance*max`, averaged with max — same formula VTClassic exposes
as rule type `2000`).
- **`CalcedBuffedTinkedDamage`** (`ComputedItemInfo.cs:47-77`): simulates
the retail iron/granite tinker-imbue choice. Available tink count =
`max(10 - IntValueKey(171), 0)`, minus 1 if `IntValueKey(179)==0`
(untinkerable-material guard), forced to `0` if `IntValueKey(131)==0`
(no material at all). For each available tink, compares
`CalculateDamageOverTime(dmg+25, variance)` (an "iron"-style +1-damage
imbue) against `CalculateDamageOverTime(dmg+24, variance*0.8)` (a
"granite"-style -20%-variance imbue) and greedily takes whichever yields
higher expected damage-over-time
(`CalculateDamageOverTime(maxDamage,variance,critChance=0.1,critMultiplier=2.0)
= maxDamage*((1-critChance)*(2-variance)/2 + critChance*critMultiplier)`,
`ComputedItemInfo.cs:182-190`).
- **`CanReachTargetValues`** (rule `2008`, `ComputedItemInfo.cs:141-180`):
same tink-count computation, but each simulated tink point is spent in
fixed priority order — melee-defense bonus first (if below target,
`+0.01`), then attack bonus (if below target, `+0.01`), then damage
(same iron/granite choice as above) — until all three targets are met or
points run out; final result is whether all three targets were reached.
- **`TotalRatings`** (`ComputedItemInfo.cs:79`): flat sum of eight gear
rating `IntValueKey`s (`370``376`,`379` — skips `377`/`378`).
- **`BuffedMissileDamage`** (`ComputedItemInfo.cs:81`):
`BuffedLog(Damage) + (BuffedDouble(DamageVariance-ish key 167772174) - 1)*100/3 + BuffedLog(204)`.
### 2.3 What must be identified before evaluation — `NeedsID`/`EarlyMatch`
`LootPluginBase.DoesPotentialItemNeedID`
(`uTank2.LootPlugins/LootPluginBase.cs:25`) → VTClassic's
`LootCore.DoesPotentialItemNeedID` (`VTClassic/LootCore.cs:36-51`): if the
item already `HasIDData` returns `false` immediately; otherwise delegates to
`cLootRules.NeedsID` (`cLootRules.cs:38-64`), a single forward pass over the
rule list:
```
flag = false; lastUndecidedAction = NoLoot
for each rule in order:
if flag AND rule.act != lastUndecidedAction: return true // needs ID
rule.EarlyMatch(item, out hasdecision, out ismatch)
if hasdecision AND ismatch: return false // decided, no ID needed
if !hasdecision: flag = true; lastUndecidedAction = rule.act
return flag // true if ANY rule was undecidable and none matched first
```
This is a genuine optimization, not a naive "identify everything": a rule
that can decide (or definitely reject) an item without ID data short-
circuits immediately; a later ID-independent rule with the **same** action
as an earlier undecidable rule also short-circuits (the outcome is the same
either way, so identifying doesn't change the classification); only a
later rule with a **different** action than a still-open earlier rule
forces `NeedsID => true`, because the true first-match answer might still
be that earlier (as-yet-undecidable) rule once ID data exists.
`cLootItemRule.AnyReqRequiresID`/`EarlyMatch`
(`cLootItemRule.cs:34-44,58-85`) apply the same "AND of requirements, but
short-circuit on a definite non-match" logic per-rule that `Match` does.
### 2.4 The result contract VTank consumes
`LootPluginBase.GetLootDecision` (abstract,
`uTank2.LootPlugins/LootPluginBase.cs:27`) → `LootCore.GetLootDecision`
(`VTClassic/LootCore.cs:53-91`): calls `cLootRules.Classify`, maps
`eLootAction` (VTClassic's own internal enum) to the public
`uTank2.LootPlugins.LootAction` (a separate, parallel enum — same ordinal
values 0-10, `LootAction.cs:1-81`), stamping the matched rule's name onto
`LootAction.RuleName`. `LootAction` itself is a closed factory type (ctors
`internal`) exposing static singletons (`NoLoot`,`Keep`,`Salvage`,`Sell`,
`User1..5`) plus `GetKeepUpTo(maxcount)`; `IsRead`/`LootAction.Read` are
`internal` — **a third-party loot plugin cannot construct a `Read` action
at all**, only VTClassic itself (in the same assembly) can; every other
plugin's "read" support would have to reuse `User1..5`. `GameItemInfo`
(`uTank2.LootPlugins/GameItemInfo.cs:7-279`) is the read side of the
contract: `ObjectClass`, `HasIDData`, `Id`, `Spells`/`ItemSpell` (resolved
through the plugin core's spell cache, not the raw wire spell-id list),
`Palettes` (lazily built from an internal `bb` struct exposing
`Palette`/`Offset`/`Length`/`ExampleColor`), and typed key accessors
(`GetValueInt/Quad/Bool/String/Double` + `KeyExists*`) backed by an
internal `fu` item object's five per-type dictionaries.
`ILootPluginCapability_SalvageCombineDecision2.ChooseBagsToCombine` is a
capability interface (`uTank2.LootPlugins/ILootPluginCapability_SalvageCombineDecision2.cs`)
VTClassic implements (`LootCore.cs:194-198`) so the host can ask the loot
plugin itself which bags to combine, forwarding to
`UTLBlock_SalvageCombine.TryCombineMultiple` (§1.4) — the combine decision
lives in the profile, not in host code.
---
## 3. VTank's own loot flow
VTank (`uTank2`) does corpse tracking, approach, open, and the
open/close/blacklist state machine itself; it calls into the loaded loot
plugin (VTClassic) only for the per-item classification decision (§2.4).
Everything below is host-side (`refs/vtank/decompiled`, obfuscated).
### 3.1 Corpse tracking — `fo` (`refs/vtank/decompiled/fo.cs`)
One `fo` instance owns a `MyDictionary<int, fo.a>` (`fo.cs:59`, field `e`)
keyed by corpse object id. Per-corpse state (`fo.a`, `fo.cs:10-49`):
`a`=released-from-view, `b`=done/looted, `c`=first-seen timestamp (set once
at creation, never bumped — the "age" clock for the public/fellow timers),
`d`=last-seen/re-touched timestamp (the clock `CorpseCacheTimeoutMinutes`
actually measures against), `e`=IsGeneratedRare, `f`=parsed killer name,
`g`=last "ownership denied" chat timestamp, `h`=is-my-own-death-corpse
(unused in the eligibility scan itself), `i`=long-description-processed
flag, `j`=open-attempt counter, `k`=blacklisted-since timestamp.
**Detection** (`WorldFilter.CreateObject` handler, `fo.cs:157-190`): fires
only for `ObjectClass == 27` (`Corpse` — matches
`uTank2.LootPlugins.ObjectClass.Corpse`, ordinal 27,
`uTank2.LootPlugins/ObjectClass.cs:32`). GUID-reuse defense: if the same
object id is already tracked but its new 2-D position differs from the
cached position by more than `0.004167` VTank distance units (~1 m at the
~240 m/unit conversion inferred in
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md`), the stale entry is
evicted and replaced (`fo.cs:167-170`) — the server recycled the object id
for an unrelated corpse. On genuine re-creation of an already-tracked id,
only `d` (last-seen) is bumped and `a` (released) cleared — `c` (first-seen
age) is untouched.
**Long-description parsing** (`WorldFilter.ChangeObject` on `StringValueKey
16`, `fo.cs:192-252`): regex `"(?:Killed by )([a-zA-Z\ \-\']*)(?:\..*)"`
extracts the killer name; a second regex requiring a trailing
`[gG]enerated` sets the rare flag `e=true`. A third regex,
`"([a-zA-Z\ \-\']*)\'s ([^\']*)"`, re-parses the extracted killer string
for a possessive form (a combat pet's name reads as `"Owner's Petname"`);
if it matches and the possessive owner is the local character, the killer
is rewritten to the local character's own name; else, if fellowship data is
available (`dz.aj.b()`), the fellow roster is scanned for a member matching
that owner name and the killer is rewritten to the matched member's full
name — kills by your own or a fellow's **combat pet** are attributed to the
owner. If the long description has **no** `"Killed by "` match at all
(non-monster corpse, or one killed by nothing recognizable), `f=""` and
**`e` (rare) is forced `true`** (`fo.cs:243-245`) — a permissive default so
`LootOnlyRareCorpses` doesn't silently skip a corpse VTank can't classify,
not a literal "this corpse drops a rare."
**Chat-driven ownership denial** (server message type `63408`, event `747`,
matched against `"...already in use by someone else!"` or `"You do not yet
have the right to loot..."`, `fo.cs:71-73,314-317`): records `g=Now` for
the currently-targeted corpse `i`; the eligibility scan (below) skips any
corpse denied within the last 10 seconds. **MossTank has no equivalent
chat-text listener** — see §4.
**Cache eviction** (`fo.cs:107-155`, on a `StartupComplete`/timer-poked
event with a bound `ey` rate-limiter fired every 30841 ms,
`fo.cs:57,84-93`): an entry is only removed once **both**
`a` (released from view) is true **and**
`(Now - d).TotalMinutes >= CorpseCacheTimeoutMinutes` (default 60,
`uTank2.Resources.defaultsettings.usd:667-671`) — a corpse still in view is
never evicted no matter how old.
### 3.2 Eligibility and selection — `fo.a(double maxRange, bool metric)` (`fo.cs:384-453`)
Per candidate, in order, `continue` (skip) if: already `b` (done), `a`
(released), denied within 10 s (`g`), or currently blacklisted
(`k` within `BlacklistCorpseOpenTimeoutSeconds`, default 200,
`defaultsettings.usd:1067-1071`); then compute distance
(`f9.a(key, CharacterFilter.Id, true)`) and skip if `> maxRange`; then skip
if `!i` (long description not yet processed) or (`!e` and
`LootOnlyRareCorpses`); then the ownership gate:
| Killer | Rule |
|---|---|
| Me | always eligible |
| Not me, corpse `e` (rare) | **always skipped** — VTank never crosses ownership on a rare corpse, at any age, regardless of `LootAllCorpses`/`LootFellowCorpses` |
| Not me, killer matches a fellow roster entry | requires `LootFellowCorpses` AND (that member's `ShareLoot`-style flag OR corpse age `>= 100 s`) |
| Not me, no fellow match (a stranger's kill) | requires corpse age `>= 100 s` AND `LootAllCorpses` |
Selection among the remaining eligible set (`fo.cs:436-446`) **strictly
prefers any rare corpse over any non-rare corpse regardless of distance**:
the first rare corpse found becomes the running best pick; once a rare pick
exists, only a *closer* rare corpse can replace it; only in the absence of
any rare pick does plain nearest-distance selection apply.
### 3.3 Approach — `g8`/`fd`, `CorpseApproachRange-Min/Max`
The corpse-approach step is `g8 : ILogicRule` (`g8.cs:7-157`,
`FriendlyName` = `"Navigate"` — a GENERIC wrapper, reused for corpse
approach, monster approach, and route navigation alike), wrapping the same
`fd` close-in mover already documented in
`refs/vtank/notes/2026-09-06-idlepeace-fcm-trace.md` for its
peace-mode-creep behavior. For corpses it is constructed as
`new g8(0, "CorpseApproachRange-Min", "CorpseApproachRange-Max",
new fg("CorpseApproachRange-Max"))` (`cLogic.cs:492,535`): `fd` looks up
the min/max range from those two setting names (`fd.f()`,
`fd.cs:400-412`), and `fg` (friendly name `"CorpseApproach (...)"`,
`fg.cs:150`) is the `bz`-family target descriptor supplying the corpse's
live position — the SAME `bz` abstraction used for monster approach (`eb`,
`"MonsterApproach (...)"`, `cLogic.cs:559`) and route navigation (`ca`,
`cLogic.cs:507,569`), just with a different concrete descriptor.
`g8.b()` (ValidNow) bails immediately if `PluginCore.dz.o.s` (the "waiting
on corpse ID" flag, next paragraph) or `EnableNav` is off, or the
Navigation/`SpreadLockTargetRequested`/`DoorOpening` action locks are held
(`g8.cs:81-103`).
`SettingDelegate_SetWaitingOnCorpseId`
(`uTank2.Logic/SettingDelegate_SetWaitingOnCorpseId.cs:5-48`) resolves what
`CorpseApproachRange-Max = 0` (the shipped default,
`defaultsettings.usd:315-317`) actually means: the effective floor is
`max(1/48, CorpseApproachRange-Max)` VTank distance units (`1/48` ≈ 5 m at
the ~240 m/unit conversion) **plus** `1/24` (~10 m) — so a `Max` of `0`
does **not** mean unlimited, it means "use the fixed ~15 m floor." Within
that radius, if any radar-tracked corpse (`dz.v.c(f0.c.c)`) lacks full item
data in VTank's item cache, the WHOLE logic engine pauses for that tick
(`dz.o.s = true`) rather than act on stale info about a corpse that close.
### 3.4 Open, loot, and wait — `bj` ("OpenCorpse"), `d0` ("LootCorpse"), `a1` ("CorpseWait")
Three distinct rules, in this exact division of labor (corrected from an
earlier draft of this doc, which had `d0`'s and `bj`'s roles swapped — the
division below was independently confirmed by tracing `fo.g()`/`hv.a()`'s
actual call sites, not just each rule's `FriendlyName` string):
- **`bj : ILogicRule`** (`bj.cs`, `FriendlyName` = `"OpenCorpse"`,
`bj.cs:126`) is the fixed-range final approach *and* the open action.
Constructed as `new bj(0, 1.0/48.0)` (`cLogic.cs:498,544` — a fixed ~5 m
use-range, not a setting-name pair). `ValidNow` requires `EnableLooting`
and either an `ItemUse` lock already held (an attempt in flight) or
`fo.a(radius≈1/48, exact:true)` selecting an unopened corpse
(`bj.cs:64-79`). When it fires, it re-selects via `fo.a(...)` and calls
`fo.g()` (`fo.cs:325-351`) — which issues a raw `f9.p(corpseId)`
**UseItem** (bypassing the peace-mode guard the same way the FCM trace's
wand-recovery path does with `f9.p(wand)`), increments the per-corpse
open-attempt counter, and at `BlacklistCorpseOpenAttemptCount` (default
30, `defaultsettings.usd:1059-1063`) attempts resets the counter, stamps
the blacklist timestamp, and posts
`PluginCore.a("Blacklisting unopenable corpse \"<name>\" for
<BlacklistCorpseOpenTimeoutSeconds> seconds.")` (`fo.cs:349`). Arms
`ItemUse`/`Navigation`/`CorpseOpenAttempt` action locks for
`CorpseOpenTimeoutSeconds` (`bj.cs:109-112`).
- **`d0 : ILogicRule`** (`d0.cs:6-132`, `FriendlyName` = `"LootCorpse"`)
is the **item-pickup** step, not the open step: `ValidNow`
(`d0.cs:60-80`) requires `EnableLooting`, no `ItemUse` lock, the
last-attempted and currently-open corpse ids matching
(`dz.r.m == dz.r.j`), `fo.e()` true (a corpse IS currently open), **and**
`hv.e()` true (the item-tracker, §3.5, has pending pickups queued). When
it fires it calls `dz.s.a()``hv.a()` with no args, the mover that
pulls the next queued item — and arms `ItemUse`/`Navigation` locks for
`0.75 s` (`d0.cs:100-113`; the same lock-duration pattern already
documented for the wand/FCM path).
- **`a1 : ILogicRule`** (`a1.cs:6-122`, `FriendlyName` = `"CorpseWait"`)
waits for the item-tracker to finish and then closes the corpse:
`ValidNow` requires `EnableLooting`, no `ItemUse` lock, and `fo.e()` (a
corpse is open). When it fires and `hv.f()` (`m_a.s.f()`, "corpse fully
processed", §3.5) is also true, it calls `fo.b()` (`m_a.r.b()`,
`fo.cs:353-363`): marks the corpse done (`b=true`) and issues a
**second** raw `f9.p(corpseId)` UseItem on the same id — VTank
explicitly re-uses the corpse object to close the container view once
looting finishes, rather than simply walking away.
**Open confirmation** rides server message type `63408` (`fo.cs:269-323`):
event `406` with `container` = the corpse id and `itemCount` marks the
corpse as the tracked "currently open" one (`j = container`), starts the
item-tracker's enumeration for that corpse (`dz.s.a(itemCount)` = `hv.a(int)`,
§3.5), and — if a `CorpseOpenAttempt` action lock was held — clears it plus
`Navigation`/`ItemUse` and calls `SchedulePoke()` to re-run the logic
engine immediately rather than wait for the next tick. Event `82` and
event `34` (matching the open corpse's id) both mean "no longer open" and
reset `j=0` plus `hv.b()` (the item-tracker's reset). `fo.c()`/`fo.e()`
(`fo.cs:365-382`) additionally resync against
`PluginCore.dz.az.Actions.OpenedContainer` every poll — if the game client
itself reports nothing open while `fo` still thinks a corpse is open, `fo`
self-corrects (`j=0`).
### 3.5 Item enumeration, decision, pickup, and priority looting
The item-tracker is `hv` (field `s.s`, i.e. `PluginCore.dz.s`) — distinct
from `el.cs` (§3.7). `hv.a(int itemCount)` (`hv.cs:275-301`) is the
enumeration entry point fired at open confirmation: clears per-corpse
state, logs `"LootList Clear (NewCorpse)"`, then walks
`WorldFilter.GetByContainer(dz.r.j)` (the open corpse's contents). Items
that materialize *after* the initial snapshot are caught by a
`WorldFilter.CreateObject` handler, `hv.a(fu)` (`hv.cs:248-273`), which
runs the identical add-and-decide logic.
**Per-item needs-ID gate**, `hv.b(int)` (`hv.cs:389-404`) — `true`
(defer, request ID first) if: the CURRENT corpse is the player's own death
corpse (`fo.a.h`, "always ID everything on your own corpse"); or the loot
plugin's own `DoesPotentialItemNeedID` (`cu.b(int)``LootPluginBase`,
§2.3/§2.4) says so; or there's remaining mana-tank fill capacity and the
item is a known-needs-ID object. Otherwise the real decision runs
immediately.
**The plugin call site.** `PluginCore.dz.ah` is a `cu` instance — VTank's
loot-plugin MANAGER, not the item-tracker (`cu.cs:9`; loads plugins from
registry key `HKLM\Software\Decal\LootPlugins`, matches an active plugin by
declared file extension, §1.5). `cu.a(int objectId)` (`cu.cs:176-195`)
builds a `GameItemInfo`, checks `.IsValid`, and calls the loaded
`LootPluginBase.GetLootDecision(item)` (§2.4), catching any exception as
`LootAction.NoLoot`. `cu.b(int)` (`cu.cs:155-174`) is the matching
`DoesPotentialItemNeedID` call site. `hv` calls these through
`dz.ah.a(id)`/`dz.ah.b(id)` (`hv.cs:395,408`) — i.e. the item-tracker
*consumes* the plugin manager's decision, it does not host the plugin
itself. `PluginCore.cs` also exposes `FLootPluginClassifyImmediate`/
`FLootPluginClassifyCallback` (`PluginCore.cs:3082-3145`) as an
async-with-ID-wait convenience wrapper over the same two `cu` calls, used
by call sites outside the corpse-loot path.
**Per-item decision**, `hv.a(int, hv.a)` (`hv.cs:406-473`): calls
`cu.a(id)` and switches on the result's `eLootAction`:
- `NoLoot` falls through to three fallback checks (below) before the item
is finally skipped.
- `KeepUpTo`: counts existing same-named items plus an in-corpse
already-queued count; treated as `NoLoot` (same fallthrough) once at or
over the cap.
- Every other action (`Keep`/`Salvage`/`Sell`/`User1`-`5`, and an
under-cap `KeepUpTo`) is stored **verbatim** and queued
(`hv.cs:438-440`) — **the Salvage/Sell/Keep/User1-5 split is not applied
at decision time**, only later once the item is confirmed in inventory
(§3.7).
- **Fallback path** (only reached on a plugin `NoLoot`): (a) if the item
is a scroll eligible under `ReadUnknownScrolls` (`hv.a(id,commit:true)`,
§3.8) it is queued as `Read` even though the plugin rejected it; else
(b)/(c) if it's a usable mana stone / mana-tank tool and capacity
remains, a synthetic `ManaStone`/`ManaTank` action is queued.
**The pending-pickup queue and mover.** `hv.m_d` is
`MyDictionary<int, hv.a>`, the queue of decided-but-not-yet-moved items;
each entry (`hv.a`, `hv.cs:9-32`) carries its own **priority score**
(field `e`, an `int`) alongside pickup-in-progress/attempt-count/resolved-
action fields. `hv.a()` (no args, `hv.cs:361-387` — the mover `d0` calls)
picks the **highest-priority entry** in `m_d`, marks it in-progress,
increments its attempt counter, drops it from the queue once attempts
exceed `CorpseLootItemMaxAttempts` (`hv.cs:379-382`), else issues
`f9.p(itemId)` — the SAME raw-UseItem helper used to open/close the
corpse. **VTank does have a genuine per-item pickup-priority mechanism**
(`hv.a.e`) — but this pass did not confirm what populates that score (it
was not traced back to `cLootItemRule.pri`, and §2.1 already established
that VTClassic's own `Classify()` never reads `pri`; whether `hv.a.e` is
sourced from `pri` via some other path, or is a purely host-computed value
unrelated to it, remains open — see §5).
**Readiness**, `hv.f()` (`hv.cs:315-350`) — "corpse fully processed":
`false` while items are still expected (`CorpseItemAppearanceTimeoutSeconds`,
logging `"Abandoned attempting to loot corpse. Item appearance timeout
occurred. (Empty corpse bug)"` on timeout, `hv.cs:320`), `false` while any
tracked item's ID is still pending beyond `CorpseItemIDTimeoutSeconds`
(logging `"Abandoned attempting to loot corpse. Unable to recieve ID for
all items."`, `hv.cs:338`), `false` while `m_d` still holds queued
pickups; else `true` — this is what `a1` ("CorpseWait") polls before
closing the corpse (§3.4).
### 3.6 Priority looting — the rule table
`cLogic.InitializeDefaultLogicRules`
(`refs/vtank/decompiled/uTank2/cLogic.cs:433-578`) places two parallel loot
chains in the master rule list (first-match-wins, `cLogic.cs:222-255`):
| Stage | Gate | Rules (in order) |
|---|---|---|
| `PREPRIORITYLOOTACTIONS``POSTPRIORITYLOOTACTIONS` (`cLogic.cs:486-490`) | `LootPriorityBoost` only | `er(0)` ("ReadScroll"), `aj(0)` ("StackCram"), `ar(0)` ("SalvageItems") — each a `LogicRulePreChain` |
| `PREPRIORITYLOOT``POSTPRIORITYLOOT` (`cLogic.cs:491-505`) | `EnableLooting` + `LootPriorityBoost` + `SettingDelegate_SetWaitingOnCorpseId` | `g8`+`fg` ("Navigate", approach) → `bj(0,1/48)` ("OpenCorpse") → `d0(0)` ("LootCorpse") → `a1(0)` ("CorpseWait") |
| `PREIDLELOOTACTIONS``POSTIDLELOOTACTIONS` (`cLogic.cs:529-533`) | none (empty `reqs4`), pre-action `cm(0)` (IdlePeace — drop to peace first) | same `er(0)`,`aj(0)`,`ar(0)` |
| `PREIDLELOOT``POSTIDLELOOT` (`cLogic.cs:534-551`) | `EnableLooting` only, pre-action `cm(0)` | same `g8`/`bj` chain, then bare `d0(num3++)`/`a1(num3++)` |
`PREATTACK`/`b4`/`POSTATTACK` combat sits structurally BETWEEN the
priority-loot block and the idle-loot block (`cLogic.cs:514-516`). So
`LootPriorityBoost` (default `False`, "corpses are looted before attacking
monsters", `defaultsettings.usd:659-665`) does not reorder a generic
priority number or touch `hv.a.e` — it duplicates the entire
approach→open→loot→wait chain into an earlier, `LootPriorityBoost`-gated
position ahead of combat; the SAME rule classes are re-registered
unconditionally (gated only by `EnableLooting`) after combat as the
"idle loot" copy, which is what actually runs when the setting is off.
`er`/`ar` ("ReadScroll"/"SalvageItems") are separate `ILogicRule`s that do
NOT run as part of the corpse-open/loot/wait chain:
- **`ar`** (`ar.cs`, `FriendlyName` = `"SalvageItems"`): `ValidNow`
requires `EnableLooting`, a `Salvage` lock active OR a Ust (salvage
tool) present in inventory, **no corpse currently open** (`!fo.e()`),
and pending salvage work (`c7.j()`, next paragraph). When it fires:
`f9.p(Ust)` (triggers the client's own salvage-combine dialog) then
`c7.i()` to drive the combine/split logic.
- **`er`** (`er.cs`, `FriendlyName` = `"ReadScroll"`): scans `dz.o.i`
(`MySortedList<int,int>`, spell-id → item-id, populated at pickup
confirmation, §3.7) for an eligible entry and issues `f9.p(itemId)`
directly (`er.cs:118,135`).
`aj` (`FriendlyName` = `"StackCram"`) is the rule that drives `el` — see
§3.7; it is NOT part of the corpse loot chain either.
### 3.7 Post-pickup differentiation, salvage staging, and the AutoStack/AutoCram mover
The Salvage/Sell/Keep/User1-5/Read/ManaStone/ManaTank split (deferred at
decision time, §3.5) is applied once the item is CONFIRMED in inventory:
`hv.a(object, ChangeObjectEventArgs)` (`hv.cs:106-216`) fires on a
container-change to the character's own inventory for an item still in
the pending queue `m_d`, and switches on the resolved action:
| Action | Handling |
|---|---|
| `Salvage` | if the item is still unidentified/generic and carries a salvage-material key, hands it to `c7.c(id)` (`dz.u`, salvage-combine staging, `hv.cs:139-146`) — else logs a "lacks a salvage material" warning and drops it |
| `Read` | adds `spellId → itemId` to `dz.o.i` for `er` ("ReadScroll") to process **later, as a separate idle-loop rule** — reading a scroll is NOT an immediate inline continuation of its pickup (`hv.cs:150-165`) |
| `ManaStone` | `dz.ac.c(id)` |
| `ManaTank` | `dz.ac.b(id)` (same unidentified-item guard as Salvage) |
| `Keep`/`Sell`/`KeepUpTo`/`User1`-`5` | added uniformly to `dz.o.h` (`MyDictionary<int,eLootAction>`) — **no Sell-specific queue or vendor-open gate was located reading this table back out**; whether a dedicated idle-loop rule consumes it for vendor selling, versus it being purely a bookkeeping/report table, is unresolved (§5) |
`c7` (`dz.u`) is VTank's salvage-combine staging class: `c7.c(int)` queues
an item, `c7.j()` reports whether combine- or split-mode work is pending,
`c7.i()` dispatches to whichever applies — consumed by `ar` ("SalvageItems")
above, never from inside the corpse-open/loot chain itself (`ar.ValidNow`
explicitly requires no corpse open).
**`el.cs` is NOT the corpse-loot mover** — independently confirmed by
reading it directly and by the background trace of `aj`/`cLogic.cs`: it is
VTank's `AutoStack`/`AutoCram` idle-inventory tidier
(`refs/vtank/decompiled/el.cs:7-208`), gated by those two settings
(`el.cs:81,129`), driven by the separate `aj` ("StackCram") rule
registered in the same sentinel-bounded stage as `er`/`ar`
(`cLogic.cs:488,531`) — not by `d0`/`a1`. `el.c()` finds either two
same-material stackable items with mismatched counts (a partial stack to
merge) or one loose item plus a container with free capacity, and `el.d()`
issues `PluginCore.dz.az.Actions.MoveItem(...)`, with an "abandon after 80
consecutive stuck ticks" blacklist (`el.cs:182-198`) chat-reported via
`PluginCore.a(...)` — the same shape as MossTank's own
`_combineAttempts`/40-attempt salvage-bag-combine abandon logic (§4), a
different subsystem and a different threshold (80 vs 40, neither
confirmed intentional).
### 3.8 `ReadUnknownScrolls` eligibility
`hv.a(int itemId, bool commit)` (`hv.cs:475-500`) is the eligibility test
referenced from both the per-item `NoLoot` fallback (§3.5, `commit=true`)
and (peek mode, `commit=false`) from `er` ("ReadScroll", §3.6) deciding
whether an already-queued scroll is still worth reading. Requires
`ObjectClass == 42` (Scroll) and `ReadUnknownScrolls` on; reads the
scroll's spell id and skips if already known
(`dz.q.y.Contains(spellId)`); then requires
`spell.Difficulty - 15 <= spell.SkillWithSchool` — the character's magic
school skill must be within 15 points of the spell's difficulty. In
`commit=true` mode, additionally requires the spell id not already queued
in `dz.o.i`. MossTank's `IsReadableUnknownScroll`
(`src/AcDream.Plugins.MossTank/Looting.cs:1370-1395`) matches this exactly
on the numeric threshold (`spell.Difficulty - 15 <= skill.Current`) and
the "already known" guard, substituting a documented adaptation for the
`ObjectClass==42` test — a name-ending-in-`" Scroll"` plus item-type-flag
heuristic, with an in-code comment explaining that Decal's `ObjectClass.Scroll`
is a derived client classification with no equivalent field on retail's
wire `PublicWeenieDesc`. The one confirmed behavioral difference: VTank
defers the actual read to a separate `er` ("ReadScroll") idle-loop rule
that fires independently, sitting behind whatever combat/idle-status rules
precede it in the list (§3.6); MossTank's `ContinuePostUse`
(`Looting.cs:857-903`) issues the read as an immediate continuation right
after the scroll is picked up.
---
## 4. The "MossTank gap"
**A real `.utl` file loads today.**
`MossTankLootProfileStore.TryImportLegacy`
(`src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs:205-264`) reads a
`.utl` from the host's `imports`/`exports` storage folders through
`VtankLootProfileSerializer.TryRead`
(`src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs:76-147`), which
independently re-implements §1's exact grammar: header/version detection
(`VtankLootProfileSerializer.cs:92-107`), the length-prefixed v1 payload
format and the fixed-line-count v0 legacy table
(`LegacyPayloadLineCount`, `VtankLootProfileSerializer.cs:376-388`
independently verified in this pass against every one of VTClassic's 30
`Read()` methods, §1.3/§1.2; every bucket matches exactly), the
`SalvageCombine` extra block including its optional trailing value-mode
section, and preserves any unrecognized block/requirement type verbatim
(`VtankLootExtraBlock`/`cUnsupportedRequirement`-equivalent
`VtankLootRequirement.Payload`, `VtankLootProfileSerializer.cs:11-15`).
Round-trip fidelity for every one of the 30 known requirement types plus
unknown-block preservation is exercised by
`tests/AcDream.Plugins.MossTank.Tests/VtankLootProfileSerializerTests.cs`
(all four tests read).
`VtankLootRequirementEvaluator.IsMatch`
(`src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs:110-190`)
implements the **entire** 30-entry requirement vocabulary (verified
switch-arm-by-switch-arm against §1.3's table, including the deliberately
dead `DamagePercentGE` always-`false` and the `DisabledRule`
never-matches-when-enabled semantics) plus every `ComputedItemInfo` formula
in §2.2 (`BuffedTinkedDamage`/`CanReachTarget` reproduce the iron/granite
tink loop and the priority-ordered target-reach loop bit-for-bit against
`ComputedItemInfo.cs`'s control flow).
Ranked semantic differences (highest impact first):
| # | Gap | VTClassic/VTank oracle | MossTank today | Impact |
|---|---|---|---|---|
| 1 | **No ID-avoidance optimization.** | `NeedsID`/`EarlyMatch` (§2.3) skip appraisal when the classification is already decidable, or when a later same-action rule makes an earlier undecidable rule moot. | `LootController.ContinueCurrentCorpse` (`Looting.cs:708-721`) calls `loot.Identify(item.ObjectId)` for **every** corpse item before any decision is made — there is no `DoesPotentialItemNeedID` analog anywhere in `Looting.cs`, and no test exercises one (`LootingTests.cs` has no `NeedsID`/`DoesPotentialItemNeedID` case). | High — changes appraisal/identify pacing and network chatter for every loot pass; a rule set that could skip IDing (e.g. "NoLoot everything except ObjectClass==Money") gets fully IDed anyway on live retail/ACE. |
| 2 | **Rare corpses are not prioritized in selection.** | `fo.a` (§3.2) strictly prefers ANY rare corpse over ANY non-rare corpse, regardless of distance, once any exists in the known set. | `LootController.Tick`'s candidate loop (`Looting.cs:557-561`) orders strictly by `.Distance` then `.ObjectId``CanLoot` gates eligibility per §4's fellow/all-corpse rules (see #4 below, this part IS faithful) but never re-orders for rarity. | High for players who loot mixed rare/mundane fields — a farther rare corpse can be skipped in favor of a nearer mundane one until the mundane one is done. |
| 3 | **No chat-text ownership-denial listener.** | `fo.cs:71-73,269-323,393` treats a "already in use by someone else!" / "you do not yet have the right to loot" chat line as an immediate 10-second skip for that corpse. | `Looting.cs` has no chat-message handling anywhere in the loot path; a denied corpse is only ever backed off via the generic `BlacklistCorpseOpenAttemptCount`/`BlacklistCorpseOpenTimeoutSeconds` retry-then-blacklist mechanism (`Looting.cs:397,1397-1417`), which the defaults confirm are faithfully ported (30 attempts / 200 s, matching `defaultsettings.usd:1059-1071` exactly). | Medium — same eventual outcome (corpse gets skipped) but far slower: retail's explicit refusal is immediate, MossTank's fallback needs up to 30 failed open attempts first. |
| 4 | **`BuffedInt`/`BuffedDouble` omit the base-key-exists gate.** | `ComputedItemInfo.GetBuffedLogValueKey`/`GetBuffedDoubleValueKey` (`ComputedItemInfo.cs:198-248`) only add a spell bonus if the item **already has** that base key (`KeyExistsInt`/`KeyExistsDouble`); otherwise the buffed value stays at the caller's default. | `VtankLootRequirementEvaluator.BuffedInt`/`BuffedDouble` (`VtankLootRequirementEvaluator.cs:415-448`) compute the base value through a default-returning lookup (no existence check) and then unconditionally add any matching spell bonus. | Medium-low — for the common case (a weapon rule reading `Damage`, which virtually every weapon carries) this never differs; it only diverges for an item that lacks the base key entirely but is affected by a matching buff spell, an edge case not covered by any current test. |
| 5 | **The 100-second public/fellow-corpse "age" clock starts at a different moment.** | `fo.a.c` (§3.1) is stamped once at `WorldFilter.CreateObject` — i.e. as soon as the corpse object streams into the client's known-object set, which is typically a much larger radius than the loot-approach range. | MossTank's `_corpseFirstSeen` (`Looting.cs:394,530`) is only populated inside `CaptureCorpses(CorpseApproachRange)` (`Looting.cs:526-530`) — the clock cannot start until the corpse is already within the (much smaller) loot approach range. | Low-medium — makes MossTank's 100-second public-corpse and fellow-non-share timers start later than retail VTank's for a corpse seen from far away before the player walks up to it; converges to the same behavior once the player is in loot range for 100+ seconds regardless. |
| 6 | **VTank explicitly re-closes a finished corpse; MossTank does not.** | `a1`'s `fo.b()` (`fo.cs:353-363`) issues a second raw UseItem on the corpse id specifically to close the container view once looting completes. | `LootController.ContinueCurrentCorpse`'s completion path (`Looting.cs:750-760`) just clears local state and moves on — it never issues an explicit close action; whether the container view auto-closes depends on the host's `ILootAutomation`/retail behavior rather than an explicit port of `fo.b()`'s second UseItem. | Low — cosmetic/UI-state difference (an open corpse window lingering) rather than a loot-decision difference, unconfirmed whether retail's own container-close behavior makes this moot. |
| 7 | **`el.cs`'s 80-attempt stuck-item abandon threshold vs MossTank's 40-attempt salvage-combine abandon.** | VTank's `AutoStack`/`AutoCram` mover (`el.cs:182-198`) gives up after 80 consecutive stuck ticks. | MossTank's `ContinueSalvageBagCombine` gives up after 40 (`Looting.cs:1138`). | Low — different subsystem (stack/cram vs salvage-bag-combine) and an unconfirmed-as-intentional magic number on both sides; flagged only because the pattern shape is otherwise an exact match. |
| 8 | **Reading a scroll is an immediate continuation in MossTank; VTank defers it to a separate idle-loop rule.** | `hv`'s `Read` handling (§3.7) only enqueues `spellId → itemId` into `dz.o.i`; the actual read fires later, whenever `er` ("ReadScroll") next becomes `ValidNow` in the (priority- or idle-)loot stage — it can be delayed behind combat/idle-status rules ahead of it in the list (§3.6). | `LootController`'s `_postUseItem` continuation (`Looting.cs:825-831,857-903`) issues the read as the very next action after the scroll's pickup completes. | Low — same eventual outcome (scroll gets read once known), different latency/interleaving; a VTank session with `LootPriorityBoost` off and a full combat queue could sit on a picked-up scroll far longer than MossTank ever would. |
| — | **`cLootItemRule.pri`/`Priority` has no effect in VTClassic's own classifier — but VTank's item-tracker DOES have a real per-item pickup-priority field.** | `cLootRules.Classify` never reads `Priority()` (§2.1) — dead weight from VTClassic's own read/write perspective. VTank's item-tracker (`hv`) separately carries a genuine priority score per queued item (`hv.a.e`, an `int`) and its pickup mover explicitly picks the **highest-priority** queued item first (`hv.a()`, §3.5). | `LootController.ContinueCurrentCorpse`'s pickup-order selection (`Looting.cs:764-769`) sorts already-decided candidates by `Decision.Priority` (descending) then rule index — the SAME shape as `hv.a.e`-based selection. | Not ranked as a gap — MossTank's behavior has a genuine VTank-side counterpart in shape. **Unconfirmed**: what populates `hv.a.e` was not traced to its source in this pass (not confirmed to be `cLootItemRule.pri`, and not confirmed to be anything else either) — see §5. |
Not gaps (verified faithful, listed so a future pass doesn't re-litigate
them): the entire `LootOnlyRareCorpses`/`LootFellowCorpses`/`LootAllCorpses`
ownership-gate cascade (`CanLoot`, `Looting.cs:1191-1230`) matches
`fo.a`'s ownership branch (`fo.cs:403-434`) line-for-line, including the
100-second thresholds and the "share loot" fellow-member flag; the
`BlacklistCorpseOpenAttemptCount`/`TimeoutSeconds` defaults (30/200) and the
`CorpseItemAppearanceTimeoutSeconds`/`CorpseItemIdentifyTimeoutSeconds`
defaults (6/60, matching `defaultsettings.usd`'s `CorpseItemAppearanceTimeoutSeconds`/
`CorpseItemIDTimeoutSeconds` exactly) are exact; the `.utl` v0/v1
read/write grammar (§1) round-trips byte-for-byte per its own test suite;
the `CalcedBuffedTinkedDamage`/`CanReachTarget` tink-simulation control
flow is an exact port of `ComputedItemInfo`'s loop structure and constants
(including the `0.9`/`0.2`/`0.8` damage-over-time and variance-decay
constants).
---
## 5. Could not determine
- **What populates `hv.a.e`** (the per-queued-item pickup-priority score,
§3.5) — confirmed to exist and to drive pickup order, but not traced
back to a source; not confirmed to be `cLootItemRule.pri` (which §2.1
established VTClassic's own `Classify()` never reads) or to be anything
else. This is the one open question behind the "not ranked as a gap"
row in §4's table.
- **`fo.a`'s dual meaning of the `e` (rare) field** (`fo.cs:12`, set both
at `fo.cs:209` for a genuine "Killed by X...Generated..." match and at
`fo.cs:244` for NO "Killed by X." match at all) — whether these two
populations are actually disjoint on a live server (i.e. whether an
ordinary solo-killed monster corpse's inspect text ever lacks a
"Killed by X." clause, and so falls into the same `e=true` bucket as a
genuine rare) could not be determined from static code alone; would need
a live packet/cdb trace of an ordinary (non-rare) corpse's
`StringValueKey 16` text. This directly affects how literally to read
§4 item 2 (VTank preferring "rare" corpses) — the practical rare-corpse
population it actually prefers may be broader than "wear the retail rare
drop message" alone.
- **`id.a.j`** (`id.cs:29`, the fellow-member record field checked at
`fo.cs:420`, gating IMMEDIATE-vs-100-second-delayed fellow-corpse
looting) — sits among several plain `int`/unnamed fields with no naming
information surviving obfuscation; its precise semantic (e.g. "member
currently in range", "member online", "member's own share-loot
preference") was not confirmed. MossTank's `member.ShareLoot`
(`Looting.cs:1224`) is presented as a direct port of this field but that
mapping itself is unverified against a confirmed semantic.
- **`f0.c` enum members `a` and `d`** (`f0.cs:11-17`) — only `b`
(item-needs-ID) and `c` (corpse-needs-ID) were observed at call sites in
this pass; the other two members' purposes are unknown.
- **The full write/reset surface of `dz.o.s`/`dz.o.t`/`dz.o.c` and similar
single-letter bookkeeping fields** on the large (~1900+ line) `ga`/`s.o`
class, which covers combat, nav, and loot state together — only the
specific call sites this doc cites were confirmed; there may be
additional gating logic on these flags elsewhere in `ga.cs` not surfaced
here.
- **Whether anything actually consumes `dz.o.h`** (the table `Keep`/
`Sell`/`KeepUpTo`/`User1`-`5` decisions land in uniformly after pickup,
§3.7) as a real vendor-sell queue, versus it being purely a bookkeeping/
report table — no rule class reading it back out was found among the
~35 rule classes `cLogic.cs` registers (only the loot-relevant ones were
opened in this pass). This makes a direct sell-path comparison against
MossTank's explicit `_sellPendingItem`/`ContinueSell` staging
(`Looting.cs`) inconclusive rather than a confirmed match or gap.
- **The exact semantic of network message type `63408`, event `34`**
treated here as "corpse closed/emptied" by contextual inference (it
resets the same state as event 82 / a failed event-406), not
independently confirmed against a protocol reference.
- **What `bz`/`ca`/`eb`** (the sibling target-provider classes to `fg`,
used respectively for route navigation and monster approach, §3.3) each
compute exactly — their own source was not read; only their call sites
and friendly-name strings were confirmed.
- **Exact chat text VTank posts on a successful loot/pickup** (as opposed
to the confirmed blacklist/abandon lines at `fo.cs:349`, `hv.cs:320`,
`hv.cs:338`, `el.cs:189`) — not traced; three distinct chat-output
helpers were identified (`PluginCore.a(string)` for user-facing lines,
the deduplicating top-level `ah.a(string)` for one-time warnings, and
`ga.a(string,eLogState)`/`dz.o.a(...)` for gated debug tracing), but no
"you loot X" style success message was independently located among them.
- **Whether MossTank's lack of an explicit corpse-close action (§4's
ranked item 6) produces any observable retail-visible difference** —
depends on whether ACE/retail auto-closes a corpse's container view once
its contents are all removed, which is outside this doc's scope
(wire/server behavior, not the VTank/`.utl` oracle).