Deadlock Research
verifiednote 0009build 66792026-08-15vdatakv3abilitiesitemsmodifierslocalization

Machine-readable interaction and effect signals

Question

Which interaction/effect signals in the decompiled gameplay data are machine-readable — i.e. what can power cross-item/ability comparison features (arm/counter lists, flavor tags, "what stuns" queries), and what exists only as prose or as behavior compiled into game code?

Summary

Findings

All counts below are from out/citadel/scripts/abilities.vdata (decompiled per note 0003) at build 6679, parsed with tools/kv3.py — never grepped, per note 0007. The file has 797 top-level keys, 795 of which are entry maps.

m_eProvidedPropertyType: the property enum vocabulary

94 distinct enums, 2,688 occurrences, every one spelled MODIFIER_VALUE_*. The totals are identical before and after kv3.flatten at this build. Top of the distribution:

occurrencesenum
809MODIFIER_VALUE_TECH_POWER
784MODIFIER_VALUE_WEAPON_POWER
113MODIFIER_VALUE_MOVEMENT_SPEED_SLOW_PERCENT
79MODIFIER_VALUE_FIRE_RATE
66MODIFIER_VALUE_HEALTH_MAX
65MODIFIER_VALUE_MOVEMENT_SPEED_MAX
60MODIFIER_VALUE_WEAPON_DAMAGE_INCREASE
45MODIFIER_VALUE_TECH_RESIST
42MODIFIER_VALUE_BULLET_ARMOR_DAMAGE_RESIST
31MODIFIER_VALUE_FIRE_RATE_SLOW

A property that carries one of these declares, in a typed and comparable way, what stat it grants. This is the signal the gallery's arm/counter mapping consumes — the enum → stat table, with per-enum live-item counts and the sign convention, is the STAT_ITEM_SIGNALS comment table in tools/build_gallery.py (e.g. HEALTH_MAX + → 51 live items, MOVEMENT_SPEED_SLOW_PERCENT + → 16, MELEE_RESIST + → 6), pinned by tools/test_gallery.py.

Damage typing: the enum is a trigger filter, not an output label

Occurrences of CITADEL_DAMAGETYPE_* values in abilities.vdata, by carrying key:

keyoccurrencesrole
m_vecProcDamageTypes30 (42 enum values)incoming filter: which damage types proc this effect
m_vecDamageTypes1 (upgrade_juggernaut)incoming filter on a proc watcher
m_eDamageType1 (ability_werewolf_transformation_trigger)outgoing, inside an m_WeaponInfo weapon config

So as an output classifier the enum appears on exactly 1 of 795 entries — and even that one is a weapon config on a trigger ability. m_vecProcDamageTypes values: ABILITY 17, MELEE 14, BULLET 10, PURE 1.

The practical flavor signals are elsewhere:

CC application channels

CC effects are expressed through four parallel, partially overlapping channels:

1. Embedded modifier dicts. 1,261 embedded _class = "modifier*" declarations across 543 entries; 977 sit under a named dict field, the other 284 inside arrays. The container fields name the role: m_DebuffModifier 100, m_BuffModifier 73, m_modifierProvidedByAura 68, m_SlowModifier 40, m_TargetModifier 22, m_AuraModifier 18, m_BuildUpModifier 16, m_SilenceModifier 12.

2. State masks. m_nEnabledStateMask appears 167 times; masks are |-joined strings of MODIFIER_STATE_* tokens — 110 distinct tokens, 357 total uses. The CC-relevant slice, per state:

state tokenusesstate tokenuses
MODIFIER_STATE_DISARMED16MODIFIER_STATE_SLOW_IMMUNE13
MODIFIER_STATE_SLOWED15MODIFIER_STATE_STATUS_IMMUNE12
MODIFIER_STATE_SILENCED13MODIFIER_STATE_KNOCKDOWN_IMMUNE12
MODIFIER_STATE_IMMOBILIZED8MODIFIER_STATE_UNSTOPPABLE12
MODIFIER_STATE_STUNNED3MODIFIER_STATE_INVULNERABLE6

Most of the 110-token vocabulary is not CC: zipline, camera, boss-fight and hero-specific bookkeeping states (MODIFIER_STATE_ZIPLINE_INTRO, MODIFIER_STATE_SINCLAIR_TAX_ULT_ACTIVE, …).

3. Modifier _class references. Among the embedded dicts: modifier_slow_base 67, modifier_citadel_silenced 22, modifier_diminishing_slow 17, modifier_unstoppable 10, modifier_citadel_root 7, modifier_citadel_disarmed 5, modifier_citadel_staticcharge 3. (The generic modifier_base 283 and modifier_intrinsic_base 138 carry no CC meaning by themselves.)

4. Property-name conventions. Flattened m_mapAbilityProperties names, by keyword: *Slow* 284 uses / 52 names, *Stun* 45 / 11 (StunDuration, DelayBeforeStun, StunDelay, …), *Silence* 12 / 8, *Sleep* 12 / 7, *Knock* 11 / 10, *Disarm* 6 / 3, *Immobilize* 6 / 1, *Root* 1 / 1.

Purgeability is flagged, not explained: m_eDebuffType = MODIFIER_DEBUFF_YES 15, MODIFIER_DEBUFF_NO 38, MODIFIER_DEBUFF_ENEMY_TEAM_ONLY 5; and MODIFIER_ATTRIBUTE_CANNOT_BE_PURGED appears on 27 lines (26 in m_nAttributes, 1 inlined into a state mask). What a purge removes is nowhere in the data.

Inferred: these channels make "which entries touch stun/slow/silence" answerable per channel, but there is no unified CC taxonomy — any cross-ability CC feature must union the channels and accept that coverage is uneven (STUNNED as a state appears 3 times while *Stun* property names appear 45 times).

Scale functions

m_subclassScaleFunction classes across the document:

classcount
scale_function_single_stat3,119
scale_function_multi_stats788
scale_function_ability_recharge_time714
scale_function_ability_charges713
scale_function_tech_damage269
scale_function_tech_duration184
scale_function_tech_range42
scale_function_healing_spirit_scale22
scale_function_healing_boon_scale8
(none / empty / other)87

A property's scaling is class + coefficient + stat, verbatim from upgrade_vex_barrier (Reactive Barrier):

VexBarrierCombatBarrier =
{
	m_strLocTokenOverride = "CombatBarrier"
	m_strValue = "325"
	m_strCSSClass = "combat_barrier"
	m_eStatsUsageFlags = "ConditionallyApplied"
	m_eProvidedPropertyType = "MODIFIER_VALUE_BARRIER_HEALTH"
	m_subclassScaleFunction = subclass:
	{
		_class = "scale_function_single_stat"
		_my_subclass_name = "CombatBarrier_scale_function"
		m_eSpecificStatScaleType = "ETechPower"
		m_flStatScale = 1.8

i.e. barrier = 325 + 1.8 × Spirit (ETechPower). The m_eSpecificStatScaleType vocabulary has 26 non-empty values, led by ETechDuration 966, ETechRange 909, ETechCooldown 513, EItemCooldown 292, ETechPower 192.

scripts/scale_functions.vdata is effectively empty: 181 characters, a single scalar generic_data_type = "CScaleFunctionVData", no entries. The scale-function definitions live inline in abilities.vdata; their evaluation lives in code.

The localization layer

The merged English artifact (build_content.localization_artifact()) holds 6,830 tokens. Descriptions embed a semi-structured effect vocabulary, {g:citadel_inline_attribute:'X'}: 33 distinct names, 269 occurrences. Top counts:

namennamen
SpiritDamage87SpiritDPS9
Slow24SpiritResist9
MeleeDamage14Stun9
WeaponDamage13BonusFireRate7
BonusSpiritDamage12Heal7
BonusMoveSpeed11BulletResist6

This vocabulary is the closest thing to an effect taxonomy the data offers — but it annotates prose, so it tags mentions, not appliers.

Measured unreliability of prose keyword tagging. Of the 173 live named shop items, 9 have a _desc mentioning "stun" (case-insensitive). Checked against structural signals (a *Stun* property name, a MODIFIER_STATE_STUNNED mask, or a stun modifier class), 5 carry one and 4 do not — they mention stun as immunity, removal, a reactive trigger, or a block:

itemmention is
upgrade_unstoppable (Unstoppable)immunity — "become immune to Stun, Silence, Sleep, Root, and Disarm"
upgrade_divine_barrier (Divine Barrier)removal — "Remove all non-stun debuffs"
upgrade_vex_barrier (Reactive Barrier)reactive — "Gain a Barrier when you are Stunned, …"
upgrade_cloak_of_opportunity (Cloak of Opportunity)block — "Block the next debuff that would apply … Stun …"

The four descriptions and the structural check are observations. Inferred: a naive keyword tagger runs a ~4/9 false-positive rate on "stun", and the same immunity/removal/reactive pattern will poison any other effect keyword ("immun" appears in 4 live item descriptions: upgrade_magic_carpet, upgrade_metal_skin, upgrade_proc_silence, upgrade_unstoppable). The rate for other keywords was not measured.

Case study: "shock" is not a status

Three unrelated mechanics share the word (all descriptions quoted from the English localization; bindings from heroes.vdata):

No MODIFIER_VALUE_* enum contains SHOCK; the only shock-named state is MODIFIER_STATE_IS_MAGIC_SHOCK_IMMUNE (1 use). Inferred: a "shock synergy" comparison feature cannot be derived from this data — the word does not denote a mechanic.

modifiers.vdata: the definitions are not here

scripts/modifiers.vdata has 81 top-level keys, 80 entry maps — overwhelmingly map/game-state machinery: teleporters, ziplines, cinematic intros, fountain/shop-tunnel states, boss phases (modifier_citadel_hideout_teleport, modifier_cinematic_intro_*, modifier_streetbrawl_*, …).

abilities.vdata references 761 distinct modifier* strings (as _class values and by-name references). Exactly 9 of them are top-level entries in modifiers.vdata (modifier_citadel_stunned, modifier_citadel_knockdown, modifier_citadel_disarmed, modifier_barrier_tracker, …); 71 of the 80 modifiers.vdata entries are never referenced from abilities at all.

Inferred: modifier semantics — what modifier_slow_base actually does to a character — live in compiled game code keyed by class name. The vdata only parameterises them (durations, values, state masks). No data-only pipeline can recover behavior for the other ~750 names.

What this enables — and what stays out

Enabled, and shipped as the gallery's Phase-1 features (derivations pinned in tools/test_gallery.py):

Stays out, on the evidence above:

Reproduce

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

python tools/find_game.py    # expect ClientVersion 6679

# 1. m_eProvidedPropertyType: 94 distinct, 2688 occurrences
python -c "
import sys, collections; sys.path.insert(0, 'tools')
from kv3 import parse
doc = parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read())
def walk(n):
    if isinstance(n, dict):
        for k, v in n.items(): yield k, v; yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
c = collections.Counter(v for k, v in walk(doc) if k == 'm_eProvidedPropertyType')
print(len(c), sum(c.values()), c.most_common(10))"

# 2. damage typing: CITADEL_DAMAGETYPE_* carriers, damage flag, css classes
python -c "
import sys, collections; sys.path.insert(0, 'tools')
from kv3 import parse, flatten, as_bool
doc = parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read())
def walk(n):
    if isinstance(n, dict):
        for k, v in n.items(): yield k, v; yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
bykey = collections.Counter()
for k, v in walk(doc):
    for x in (v if isinstance(v, list) else [v]):
        if isinstance(x, str) and x.startswith('CITADEL_DAMAGETYPE'): bykey[k] += 1
print(dict(bykey))
flags = [v for k, v in walk(doc) if k == 'm_bIsAbilityDamageProperty']
print('damage flags', len(flags), 'true', sum(map(as_bool, flags)))
css = collections.Counter()
for e in flatten(doc).values():
    if isinstance(e, dict):
        for p in (e.get('m_mapAbilityProperties') or {}).values():
            if isinstance(p, dict) and as_bool(p.get('m_bIsAbilityDamageProperty')):
                css[p.get('m_strCSSClass')] += 1
print(dict(css))"

# 3. CC channels: state masks, modifier classes, property names, purge flags
python -c "
import sys, re, collections; sys.path.insert(0, 'tools')
from kv3 import parse, flatten
doc = parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read())
def walk(n):
    if isinstance(n, dict):
        for k, v in n.items(): yield k, v; yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
toks = collections.Counter()
masks = [v for k, v in walk(doc) if k == 'm_nEnabledStateMask']
for v in masks:
    for t in re.split(r'[|\s]+', str(v)):
        if t: toks[t] += 1
print('masks', len(masks), 'distinct tokens', len(toks), 'STUNNED', toks['MODIFIER_STATE_STUNNED'])
cls = collections.Counter(v for k, v in walk(doc)
                          if k == '_class' and str(v).startswith('modifier'))
print('embedded modifier decls', sum(cls.values()), 'slow_base', cls['modifier_slow_base'])
names = collections.Counter()
for e in flatten(doc).values():
    if isinstance(e, dict):
        for p in (e.get('m_mapAbilityProperties') or {}): names[p] += 1
print('*Stun* names', sum(n for p, n in names.items() if 'stun' in p.lower()))
print('m_eDebuffType', dict(collections.Counter(v for k, v in walk(doc) if k == 'm_eDebuffType')))"
grep -c 'MODIFIER_ATTRIBUTE_CANNOT_BE_PURGED' out/citadel/scripts/abilities.vdata   # 27

# 4. scale functions + modifiers.vdata
python -c "
import sys, collections; sys.path.insert(0, 'tools')
from kv3 import parse
doc = parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read())
def walk(n):
    if isinstance(n, dict):
        for k, v in n.items(): yield k, v; yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
print(collections.Counter(v.get('_class') for k, v in walk(doc)
      if k == 'm_subclassScaleFunction' and isinstance(v, dict)).most_common(6))
sf = open('out/citadel/scripts/scale_functions.vdata', encoding='utf-8').read()
print('scale_functions.vdata chars', len(sf), 'keys', list(parse(sf)))
md = parse(open('out/citadel/scripts/modifiers.vdata', encoding='utf-8').read())
ments = {k for k, v in md.items() if isinstance(v, dict)}
refs = {x for k, v in walk(doc) for x in (v if isinstance(v, list) else [v])
        if isinstance(x, str) and x.startswith('modifier')}
print('modifiers.vdata entries', len(ments), 'referenced names', len(refs),
      'defined there', len(refs & ments))"
grep -n -A12 'VexBarrierCombatBarrier = ' out/citadel/scripts/abilities.vdata | head -14

# 5. localization vocabulary + the stun prose audit
python -c "
import sys, re, json, collections; sys.path.insert(0, 'tools')
import build_content as bc
from kv3 import parse, flatten, as_bool
tokens = json.loads(next(iter(bc.localization_artifact().values())))
vocab = collections.Counter(m.group(1) for v in tokens.values()
        for m in re.finditer(r\"\{g:citadel_inline_attribute:'(\w+)'\}\", v))
print('inline attrs', len(vocab), sum(vocab.values()), vocab.most_common(5))
flat = flatten(parse(open('out/citadel/scripts/abilities.vdata', encoding='utf-8').read()))
live = {k: v for k, v in flat.items() if isinstance(v, dict)
        and v.get('m_eAbilityType') == 'EAbilityType_Item'
        and not as_bool(v.get('m_bDisabled')) and k in tokens
        and v.get('m_eItemSlotType') in ('EItemSlotType_WeaponMod', 'EItemSlotType_Armor', 'EItemSlotType_Tech')}
def walk(n):
    if isinstance(n, dict):
        for k, v in n.items(): yield k, v; yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
def stuns(e):
    return any('stun' in p.lower() for p in (e.get('m_mapAbilityProperties') or {})) or \
           any(k == 'm_nEnabledStateMask' and 'MODIFIER_STATE_STUNNED' in str(v) or
               k == '_class' and 'stun' in str(v).lower() for k, v in walk(e))
hits = [k for k in live if re.search('stun', tokens.get(k + '_desc', ''), re.I)]
print('live named items', len(live), 'desc mentions stun', len(hits),
      'without structural stun', [k for k in hits if not stuns(live[k])])"

# 6. shock: names, bindings, ShockDelay label
python -c "
import sys, json; sys.path.insert(0, 'tools')
import build_content as bc
from kv3 import parse
tokens = json.loads(next(iter(bc.localization_artifact().values())))
print(tokens['upgrade_chain_lightning'], '|', tokens['ShockDelay_label'])
heroes = parse(open('out/citadel/scripts/heroes.vdata', encoding='utf-8').read())
for hk, hv in heroes.items():
    if isinstance(hv, dict):
        for slot, ab in (hv.get('m_mapBoundAbilities') or {}).items():
            if ab in ('ability_power_surge', 'citadel_ability_static_charge'):
                print(hk, slot, ab)"

Expected: 94 2688; damagetype carriers {'m_vecProcDamageTypes': 42, 'm_vecDamageTypes': 1, 'm_eDamageType': 1} (the snippet counts enum values; the 42 sit under 30 m_vecProcDamageTypes keys); damage flags 169 / true 168; masks 167 / distinct 110; embedded modifier decls 1261; *Stun* names 45; scale classes led by scale_function_single_stat 3119; modifiers 80 entries / 761 referenced / 9 defined; inline attrs 33 / 269; stun audit 173 live, 9 mentions, 4 without structural stun; Tesla Bullets | Delay Before Stun; hero_gigawatt bindings for both abilities.

Gotchas

  1. m_nEnabledStateMask is a |-joined string, not a list. Split on | before counting states, or every combined mask becomes a phantom unique value.
  2. Do not treat m_strCSSClass as a taxonomy. It is a styling hint with ~190 distinct values, most of them one-off item names. Only the damage/healing handful (tech_damage, bullet_damage, melee_damage, healing) reliably co-occurs with m_bIsAbilityDamageProperty.
  3. CITADEL_DAMAGETYPE_* looks like an output classifier and is not. 31 of its 32 carrying keys' occurrences are incoming trigger filters. Classifying "what type of damage does X deal" from this enum yields one classified entry out of 795.
  4. Keyword-tagging descriptions inverts meaning. Nearly half the "stun" mentions in live item descriptions are stun immunity, removal or reaction. Prose mentions also hide inside {g:citadel_inline_attribute:'Stun'} templates.
  5. Display names are not unique keys. citadel_ability_static_charge and its _v2 variant share the display name "Static Charge"; a name→codename reverse map silently keeps whichever was seen last. Join on codenames (note 0004).
  6. Boolean spellings still bite here. m_bIsAbilityDamageProperty and m_bDisabled must be read with kv3.as_bool (note 0007); a = true text search undercounts.
  7. Raw-vs-flattened counts happening to match is luck, not a rule. The m_eProvidedPropertyType totals are identical before and after kv3.flatten at this build; per-entry statistics must still be computed on flattened entries.

Open questions

Sources

Derived entirely from the local install at build 6679, tools/kv3.py, tools/build_content.py, tools/build_gallery.py and tools/test_gallery.py. No external sources used.