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.
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.