diff --git a/docs/research/vtank-kb/05-looting-and-utl.md b/docs/research/vtank-kb/05-looting-and-utl.md new file mode 100644 index 00000000..d7b26995 --- /dev/null +++ b/docs/research/vtank-kb/05-looting-and-utl.md @@ -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 + "."` +(`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` (`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 \"\" for + 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`, 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`, 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`) — **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).