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
- The strongest signal is
m_eProvidedPropertyType: 94 distinctMODIFIER_VALUE_*enums, 2,688 occurrences inabilities.vdata. Typed, comparable across entries — this is what the gallery's statlinks are built on. - Damage "type" enums are not an output classifier.
CITADEL_DAMAGETYPE_*appears as outgoing typing on exactly 1 of 795 entries; its real use is incoming trigger filters (m_vecProcDamageTypes, 30 occurrences). Damage flavor comes fromm_bIsAbilityDamageProperty+m_strCSSClassinstead. - CC is applied through several parallel channels (embedded modifier dicts, state masks, modifier
_classreferences, property-name conventions) — usable individually, but there is no single field meaning "this ability stuns". - Scaling is fully machine-readable per property:
m_subclassScaleFunctioncarries a class, a coefficient and a stat.scripts/scale_functions.vdataitself is effectively empty (181 chars, no entries). - Modifier semantics live in code, not data: abilities reference 761 distinct
modifier*names, only 9 of which are defined inmodifiers.vdata(80 entries, mostly map/game-state machinery). - Prose is a bad tagger: of the 9 live items whose description mentions "stun", 4 are immunity/removal/reactive mentions, not stun appliers. "Shock" is three unrelated mechanics sharing a word.
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:
| occurrences | enum |
|---|---|
| 809 | MODIFIER_VALUE_TECH_POWER |
| 784 | MODIFIER_VALUE_WEAPON_POWER |
| 113 | MODIFIER_VALUE_MOVEMENT_SPEED_SLOW_PERCENT |
| 79 | MODIFIER_VALUE_FIRE_RATE |
| 66 | MODIFIER_VALUE_HEALTH_MAX |
| 65 | MODIFIER_VALUE_MOVEMENT_SPEED_MAX |
| 60 | MODIFIER_VALUE_WEAPON_DAMAGE_INCREASE |
| 45 | MODIFIER_VALUE_TECH_RESIST |
| 42 | MODIFIER_VALUE_BULLET_ARMOR_DAMAGE_RESIST |
| 31 | MODIFIER_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:
| key | occurrences | role |
|---|---|---|
m_vecProcDamageTypes | 30 (42 enum values) | incoming filter: which damage types proc this effect |
m_vecDamageTypes | 1 (upgrade_juggernaut) | incoming filter on a proc watcher |
m_eDamageType | 1 (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:
m_bIsAbilityDamageProperty— 169 occurrences, 168true(booleans read withkv3.as_bool, per note 0007).m_strCSSClasson damage-flagged properties (flattened):tech_damage144,melee_damage11,bullet_damage7, none 4,healing1,damage1. The overallm_strCSSClassvocabulary is a long tail of one-off styling names (glassCannon,mokrillScorn, …); only this damage/healing handful is load-bearing.scale_function_tech_damage— 269 properties scale as spirit damage, a usable "this number is spirit damage" marker.
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 token | uses | state token | uses | |
|---|---|---|---|---|
MODIFIER_STATE_DISARMED | 16 | MODIFIER_STATE_SLOW_IMMUNE | 13 | |
MODIFIER_STATE_SLOWED | 15 | MODIFIER_STATE_STATUS_IMMUNE | 12 | |
MODIFIER_STATE_SILENCED | 13 | MODIFIER_STATE_KNOCKDOWN_IMMUNE | 12 | |
MODIFIER_STATE_IMMOBILIZED | 8 | MODIFIER_STATE_UNSTOPPABLE | 12 | |
MODIFIER_STATE_STUNNED | 3 | MODIFIER_STATE_INVULNERABLE | 6 |
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:
| class | count |
|---|---|
scale_function_single_stat | 3,119 |
scale_function_multi_stats | 788 |
scale_function_ability_recharge_time | 714 |
scale_function_ability_charges | 713 |
scale_function_tech_damage | 269 |
scale_function_tech_duration | 184 |
scale_function_tech_range | 42 |
scale_function_healing_spirit_scale | 22 |
scale_function_healing_boon_scale | 8 |
| (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:
| name | n | name | n | |
|---|---|---|---|---|
SpiritDamage | 87 | SpiritDPS | 9 | |
Slow | 24 | SpiritResist | 9 | |
MeleeDamage | 14 | Stun | 9 | |
WeaponDamage | 13 | BonusFireRate | 7 | |
BonusSpiritDamage | 12 | Heal | 7 | |
BonusMoveSpeed | 11 | BulletResist | 6 |
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:
| item | mention 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):
- Tesla Bullets (
upgrade_chain_lightning, item): "Your bullets have a chance to shock your target. The shock will jump to a nearby enemy." — chaining bullet-proc damage. The entry has noShock*-named property; "shock" here is prose only. - Power Surge (
ability_power_surge,hero_gigawattESlot_Signature_3): "Power up your weapon with a shock effect, making your bullets proc shock damage … This shock damage bounces to enemies near your target." — a weapon empower, again chaining damage. - Static Charge (
citadel_ability_static_charge,hero_gigawattESlot_Signature_2, plus a_v2variant sharing the display name): carries aShockDelayproperty whose label tokenShockDelay_labelresolves to "Delay Before Stun" — here "shock" is a stun. - Flavor-only uses exist too:
ability_punkgoat_ult's description says the slam "creates a shockwave", andability_frank_shocktarget2hasBonusShocks*properties for its bolt volley.
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):
- Ability flavor accents from structured fields only:
m_bIsAbilityDamageProperty+m_strCSSClass(+ sustain enums /scale_function_healing_*); over the 38×4 playable abilities this yields 99 spirit / 10 weapon / 15 vitality / 28 untagged. - Statlinks (arm/counter per starting stat) from
m_eProvidedPropertyTypeenums with value signs — the enum → stat table with per-enum live-item counts is theSTAT_ITEM_SIGNALScomment block intools/build_gallery.py, which also documents what was deliberately not mapped (OUT_OF_COMBAT_HEALTH_REGEN,MOVE_SPEED_LIMIT, …) because mapping them would be a guess rather than a field-backed claim.
Stays out, on the evidence above:
- Prose-only effects (shock, shockwave, most "immune to X" claims) — no structured counterpart to join on.
- Code-defined modifier behavior — 761 referenced names, 9 data-defined.
- Cleanse/purge semantics — the data flags purgeability but never defines what a purge removes.
- A unified CC taxonomy — four channels with uneven coverage, no single "applies stun" field.
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
m_nEnabledStateMaskis a|-joined string, not a list. Split on|before counting states, or every combined mask becomes a phantom unique value.- Do not treat
m_strCSSClassas 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 withm_bIsAbilityDamageProperty. 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.- 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. - Display names are not unique keys.
citadel_ability_static_chargeand its_v2variant share the display name "Static Charge"; a name→codename reverse map silently keeps whichever was seen last. Join on codenames (note 0004). - Boolean spellings still bite here.
m_bIsAbilityDamagePropertyandm_bDisabledmust be read withkv3.as_bool(note 0007); a= truetext search undercounts. - Raw-vs-flattened counts happening to match is luck, not a rule. The
m_eProvidedPropertyTypetotals are identical before and afterkv3.flattenat this build; per-entry statistics must still be computed on flattened entries.
Open questions
m_eStatsUsageFlags(e.g."ConditionallyApplied") was observed but its vocabulary and semantics were not surveyed.- Whether
CITADEL_DAMAGETYPE_ABILITYin proc filters covers item-proc'd spirit damage was not tested — filter semantics are code. - The buildup system (
m_BuildUpModifier,EBuildUpRate,modifier_citadel_base_buildup) was counted but not traced. - Only "stun" and "immun" were audited for prose false positives; rates for slow, silence, sleep, root etc. were not measured.
- Only
abilities.vdatawas surveyed for these fields; hero innates inheroes.vdataandnpc_units.vdatawere not. - No CC duration, scale coefficient or barrier value was checked against the in-game UI.
- Whether the
_v2Static Charge replaces the bound v1 at runtime (an AbilitySwap-style mechanism?) was not investigated.
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.