Deadlock Research

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.

toolwhat it does
find_game.pyLocate the install across Windows/Linux/macOS Steam libraries; print the build stamp
vpkdb.pyIndex every VPK into SQLite; search, inspect, extract, diff across patches
vpk_list.pyOne-shot VPK parser and raw entry reader — the layer vpkdb.py is built on
kv3.pyParse KV3 text; resolve _multibase inheritance; as_bool for its three boolean spellings
kv1.pyParse KeyValues v1 (.txt/.res/.vdf); resolve platform conditionals; sniff KV1 vs KV3
serialize.pyDependency-free YAML emitter for the parsers' output
decompile.pyFetch and drive the Source 2 Viewer CLI into out/
build_site.pyRender the Markdown and the explorer into site/
build_explorer.pyGenerate the browsable file explorer from the index
build_content.pyGenerate the file contents the explorer renders
fetch_openapi.pySnapshot the community APIs' OpenAPI specs into tools/data/openapi/
build_api.pyRender those snapshots into the static /api/ reference
install_hooks.pyPoint 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.

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:

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:

referencecompileddecompiled
file://{images}/x.psdpanorama/images/x_psd.vtex_c.png
file://{images}/x.svgpanorama/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):

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:

hookdoes
pre-commitFails the commit if the Markdown no longer renders. Stages nothing.
pre-pushOn 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:

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.