acdream/docs/research/vtank-kb/05-looting-and-utl.md
2026-09-06 20:25:19 +02:00

59 KiB
Raw Blame History

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; for a RECOGNIZED type the count is read and discarded and iLootRule.Read consumes its own lines (cLootItemRule.cs:112-118); only an UNRECOGNIZED type consumes that many raw characters via inf.Read(char[],0,num) (cLootItemRule.cs:119-123); 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 IntValueKeys 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 !bwhen 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 — when that bit is SET the rule reports undecided, i.e. the bit marks "spell data not yet revealed", not "identified"; 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 25 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 (int)Change == 1 — i.e. Change anywhere in [1.0, 2.0) truncates to 1 — in which case multiplicative; in BOTH branches the operand applied is Bonus, never Change, ComputedItemInfo.cs:244) with a bonus from a hardcoded spell-id → SpellInfo{Key,Change,Bonus} table seeded in the static constructor (ComputedItemInfo.cs:88-139). Only the three-argument entries are live: the two-argument ctor (ComputedItemInfo.cs:17-20) sets Bonus = 0.0, and both getters skip any entry with Bonus == 0.0 (:213, :242). So spell 1616 (SpellInfo(218103842, 20.0), Bonus 0) contributes nothing; a live example is spell 2598+2 to IntValueKey 218103842 = Damage (:96). MossTank's tables at VtankLootRequirementEvaluator.cs:16-39 correctly carry only the live entries (citation pass 2026-09-06).
  • 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 IntValueKeys (370376,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 VTClassic.eLootAction (11 members, 0-10) onto the host's uTank2.eLootAction (13 members, 0-12 — adds ManaStone/ManaTank, uTank2/eLootAction.cs:3-18) wrapped in the public class uTank2.LootPlugins.LootAction (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 to the uTank2 assemblyno loot plugin can construct a Read action, VTClassic included (VTClassic ships as its own assembly and the tree carries no InternalsVisibleTo). Confirmed by the mapping switch itself (LootCore.cs:65-82), which has no Read case and no User1..5 cases — both fall through to the NoLoot initializer, so VTClassic can only ever return NoLoot/Keep/Salvage/Sell/KeepUpTo; Read and User1..5 reach hv only from host-internal paths. 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 that member's own name field (fo.cs:233 assigns the same item.Value.b it matched case-insensitively — a case normalization, not a fuller 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:133-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. The priority field hv.a.e is dead. It is declared (hv.cs:19), read at hv.cs:371,374, and never written anywhere — the ctor leaves it 0 (hv.cs:25-31) and neither construction site sets it (hv.cs:256,289); hv.a is a private class so no external writer is possible. The "highest-priority" scan therefore always keeps the FIRST key MyDictionary enumerates (strict > against int.MinValue). VTank has no effective per-item pickup priority (citation pass 2026-09-06).

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)
PREPRIORITYLOOTACTIONSPOSTPRIORITYLOOTACTIONS (cLogic.cs:486-490) LootPriorityBoost only er(0) ("ReadScroll"), aj(0) ("StackCram"), ar(0) ("SalvageItems") — each a LogicRulePreChain
PREPRIORITYLOOTPOSTPRIORITYLOOT (cLogic.cs:491-505) EnableLooting + LootPriorityBoost + SettingDelegate_SetWaitingOnCorpseId g8+fg ("Navigate", approach) → bj(0,1/48) ("OpenCorpse") → d0(0) ("LootCorpse") → a1(0) ("CorpseWait")
PREIDLELOOTACTIONSPOSTIDLELOOTACTIONS (cLogic.cs:529-533) none (empty reqs4), pre-action cm(0) (IdlePeace — drop to peace first) same er(0),aj(0),ar(0)
PREIDLELOOTPOSTIDLELOOT (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 ILogicRules 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 31 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 31 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 31-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 .ObjectIdCanLoot 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:386-387,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. It also mis-ports the branch selector: (int)bonus.Bonus == 1 (VtankLootRequirementEvaluator.cs:445) tests the bonus where VTClassic tests the separate Change field (ComputedItemInfo.cs:244) — harmless today only because MossTank's table holds only entries where Change == 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 declares a priority field (hv.a.e) but never writes it (§3.5) — the "highest-priority" scan degenerates to dictionary-enumeration order, so VTank has no effective pickup priority. 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. Low — MossTank's Decision.Priority ordering is a MossTank-side addition with no live VTank counterpart. Resolved: hv.a.e is dead (§3.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 (one benign difference: VTank compares killer and fellow names case-SENSITIVELY, fo.cs:407,417; MossTank uses OrdinalIgnoreCase), 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

  • 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 — RESOLVED (citation pass 2026-09-06): id.cs:221 populates it directly from the fellowship message's named field (a10.j = A_0.Value<int>("shareLoot") != 0;, alongside name/level/ maxHealth, id.cs:212-221; field declared at id.cs:30). MossTank's member.ShareLoot mapping (Looting.cs:1224) is confirmed correct.
  • f0.c enum members — RESOLVED (citation pass 2026-09-06): f0.c is the ID-request category. a = the "Set default profile" inventory-scan sweep (da.cs:577,581-583,594-599), b = corpse-item ID (hv.cs:260,293,306), c = corpse ID (fo.cs:177), d = door ID for the OpenDoors rule, range-gated by DoorIDRange (b7.cs:99-104).
  • 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.
  • dz.o.h consumers — RESOLVED (citation pass 2026-09-06): no internal rule reads it. Its only readers are two external-plugin API surfaces gated on eExternalsPermissionLevel.FullUnderlyingGetByAction(eLootAction) (uTank2/PluginCore.cs:274-278PluginCore.a(eLootAction) at :2752-2776, which also prunes ids no longer in inventory) and GetCustomLootActionItems(eLootAction) (uTank2/PluginCore.cs:466-478). Writes: hv.cs:184 (add), hv.cs:73-75 (remove on release). It is a bookkeeping + third-party query table, NOT a vendor-sell queue, so MossTank's explicit _sellPendingItem/ContinueSell staging is a confirmed MossTank-side addition with no VTank counterpart.
  • 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).