Deadlock
Reverse-engineering the data formats of Deadlock (Valve, Source 2 — internal codename Citadel), with the goal of extracting game data — heroes, purchasable items, abilities — into usable structured form.
Nothing here is documented by Valve. Everything we know is derived from a specific installed build and re-verified when the game patches.
Start here
git clone https://github.com/coolhome/Deadlock && cd Deadlock
python tools/install_hooks.py # one-time: enables the docs site build hook
python tools/find_game.py # locate your install, print its build stamp
python tools/vpk_list.py # see what's in the archives
Then read docs/research/ — five notes covering archive layout, where gameplay data lives, the decompilation toolchain, localization, and prior art.
| tool | what it does |
|---|---|
| find_game.py | Locate the install across Windows/Linux/macOS Steam libraries; print the build stamp |
| vpkdb.py | Index every VPK into SQLite; search, inspect, extract, diff across patches |
| vpk_list.py | One-shot VPK parser and raw entry reader — the layer vpkdb.py is built on |
| kv3.py | Parse KV3 text; resolve _multibase inheritance; as_bool for its three boolean spellings |
| kv1.py | Parse KeyValues v1 (.txt/.res/.vdf); resolve platform conditionals; sniff KV1 vs KV3 |
| serialize.py | Dependency-free YAML emitter for the parsers' output |
| decompile.py | Fetch and drive the Source 2 Viewer CLI into out/ |
| build_site.py | Render the Markdown and the explorer into site/ |
| build_explorer.py | Generate the browsable file explorer from the index |
| build_content.py | Generate the file contents the explorer renders |
| fetch_openapi.py | Snapshot the community APIs' OpenAPI specs into tools/data/openapi/ |
| build_api.py | Render those snapshots into the static /api/ reference |
| install_hooks.py | Point core.hooksPath at tools/hooks/ |
Everything is stdlib-only Python 3.10+. No dependencies, no virtualenv, no setup step.
Exploring the game files
tools/vpkdb.py build indexes all 21 archives — 134,166 entries, 28.2 GiB — into a single SQLite file in about three seconds. After that, exploring is queries rather than re-parsing:
python tools/vpkdb.py build
python tools/vpkdb.py sniff # identify formats from headers
python tools/vpkdb.py find 'scripts/*.vdata_c' --sort size
python tools/vpkdb.py show scripts/heroes.vdata_c
python tools/vpkdb.py cat scripts/items/items_game.txt # raw bytes, no decompiler
python tools/vpkdb.py stats --by dir
python tools/vpkdb.py diff db/build-6600.db # what a patch changed
sniff reads the leading bytes of all 134,166 entries in ~12s and records what each file actually is — 133,657 are Source 2 resources — plus each resource's version and its block tags, which say what it contains. PHYS appears only on models, SrMa only on stylesheets. See note 0006.
Everything takes --json, and sql is a read-only escape hatch for anything the subcommands miss:
python tools/vpkdb.py sql "SELECT ext, count(*) n FROM file GROUP BY ext ORDER BY n DESC LIMIT 5"
Once you have decompiled output (see note 0003), index-text adds full-text search over KV3 entries:
python tools/vpkdb.py index-text out/
python tools/vpkdb.py search "EItemSlotType_Tech EModTier_4"
The database lives in db/ and is gitignored — it is derived data, and it rebuilds in seconds. Because every entry carries its CRC32, diff between two builds gives an exact list of what a patch added, removed, or changed.
Browsing it in a browser
deadlock.xerodefect.ai/explorer/ is a file explorer over the same index — directory tree, virtualised listing of all 134,166 entries, search, and type/mount/block filters. Selecting a file shows its detected format, resource version, block tags, and the vpkdb.py command to extract it locally. Clicking a block chip filters the listing to every resource carrying that block.
It is generated by tools/build_explorer.py as part of build_site.py, so vpkdb.py build then build_site.py refreshes it. If the index has not been built, the page renders a placeholder instead of breaking the nav.
Search notes: a leading . matches extensions only (.vdata_c), a / makes the query match against the full path, and search is always global rather than scoped to the open directory.
The gallery
deadlock.xerodefect.ai/gallery/ is the extracted data made visible: a hero selector (each hero's four abilities with icons, cooldowns and descriptions) and a shop-style item viewer (195 named items by slot and tier, with costs, stats, descriptions and build paths). Everything is resolved at build time by tools/build_gallery.py — inheritance flattened, localization joined, description templates substituted, small icons inlined — so the page's JavaScript only renders. Toggles reveal the 21 unreleased heroes and 22 disabled items hidden by default. Detail views render through the vendored ability-card design kit (tools/assets/); card art always comes from the payload's resolved icon field, never a raw game file path.
A third tab, item progression, lays the same items out along the build chains they form — 35 chains over the 156 shown by default, 57 items with no relative — and files those chains under a category axis: the shop's own filter tokens, the crowd control an item applies, or its shop slot. Both the chain topology and the filter vocabulary are derived in note 0012; the filter tokens are the buckets the shop files an item under, which is not the same thing as the stats on its tile, and the page says so.
The API reference
deadlock.xerodefect.ai/api/ is a static reference for the programmatic surface around the game — the community-run deadlock-api.com family surveyed in note 0011 (match history, analytics, leaderboards, parsed game data; 117 endpoints in 31 groups). Not a Valve API — the pages carry that warning visibly, and it matters: Valve throttled the whole community layer offline for weeks in 2024.
It renders entirely from a committed snapshot of each service's OpenAPI spec (tools/data/openapi/, MIT-licensed upstream), so the site build stays offline and deterministic — no Swagger UI, no CDN, no external assets. Each endpoint shows its parameters, response shape, documented rate limits and a copyable curl line.
Refreshing after the upstream API changes:
python tools/fetch_openapi.py # one polite GET per service
python tools/build_site.py # re-render site/api/
git diff tools/data/openapi/ # review what actually changed
python tools/test_api.py # rendered pages still match the snapshot
If no snapshot is present the page degrades to a placeholder, like the explorer without an index. Invariants live in tools/test_api.py: endpoint counts are asserted against the snapshot itself, so a refresh cannot silently disagree with the rendered pages.
Reading file contents
Selecting a file with renderable content gets a View button:
- Text files open as virtualised, line-numbered text.
- KV3 gameplay data opens as a table — rows are top-level entries, and columns are picked from leaf paths ranked by how many entries carry them. Sort by any column, filter rows, and click a row to see that entry pretty-printed.
abilities.vdataandheroes.vdataalso get Items and Heroes presets with localized names, tiers, slots and soul costs joined in.
Content is generated by build_content.py and fetched only when a file is opened, so the initial page load stays at ~1.1 MB. Producing it needs a decompiled tree:
python tools/decompile.py --fetch # once: downloads the Source 2 Viewer CLI
python tools/decompile.py # all 99 vdata_c, both mounts, into out/<mount>/
# Images, so the table can render them. vtex_c -> PNG, vsvg_c -> SVG.
python tools/decompile.py --mounts citadel --ext vtex_c --filter panorama/images/
python tools/decompile.py --mounts citadel --ext vsvg_c --filter panorama/images/
# Styles, to (re)derive the gallery's inline stat icons: the class -> sprite
# mapping in tools/build_gallery.py STAT_ICONS comes from the first stylesheet,
# the wash-color hex values from the second. Only needed when re-verifying the
# table after a patch -- the gallery build reads STAT_ICONS, not the CSS.
python tools/decompile.py --mounts citadel --ext vcss_c --filter panorama/styles/ability_property_icons
python tools/decompile.py --mounts citadel --ext vcss_c --filter panorama/styles/citadel_base_styles
# Layout evidence for the same table: the shop tooltip includes that stylesheet
# and reads each important stat's icon from a `prop_*` class -- the join item
# stat rows reuse (property m_strCSSClass -> prop_<css>).
python tools/decompile.py --mounts citadel --ext vxml_c --filter panorama/layout/tooltips/citadel_tooltip_mod
python tools/decompile.py --mounts citadel --ext vcss_c --filter panorama/styles/tooltips/citadel_tooltip_mod
python tools/build_site.py # picks up out/ automatically
The gallery also depends on the two panorama/images/ commands above: description templates embed AbilityPropertyIcon panels, item stat rows key icons off their property's m_strCSSClass, and build_gallery.py inlines the mapped hud sprites (29 table entries, every file ≤ 8 KB at this build) as data URIs — per occurrence in descriptions, and once per class in the payload's statIcons map for stat rows. If they are missing from out/, the build says so and the affected icons drop — test_gallery.py pins the rendered counts, so the gap fails loudly.
Any value that is an image reference renders as the image — in the table and inline in the entry view — with hover to enlarge. References take two forms and compile differently:
| reference | compiled | decompiled |
|---|---|---|
file://{images}/x.psd | panorama/images/x_psd.vtex_c | .png |
file://{images}/x.svg | panorama/images/x.vsvg_c | .svg |
Icons at or below 8 KB are inlined into the document as data URIs — 345 of them in abilities.vdata — so scrolling the table fires no image requests at all. Larger art is fetched through a throttled queue (4 at a time) that retries with backoff and shows a placeholder meanwhile.
Only images the data actually references are published — 982 files, 36.6 MB, covering 985 references (the 3 misses are art for an unreleased hero that isn't in the archives). That filter is load-bearing: panorama/images/heroes/backgrounds/ alone is 126 MB of art nothing points at. To publish less, decompile fewer directories — everything downstream adapts, and unresolved references simply render as text.
Without out/, the site still builds — the explorer just offers no KV3 view.
decompile.py reads every mount (citadel and core) with no path filter, because gameplay vdata also lives outside scripts/ — under stats/, soundstacks/ and the archive root. Output is per mount since the same path exists in more than one with different contents.
No JavaScript dependencies — the tree, virtual scrolling, and search are hand-written, so the site stays self-contained and works offline. Regression tests for that logic live in tools/test_explorer.js and need Node (the build does not):
node tools/test_explorer.js
What we know so far
Verified against build 6679 (Aug 14 2026):
- Content is 274 VPK archives (27.4 GiB, 130,730 entries) under
citadel/. - All gameplay data is 85
.vdata_cfiles totalling 0.8 MB — 0.003% of the payload.scripts/heroes.vdata_candscripts/abilities.vdata_care the two that matter. - Abilities and purchasable items share one file, discriminated by
m_eAbilityType: 277 items, 241 signature abilities, 78 ultimates. - Localization is loose plain text on disk — no extraction needed.
- The main correctness trap is unresolved KV3 inheritance (note 0002).
Repository layout
docs/research/*.md numbered research notes + the pattern that produces them
tools/ stdlib-only Python helpers
tools/hooks/ version-controlled git hooks
AGENTS.md conventions for automated contributors
site/ (gitignored) rendered site, uploaded to Azure Static Web Apps
db/ (gitignored) SQLite index of the game files, rebuilt by tools/vpkdb.py
Documentation site
The site is served by Azure Static Web Apps (Free plan) at https://deadlock.xerodefect.ai — nothing billable, no CI: the build needs the extracted game data that never leaves this machine, so publishing happens locally. See infra/README.md for the Azure setup (Bicep, deployed resources, DNS). The old GitHub Pages copy at coolhome.github.io/Deadlock is frozen at its last gh-pages publish and no longer updated.
main tracks Markdown only. tools/build_site.py renders it into site/ (gitignored), and tools/deploy_site.py uploads that to the Static Web App with the SWA CLI, fetching the deployment token at run time from the logged-in az session (nothing stored). Generated HTML never lands on main.
python tools/install_hooks.py # one-time, per clone
python tools/build_site.py # render to site/
python tools/deploy_site.py --dry-run # build only, report what would ship
python tools/deploy_site.py # build and upload to Azure
Two hooks keep it automatic:
| hook | does |
|---|---|
pre-commit | Fails the commit if the Markdown no longer renders. Stages nothing. |
pre-push | On a main push, builds and uploads to Azure Static Web Apps. |
Edit only the Markdown. Bypass either with --no-verify.
Every page footer shows when the site was built, rendered in your local timezone (the markup ships UTC and a few lines of JS localise it on load), and the explorer shows the same in its header with UTC in the tooltip. --check ignores the timestamp, so it reports staleness only when content actually differs.
Contributing
Research follows a fixed loop — ask, verify, write, stamp, re-verify — documented in docs/research/README.md. The short version:
- Every claim about game data must be reproducible by a command in the note.
- Every note is stamped with the game build it was verified against.
- What you didn't check goes in Open Questions.
- Never commit extracted game assets.
Automated contributors: see AGENTS.md.
Licence
See LICENSE. This project contains no Valve assets — only scripts and findings. Deadlock and Source 2 are trademarks of Valve Corporation.