Deadlock Research
verifiednote 0012build 66792026-08-16vdatakv3itemsshoplocalizationgallery

Shop filters and item build chains

Question

Two questions about the purchasable items, both asked because the gallery needed a second reading of the shop that is not "one flat grid per slot":

  1. Is there a structured field that groups items by what they are for — the kind of axis a player means by "show me the crowd-control items" — or does that have to be inferred?
  2. What shape does m_vecComponentItems actually make when you stop looking at one item at a time and take the whole relation at once?

Summary

Findings

All counts are from out/citadel/scripts/abilities.vdata (decompiled per note 0003) at build 6679, parsed with tools/kv3.py and flattened before counting, per note 0007. "Live named shop items" is the 173-item set defined in note 0002 and pinned in tools/test_gallery.py: typed EAbilityType_Item, carrying one of the three EItemSlotType_* slots, present in the localization table, not m_bDisabled.

m_eShopFilters: the vocabulary

live carrierstokenderived label
54EShopFilterDurabilityDurability
52EShopFilterWeaponDamageWeapon Damage
52EShopFilterMagicDamageMagic Damage
38EShopFilterHealingHealing
30EShopFilterMovementMovement
28EShopFilterFireRateFire Rate
25EShopFilterDisruptionDisruption
18EShopFilterClipSizeClip Size
8EShopFilterMeleeMelee

Tokens per item, over the 173 live named items:

tokensitems
020
144
275
326
47
51

Two observations worth separating from the table:

The tokens are unlocalized

The English localization table has 6,830 entries. None is keyed by any of the nine tokens, and exactly one key matches shop case-insensitively: ItemHistory_Action_SteamWorkshopContributor. There is therefore no shipped display string for a shop filter at this build.

The gallery derives its labels mechanically — strip the EShopFilter prefix, split camel case (EShopFilterMagicDamageMagic Damage) — which is the same fallback render_desc already uses for an attribute with no *_label entry. Inferred: these match what the shop UI shows. Not verified: the panorama layout files were not read to confirm the button captions.

The filter is not the stat row

The trap, in three items:

itemslotfiltersstat rows
Extra HealthArmorWeapon Damage, Durability-1s Charge Delay, +210 Bonus Health
Extra SpiritTechMagic Damage, Healing-1s Charge Delay, +10 Spirit Power
RefresherTech(none)300s Cooldown, 0.6s Cast Delay, -1s Charge Delay, +14% Spirit Resist, +15% Bullet Resist

Extra Health grants no weapon stat and Extra Spirit grants no healing, so the tokens cannot be read as a description of the item's own grants. Inferred: they mark the builds an item is recommended for — a T1 health item is a standard opener in a weapon build. That inference is not needed to use the field, and the gallery does not publish it; it publishes the token and says on the page what the token is.

For "what this item actually gives you" the honest source remains the item's own labelled property rows (item_stats, note 0004's label-override rule) — 177 distinct labels over the visible set, 95 of them carried by a single item, which is why that axis works as a flat grouping on the items page and would be unusable as a section list.

Build chains

Taking m_vecComponentItems undirected over the 156 default-visible items (live, non-arena). The 17 live arena items — the 9,999-soul sentinel tier — are excluded, and excluding them costs nothing: none of the 17 carries a component or is referenced as one, so they would each be a chain of one either way.

visible items156
connected chains (≥2 items)35
items inside a chain99
items with no visible relative57

Chain sizes: 21 pairs, 8 triples, 2 fours, one five, one six, two sevens. The two seven-item chains:

Two structural facts the layout depends on:

Reproduce

Decompile per note 0003 (python tools/decompile.py --fetch, then python tools/decompile.py), then run from the repo root:

python tools/find_game.py    # expect ClientVersion 6679

# 1. m_eShopFilters vocabulary, coverage, and the absent localization
python -c "
import sys, collections; sys.path.insert(0, 'tools')
from kv3 import parse, flatten, as_bool
from build_gallery import load_localization, SLOTS
doc = flatten(parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read()))
loc = load_localization()
items = {k: e for k, e in doc.items() if isinstance(e, dict)
         and e.get('m_eAbilityType') == 'EAbilityType_Item'
         and e.get('m_eItemSlotType') in SLOTS and k in loc
         and not as_bool(e.get('m_bDisabled'))}
tok = collections.Counter()
for e in items.values():
    for t in str(e.get('m_eShopFilters') or '').split('|'):
        if t.strip(): tok[t.strip()] += 1
print('live named items', len(items))
print('carrying a filter', sum(1 for e in items.values() if e.get('m_eShopFilters')))
print('per-item token count', dict(sorted(collections.Counter(
    len([t for t in str(e.get('m_eShopFilters') or '').split('|') if t.strip()])
    for e in items.values()).items())))
for t, n in tok.most_common(): print('%4d  %s' % (n, t))
print('loc keys for tokens', [t for t in tok if t in loc])"
live named items 173
carrying a filter 153
per-item token count {0: 20, 1: 44, 2: 75, 3: 26, 4: 7, 5: 1}
  54  EShopFilterDurability
  52  EShopFilterWeaponDamage
  52  EShopFilterMagicDamage
  38  EShopFilterHealing
  30  EShopFilterMovement
  28  EShopFilterFireRate
  25  EShopFilterDisruption
  18  EShopFilterClipSize
   8  EShopFilterMelee
loc keys for tokens []
# 2. chain topology over the default-visible set
python -c "
import sys, collections; sys.path.insert(0, 'tools')
from kv3 import parse, flatten, as_bool
from build_gallery import load_localization, SLOTS, tier_index
doc = flatten(parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read()))
loc = load_localization()
prices = parse(open('out/citadel/scripts/generic_data.vdata', encoding='utf-8').read()).get('m_nItemPricePerTier') or []
items = {k: e for k, e in doc.items() if isinstance(e, dict)
         and e.get('m_eAbilityType') == 'EAbilityType_Item'
         and e.get('m_eItemSlotType') in SLOTS and k in loc
         and not as_bool(e.get('m_bDisabled')) and prices[tier_index(e)] < 9999}
adj = {k: set() for k in items}
for k, e in items.items():
    for c in e.get('m_vecComponentItems') or []:
        if c in items: adj[k].add(c); adj[c].add(k)
seen, groups = set(), []
for k in sorted(items):
    if k in seen: continue
    stack, g = [k], []; seen.add(k)
    while stack:
        n = stack.pop(); g.append(n)
        for o in sorted(adj[n] - seen): seen.add(o); stack.append(o)
    groups.append(g)
chains = [g for g in groups if len(g) > 1]
print('visible items', len(items))
print('chains', len(chains), 'members', sum(map(len, chains)),
      'loners', sum(1 for g in groups if len(g) == 1))
print('sizes', dict(sorted(collections.Counter(map(len, chains)).items())))
roots = [[k for k in g if not [c for c in items[k].get('m_vecComponentItems') or [] if c in g]] for g in chains]
print('roots per chain', dict(sorted(collections.Counter(map(len, roots)).items())))
print('slot-crossing', [sorted(loc[k] for k in g) for g in chains
      if len({items[k]['m_eItemSlotType'] for k in g}) > 1])"
visible items 156
chains 35 members 99 loners 57
sizes {2: 21, 3: 8, 4: 2, 5: 1, 6: 1, 7: 2}
roots per chain {1: 33, 2: 2}
slot-crossing [['Arcane Surge', 'Extra Stamina', 'Kinetic Dash', 'Stamina Mastery'], ['Ballistic Enchantment', 'Greater Expansion', 'Mystic Expansion'], ['Bullet Lifesteal', 'Fury Trance', 'Infuser', 'Leech', 'Spirit Lifesteal', 'Spiritual Overflow', 'Vampiric Burst']]
# 3. the filter-is-not-the-stat-row counter-examples
python -c "
import sys; sys.path.insert(0, 'tools')
from kv3 import parse, flatten
from build_gallery import load_localization, item_stats
doc = flatten(parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read()))
loc = load_localization()
for k in ['upgrade_health', 'upgrade_improved_spirit', 'upgrade_ability_refresher']:
    e = doc[k]
    print(loc[k], '|', e.get('m_eItemSlotType'), '|', e.get('m_eShopFilters') or '(none)')
    print('   stats:', [r[2] + r[1] + r[3] + ' ' + r[0] for r in item_stats(k, e, loc)])"
Extra Health | EItemSlotType_Armor | EShopFilterWeaponDamage | EShopFilterDurability
   stats: ['-1s Charge Delay', '+210 Bonus Health']
Extra Spirit | EItemSlotType_Tech | EShopFilterMagicDamage | EShopFilterHealing
   stats: ['-1s Charge Delay', '+10 Spirit Power']
Refresher | EItemSlotType_Tech | (none)
   stats: ['300s Cooldown', '0.6s Cast Delay', '-1s Charge Delay', '+14% Spirit Resist', '+15% Bullet Resist']

Both figures are pinned as regression tests — python -m unittest tools.test_gallery (test_shop_filter_vocabulary_is_pinned, test_shop_filter_coverage_is_pinned, test_shop_filters_are_the_shops_buckets_not_the_stat_rows, test_build_chains_behind_the_progression_view) and node tools/test_gallery.js for the rendered view.

Gotchas

Open questions