59 KiB
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/*.csandVTClassic.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 synthetica/b/c…— semantics below were derived by reading call graphs and cross-referencinguTank2.Resources.defaultsettings.usdsetting 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}andsrc/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: theNeedsIDearly-decision optimization, thepri-field non-use, theKeyExistsInt/KeyExistsDoublebuff gate, and the exactfo.csrare/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 !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 — 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/KeyExistsDoublegate,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.Changeanywhere in [1.0, 2.0) truncates to 1 — in which case multiplicative; in BOTH branches the operand applied isBonus, neverChange,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) setsBonus = 0.0, and both getters skip any entry withBonus == 0.0(:213,:242). So spell1616(SpellInfo(218103842, 20.0), Bonus 0) contributes nothing; a live example is spell2598→+2toIntValueKey 218103842= Damage (:96). MossTank's tables atVtankLootRequirementEvaluator.cs:16-39correctly 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 type2000).CalcedBuffedTinkedDamage(ComputedItemInfo.cs:47-77): simulates the retail iron/granite tinker-imbue choice. Available tink count =max(10 - IntValueKey(171), 0), minus 1 ifIntValueKey(179)==0(untinkerable-material guard), forced to0ifIntValueKey(131)==0(no material at all). For each available tink, comparesCalculateDamageOverTime(dmg+25, variance)(an "iron"-style +1-damage imbue) againstCalculateDamageOverTime(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(rule2008,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 ratingIntValueKeys (370–376,379— skips377/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 assembly — no 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 asnew bj(0, 1.0/48.0)(cLogic.cs:498,544— a fixed ~5 m use-range, not a setting-name pair).ValidNowrequiresEnableLootingand either anItemUselock already held (an attempt in flight) orfo.a(radius≈1/48, exact:true)selecting an unopened corpse (bj.cs:64-79). When it fires, it re-selects viafo.a(...)and callsfo.g()(fo.cs:325-351) — which issues a rawf9.p(corpseId)UseItem (bypassing the peace-mode guard the same way the FCM trace's wand-recovery path does withf9.p(wand)), increments the per-corpse open-attempt counter, and atBlacklistCorpseOpenAttemptCount(default 30,defaultsettings.usd:1059-1063) attempts resets the counter, stamps the blacklist timestamp, and postsPluginCore.a("Blacklisting unopenable corpse \"<name>\" for <BlacklistCorpseOpenTimeoutSeconds> seconds.")(fo.cs:349). ArmsItemUse/Navigation/CorpseOpenAttemptaction locks forCorpseOpenTimeoutSeconds(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) requiresEnableLooting, noItemUselock, the last-attempted and currently-open corpse ids matching (dz.r.m == dz.r.j),fo.e()true (a corpse IS currently open), andhv.e()true (the item-tracker, §3.5, has pending pickups queued). When it fires it callsdz.s.a()—hv.a()with no args, the mover that pulls the next queued item — and armsItemUse/Navigationlocks for0.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:ValidNowrequiresEnableLooting, noItemUselock, andfo.e()(a corpse is open). When it fires andhv.f()(m_a.s.f(), "corpse fully processed", §3.5) is also true, it callsfo.b()(m_a.r.b(),fo.cs:353-363): marks the corpse done (b=true) and issues a second rawf9.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:
NoLootfalls 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 asNoLoot(same fallthrough) once at or over the cap.- Every other action (
Keep/Salvage/Sell/User1-5, and an under-capKeepUpTo) 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 underReadUnknownScrolls(hv.a(id,commit:true), §3.8) it is queued asReadeven though the plugin rejected it; else (b)/(c) if it's a usable mana stone / mana-tank tool and capacity remains, a syntheticManaStone/ManaTankaction 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) |
|---|---|---|
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 ILogicRules that do
NOT run as part of the corpse-open/loot/wait chain:
ar(ar.cs,FriendlyName="SalvageItems"):ValidNowrequiresEnableLooting, aSalvagelock 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) thenc7.i()to drive the combine/split logic.er(er.cs,FriendlyName="ReadScroll"): scansdz.o.i(MySortedList<int,int>, spell-id → item-id, populated at pickup confirmation, §3.7) for an eligible entry and issuesf9.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 .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: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 thee(rare) field (fo.cs:12, set both atfo.cs:209for a genuine "Killed by X...Generated..." match and atfo.cs:244for 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 samee=truebucket 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'sStringValueKey 16text. 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:221populates it directly from the fellowship message's named field (a10.j = A_0.Value<int>("shareLoot") != 0;, alongsidename/level/maxHealth,id.cs:212-221; field declared atid.cs:30). MossTank'smember.ShareLootmapping (Looting.cs:1224) is confirmed correct.f0.cenum members — RESOLVED (citation pass 2026-09-06):f0.cis 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 theOpenDoorsrule, range-gated byDoorIDRange(b7.cs:99-104).- The full write/reset surface of
dz.o.s/dz.o.t/dz.o.cand similar single-letter bookkeeping fields on the large (~1900+ line)ga/s.oclass, 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 inga.csnot surfaced here. dz.o.hconsumers — RESOLVED (citation pass 2026-09-06): no internal rule reads it. Its only readers are two external-plugin API surfaces gated oneExternalsPermissionLevel.FullUnderlying—GetByAction(eLootAction)(uTank2/PluginCore.cs:274-278→PluginCore.a(eLootAction)at:2752-2776, which also prunes ids no longer in inventory) andGetCustomLootActionItems(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/ContinueSellstaging is a confirmed MossTank-side addition with no VTank counterpart.- The exact semantic of network message type
63408, event34— 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 tofg, 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-levelah.a(string)for one-time warnings, andga.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/
.utloracle).