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":
- 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?
- What shape does
m_vecComponentItemsactually make when you stop looking at one item at a time and take the whole relation at once?
Summary
m_eShopFiltersis the game's own item grouping and it is a plain top-level field on the item entry: a|-joined token set, exactly likem_nEnabledStateMask. 9 distinct tokens at build 6679, carried by 153 of the 173 live named shop items; 20 carry none.- No token has a localization entry. Searching all 6,830 English strings for the tokens returns nothing, so any display name for them is derived, not the game's.
- A token is where the shop files an item, not a reading of its stat rows. Extra Health — an Armor-slot item whose only bonus is
+210 Bonus Health— carriesEShopFilterWeaponDamage. Treating the filter as "what this item gives you" publishes a false claim; it is closer to "what build this item belongs in". - The component relation is sparse and shallow. Over the 156 items the gallery shows by default (live, non-arena), it forms 35 connected chains covering 99 items, and leaves 57 items with no relative at all. Chain sizes run 2–7.
- Chains have a single entry point, nearly always. 33 of the 35 have exactly one root (a member with no component inside the chain); 2 have a pair. 3 chains cross shop slots, so slot is not a property of a chain.
- Inferred: the 20 filterless items are the shop's utility shelf (Refresher, Cheat Death, Vortex Web, …). The data says only that they carry no token — no field explains why.
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 carriers | token | derived label |
|---|---|---|
| 54 | EShopFilterDurability | Durability |
| 52 | EShopFilterWeaponDamage | Weapon Damage |
| 52 | EShopFilterMagicDamage | Magic Damage |
| 38 | EShopFilterHealing | Healing |
| 30 | EShopFilterMovement | Movement |
| 28 | EShopFilterFireRate | Fire Rate |
| 25 | EShopFilterDisruption | Disruption |
| 18 | EShopFilterClipSize | Clip Size |
| 8 | EShopFilterMelee | Melee |
Tokens per item, over the 173 live named items:
| tokens | items |
|---|---|
| 0 | 20 |
| 1 | 44 |
| 2 | 75 |
| 3 | 26 |
| 4 | 7 |
| 5 | 1 |
Two observations worth separating from the table:
- Widening the set from "live named" to "live typed + slotted" adds the 18 unnamed tier-scaffolding entries (
weapon_upgrade_t3,item_projectile_t1, …) and the filter-carrier count stays at 153 — none of the scaffolding entries carries a token. The field is only ever set on entries the shop actually lists. - The tokens' order within an item's string is stable across reads but carries no ranking that any other field corroborates; the gallery preserves it rather than sorting it, and orders its own sections by carrier count instead.
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 (EShopFilterMagicDamage → Magic 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:
| item | slot | filters | stat rows |
|---|---|---|---|
| Extra Health | Armor | Weapon Damage, Durability | -1s Charge Delay, +210 Bonus Health |
| Extra Spirit | Tech | Magic Damage, Healing | -1s Charge Delay, +10 Spirit Power |
| Refresher | Tech | (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 items | 156 |
| connected chains (≥2 items) | 35 |
| items inside a chain | 99 |
| items with no visible relative | 57 |
Chain sizes: 21 pairs, 8 triples, 2 fours, one five, one six, two sevens. The two seven-item chains:
- Grit → Guardian Ward, Reactive Barrier, Spirit Shielding, Weapon Shielding → Divine Barrier, Indomitable — tiers 1, 2, 4, all Armor.
- Bullet Lifesteal + Spirit Lifesteal → Fury Trance → Infuser, Leech, Spiritual Overflow, Vampiric Burst — tiers 2–4, crossing Weapon and Armor.
Two structural facts the layout depends on:
- Roots: 33 chains have exactly one member with no component inside the chain; 2 have two (the lifesteal line above, and High-Velocity Rounds + Long Range). Naming a chain after its root(s) is therefore almost always a single name.
- Tiers are not contiguous: Grit's line occupies tiers 1, 2 and 4 with nothing at 3. A renderer that assumes T1→T2→T3→T4 columns draws an empty column.
- 3 chains cross slots (the stamina line, the Mystic Expansion line, the lifesteal line), so a chain has no single slot colour.
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
m_eShopFiltersis a token set, not a string. Splitting is on|with surrounding whitespace; comparing the raw string equal to a token silently misses every item with more than one, which is 109 of the 153 carriers.- Do not present a filter as "what the item gives you." Extra Health under Weapon Damage is the counter-example that breaks that reading. The gallery's progression view labels the axis "shop filter" and says so in the page caption for exactly this reason.
- Filters are absent, not empty, on 20 live items. The field is missing rather than
"", soentry["m_eShopFilters"]raises whereentry.get(...)is correct. - Chains are built from
m_vecComponentItemsonly. The reverse direction is derived, not stored: nothing in the data says "builds into". Walking both directions of the same derived relation is what makes a family come out whole from any member. - A chain is not a tier ladder. Tiers can skip (Grit's line has nothing at tier 3) and a chain can hold several items at one tier (four at tier 2 in that same line), so the layout has to follow the tiers present, not 1..4.
- The relation only ties items that are both visible. Filtering the item set first — by search text, or by hiding disabled/arena items — can split a chain into loose items, because its connecting member is gone. That is the gallery's behaviour by design; it is not a data change.
Open questions
- Where the filter buttons' captions live. The panorama layout and CSS files (
panorama/styles/citadel_hud_hero_shop.vcss_cand neighbours) were not decompiled or read, so the derived labels are unconfirmed against the shop UI. - Whether token order means anything. It is stable, but no other field was found that would corroborate a primary/secondary reading, and no attempt was made to correlate it with the shop's display order.
- Disabled items were not analysed. 20 of the 22 disabled named items carry filter tokens; nothing here checks whether those are stale.
- Cross-build stability is untested. Every count is from 6679 alone; no earlier build was indexed to see whether the token vocabulary or the chain shapes move between patches.
- Why the arena items have no components at all — that none of the 17 carries a component or a reverse reference is verified, but nothing here explains it, and no other field was examined for an arena-mode marker beyond the sentinel price.