Gameplay data in vdata files
Question
Where do heroes, abilities, and purchasable items live, and what is the shape of that data once decompiled?
Summary
- Everything gameplay-related is in
scripts/*.vdata_c, which decompile to KV3 text — a Valve key-value format, human-readable and machine-parseable. scripts/heroes.vdata_c(84 KB packed → 2.1 MB text) holds hero definitions: 61 top-level keys, of which 60 are maps carrying anm_HeroID. One ishero_base(m_HeroID = 0), so there are 59 real heroes — 43 player-selectable, 19 disabled.scripts/abilities.vdata_c(485 KB → 7.0 MB text) holds both abilities and purchasable items in one file, discriminated bym_eAbilityType.scripts/generic_data.vdata_cholds global constants, including the item price table:m_nItemPricePerTier = [0, 800, 1600, 3200, 6400, 9999].- The single biggest correctness trap is unresolved inheritance — see Gotchas.
Findings
The files that matter
| path | packed | what |
|---|---|---|
scripts/abilities.vdata_c | 484.6 KB | abilities and items |
scripts/heroes.vdata_c | 83.7 KB | hero definitions |
scripts/npc_units.vdata_c | 28.7 KB | troopers, guardians, bosses |
scripts/misc.vdata_c | 20.9 KB | assorted gameplay constants |
scripts/generic_data.vdata_c | 11.7 KB | global constants, price table |
scripts/modifiers.vdata_c | 10.5 KB | modifier definitions |
scripts/scale_functions.vdata_c | 1.1 KB | stat scaling function defs |
scripts/loot_tables.vdata_c | 4.5 KB | loot |
scripts/ranked_seasons.vdata_c | 1.8 KB | ranked season config |
stats/tracked_stats_*.vdata_c | ~1.5 KB ea. | tracked match/player/team stats |
Also present: scripts/tarot/*.vdata_c, scripts/bots/bot_difficulty.vdata_c, 48 scripts/tagged_sounds/<hero>_anim_sounds.vdata_c, and ~110 .vrr_c response-rule files under scripts/talker/ (voice line logic, one per hero).
The full list is 77 .vdata_c under scripts/ plus 6 under stats/, 1 soundstacks/, and 1 at the archive root.
Decompiled shape (KV3)
heroes.vdata opens with:
<!-- kv3 encoding:text:version{e21c7f3c-...} format:generic:version{7412167c-...} -->
{
generic_data_type = "CitadelHeroData_t"
hero_base =
{
_class = "CitadelHeroData_t"
m_HeroID = 0
m_strModelName = resource_name:"models/heroes_staging/gen_man/gen_man.vmdl"
m_mapStartingStats =
{
EMaxHealth = 780.0
EMaxMoveSpeed = 7.2
ESprintSpeed = 1.6
EStamina = 3
EBaseHealthRegen = 2.0
...
}
Top-level keys are entry codenames (hero_base, hero_inferno, weapon_upgrade_t1, …).
Ability and item taxonomy
Counts of m_eAbilityType in abilities.vdata:
| value | count |
|---|---|
EAbilityType_Item | 277 |
EAbilityType_Signature | 241 |
EAbilityType_Weapon | 88 |
EAbilityType_Ultimate | 78 |
EAbilityType_Melee | 60 |
EAbilityType_Innate | 40 |
EAbilityType_Cosmetic | 3 |
Items are further split by m_eItemSlotType — WeaponMod 97, Armor 93, Tech 85, Invalid 3 — and by m_iItemTier — EModTier_1 42, _2 72, _3 72, _4 64, _5 27.
Of the 277 EAbilityType_Item entries, 84 are disabled, leaving 193 live items:
| slot | live entries |
|---|---|
Armor | 67 |
WeaponMod | 62 |
Tech | 62 |
| no slot type (base entries) | 2 |
Corrected 2026-08-15. This section previously reported 73 disabled and 204 live (Armor 75 / Tech 65 / WeaponMod 62). Those numbers came from the text search
body LIKE '%m_bDisabled = true%', which silently misses 11 entries — see Gotcha 7. The figures above come fromtools/kv3.py, which parses the document.
m_bDisabled is spelled three ways within this one file: the boolean true (73 entries), the string "true" (6), and the integer 1 (5). The negative cases vary too — false (20 entries) and "false" (1). Entries missed by a = true text search include upgrade_rocket_boots, upgrade_rebirth, upgrade_camouflage and upgrade_disarm.
None of the three EItemSlotType_Invalid entries is an item, so the disabled flag is the only filter that matters here.
Inferred: ~193 purchasable items in the live game, ~191 excluding the two base entries. Still an inference: "not disabled" is not proven to mean "purchasable in a match", and this was not checked against the in-game shop.
Annotated 2026-08-15. The 193 figure counts inheritance scaffolding as items. Of the 277 typed entries, 2 are slotless bases (
upgrade_base,ability_item_pickup_effects) and 80 have no localization name — 18 live tier-scaffolding entries ({armor,tech,weapon}_upgrade_{base,t1..t5}) plus 62 disabled test items. The named-shop-item census is therefore 195 named, 173 live (193 live = 173 named + 18 unnamed scaffolding + 2 slotless). Likewise, 5 of the 43m_bPlayerSelectableheroes are alsom_bDisabled(hero_boho,hero_fortuna,hero_graf,hero_skyrunner,hero_swan), so the shipping roster is 38, not 43. Both derivations are pinned intools/test_gallery.pyand reverified against the current build'ssite/gallery/data/gallery.jsonand the vdata.
Tier maps to cost via generic_data.vdata:
m_nItemPricePerTier =
[
0, 800, 1600, 3200,
6400, 9999,
]
Index 0 is a placeholder; EModTier_1 → 800 souls, and so on. The trailing 9999 is suspicious and unconfirmed — see Open Questions.
An item entry carries a stat map keyed by property name:
weapon_upgrade_t1 =
{
_class = "citadel_item"
m_mapAbilityProperties =
{
AbilityCooldown =
{
m_strValue = "0"
m_strCSSClass = "cooldown"
m_subclassScaleFunction = subclass: { _class = "scale_function_single_stat" ... }
}
}
_multibase = [ "upgrade_base", ]
m_eAbilityType = "EAbilityType_Item"
m_eItemSlotType = "EItemSlotType_WeaponMod"
m_nUpgradeSlotCost = 1
}
Reproduce
Decompile per note 0003, then:
cd out/scripts
grep -o 'm_eAbilityType = "[^"]*"' abilities.vdata | sort | uniq -c
grep -o 'm_eItemSlotType = "[^"]*"' abilities.vdata | sort | uniq -c
grep -o 'm_iItemTier = "[^"]*"' abilities.vdata | sort | uniq -c
grep -c 'm_HeroID = ' heroes.vdata
grep -A6 'm_nItemPricePerTier' generic_data.vdata
Listing the source files without decompiling:
python tools/vpk_list.py --prefix scripts/ --ext vdata_c --list
The item counts, parsed rather than grepped — this is the authoritative form, and the grep/LIKE variants above are only safe for fields that are consistently typed:
python tools/decompile.py --fetch # once, to populate out/
python -c "
import sys; sys.path.insert(0, 'tools')
from kv3 import parse, as_bool
import collections
doc = parse(open('out/scripts/abilities.vdata', encoding='utf-8').read())
items = {k: v for k, v in doc.items()
if isinstance(v, dict) and v.get('m_eAbilityType') == 'EAbilityType_Item'}
live = {k: v for k, v in items.items() if not as_bool(v.get('m_bDisabled'))}
print('items', len(items), 'disabled', len(items) - len(live), 'live', len(live))
print(collections.Counter(v.get('m_eItemSlotType', '(none)') for v in live.values()))"
Expected at build 6679: items 277 disabled 84 live 193.
Gotchas
- Inheritance is not resolved by the decompiler. Entries use
_classand_multibase = ["upgrade_base"]to inherit from other top-level entries in the same file. A naive per-entry parse yields incomplete stats — fields present on the base are simply absent from the child. This is the single largest source of wrong numbers in third-party Deadlock datasets.kv3.flatten()resolves it; see note 0007 for the grammar. - Stats are functions, not scalars.
m_subclassScaleFunctionencodes scaling by spirit/level. Readingm_strValuealone gives the base, not the in-game number. m_strValueis a string even when numeric ("0", not0). Coerce carefully._includeis already flattened.abilities.vdatalists ~30scripts/abilities/<hero>.vdata_incincludes, but those files are not in the VPK — the compiler merged them. Do not go looking for them.- Unreleased content is present. Entries exist for heroes not in the live game. Filter on
m_bPlayerSelectable/m_bDisabled/m_bInDevelopmentbefore publishing anything as "the hero roster". - A
grep -c 'm_HeroID'of 60 is not the roster size. It countshero_base(m_HeroID = 0), leaving 59; only 43 of those arem_bPlayerSelectable. The document also has a scalar top-level key (generic_data_type) that is not an entry at all, so "top-level keys" (61) is a third different number. - Booleans are not consistently typed, and text searches get them wrong. Within
abilities.vdata,m_bDisabledappears as a bool, as"true"/"false"strings, and as0/1integers. Grepping form_bDisabled = truemisses 11 disabled items; plain Python truthiness then misclassifies the string"false"as disabled. Usekv3.as_bool, and treat any boolean field in this data as multi-spelled. m_bDisabledprunes about a third of the item table. Publishing the raw 277 as "the item list" overstates it by 84.
Open questions
- The
9999at the end ofm_nItemPricePerTieris unexplained — placeholder, or a real tier-5-plus cost? Not confirmed. - Inheritance flattening has been identified but not implemented or validated against in-game values. No stat in this note has been checked against the live game UI.
Annotated 2026-08-15. Partially resolved. Flattening is implemented as
kv3.flatten(note 0007), and scale-function evaluation turned out to be fully machine-readable per property — class + coefficient (m_flStatScale) + stat (m_eSpecificStatScaleType), e.g. barrier = 325 + 1.8 × Spirit — documented in note 0009. The gallery still publishes base values only; nothing has been validated against the in-game UI.
- Whether a non-disabled item is genuinely "purchasable in a live match" — the 193 figure has not been checked against the in-game shop.
- Which other boolean fields use mixed spellings. Only
m_bDisabledwas audited. - The two live item entries with no
m_eItemSlotTypewere not identified by name. modifiers.vdataandscale_functions.vdatawere listed but not read.
Annotated 2026-08-15. Both read in note 0009:
modifiers.vdataholds 80 mostly map/game-state entries, andscale_functions.vdatais effectively empty (181 chars, no entries).
- The relationship between hero entries and their ability lists was not traced end to end.
.vrr_cresponse-rule format not examined.
Sources
Derived entirely from the local install at build 6679. No external sources used.