A Chrome-40-era DevTools frontend for debugging Planetary Annihilation: TITANS,
extracted from a Chromium snapshot's resources.pak.
Pure Node, no dependencies, no shelling out to platform tools — the same commands work on Windows, Linux and macOS.
PA's UI runs in Coherent UI 2.6.8, a Chromium fork. Its host process
(bin_x64/host/CoherentUI_Host.exe) contains the standard Chromium
devtools_http_handler_impl and exposes the DevTools protocol on
127.0.0.1:9999 — but ships no DevTools frontend. bin_x64/host/ has
blink_resources.pak, content_resources_100_percent.pak,
theme_resources_standard.pak and ui_resources_100_percent.pak, and no
devtools pak among them. So /devtools/devtools.html has nothing to serve.
A modern DevTools frontend can't drive a backend that old. This tool recovers the era-matched one and serves it from your own machine.
PA's own /json/version reports:
"User-Agent": "... Chrome/40.0.2214.28 Safari/537.36"
"WebKit-Version": "537.36 (@5994603edf160b2d21296cfe70499b12dae8301a)"
"Protocol-Version": "1.1"
Snapshot 303346 is Chrome 40.0.2214's branch base — an exact match to the build PA embeds.
The frontend's declared protocol surface, diffed against the literals compiled
into CoherentUI_Host.exe:
| frontend declares | found in Coherent host | |
|---|---|---|
| commands | 258 | 258 |
| events | 96 | 95 |
Every command matches. The single missing event is
Console.messageRepeatCountUpdated, which only collapses repeated console
lines. All 26 domains — DOM, CSS, Debugger, Runtime, Network,
Timeline, Profiler, Page, … — are fully covered.
Confirmed 2026-07-27 against a running client. The ws:// upgrade succeeds,
and every bootstrap call the frontend's panels make on attach returns a result:
Inspector/Page/Runtime/Console/Network/Debugger/DOM/CSS/DOMStorage/Database/
IndexedDB/Profiler/HeapProfiler/Timeline/LayerTree/Worker/ApplicationCache .enable
-> 18 ok, 0 failed
DOM.getDocument -> #document, 2 children, coui://ui/main/main.html
Page.getResourceTree -> frame coui://ui/main/main.html, 9 resources
Runtime.evaluate -> model/ko/api all reachable, wasThrown=false
CSS.getComputedStyleForNode-> 263 properties on <body>, 6 matched rules
DOM.getOuterHTML -> 2425 chars
99 events streamed while attached — Console.messageAdded x70,
Debugger.scriptParsed x12, Network.*, CSS.styleSheetAdded,
LayerTree.layerTreeDidChange. PA stayed healthy throughout and all 5 targets
remained listed afterwards.
Rendering was then confirmed too, by driving an era-matched Chromium's own CDP
and reading back the loaded frontend: 1 target attached, all 8 panels built
(elements,network,sources,timeline,profiles,resources,audits,console), 546
elements in the page, Console panel 1249x1259 with 141 message rows of live
PA output, and zero errors in the frontend's own console.
Do this once. It downloads roughly 170 MB in total, all cached under .cache/
so it never repeats.
-
Check Node. Needs 18 or newer; there are no other dependencies.
node --version
-
Build the frontend. Downloads a Chromium 40 snapshot (~78 MB), extracts 95 files from its
resources.pak, and verifies every internal reference resolves. Output lands infront_end/.node pa-devtools.mjs build
-
Fetch the viewer browser. First run downloads an era-matched Chromium (~91 MB) and launches it — close the window once it appears. Every later run starts instantly from cache. This step is not optional; see You cannot view this in a modern browser.
node pa-devtools.mjs browser
Do this each debugging session.
-
Launch PA with the debug port. It is opt-in per launch, so a refused connection almost always means a missing argument rather than a closed game.
--coherent_portis a PA argument, not a Windows mechanism, so it works unchanged on Linux and macOS.PA.exe --coherent_port=9999 -
Navigate PA to the scene you want to inspect. Targets appear only once you are actually in them — GW scenes (
gw_start,gw_play,gw_war_over,gw_coop_per_player_loadout) won't be listed from the main menu. -
Start the server and leave it running.
node pa-devtools.mjs serve
-
Launch the viewer, which opens on the target list. Same command as install step 3, but instant now that it's cached.
node pa-devtools.mjs browser
-
Click the page you want to debug. Greyed-out entries are already being inspected — only one client may attach to a target at a time.
-
Reload the list whenever PA changes scene. Target ids are regenerated on every navigation, so a
?ws=URL from an earlier scene is stale. Go back to http://127.0.0.1:8099/ rather than bookmarking one.
If something looks wrong, node pa-devtools.mjs doctor reports whether PA's
debug port is answering and whether the frontend is present.
Useful flags: --port, --pa-host, --pa-port, --out, --dir, and
--debug-port (gives the viewer its own CDP port, so you can attach to it and
debug the debugger — the fastest way to diagnose a frontend problem).
Not a limitation of the extraction — the 2014 frontend depends on APIs every current engine has removed:
| API | Uses | Status |
|---|---|---|
::shadow / /deep/ CSS |
98 | Removed Chrome 63; never in Firefox |
createShadowRoot (Shadow DOM v0) |
2 | Removed Chrome 80; never in Firefox |
KeyboardEvent.keyIdentifier |
74 | Removed Chrome 54; never in Firefox |
SharedWorker temp storage |
9 | Fails → Failed to clear temp storage |
Panel content lives inside shadow roots styled through those combinators, so a modern browser paints the tab bar and drops everything beneath it. That is the exact symptom to expect, and there is no shim worth trusting across 4 MB of minified code.
browser solves it by running the frontend in the browser it shipped with:
| Host | Snapshot | Build |
|---|---|---|
| Windows | Win/303348 |
Chrome 40.0.2214.0 (verified on Windows 11) |
| Linux | Linux_x64/303346 |
Chrome 40.0.2214 |
| macOS | Mac/303350 |
Chrome 40.0.2214 |
The viewer only has to predate the removals above, so anything from roughly
Chrome 28 to Chrome 53 works — pass --position if you want a different one.
A newer pick in that range may behave better on a modern OS.
Security: this is an unpatched 2014 Chromium with known, unfixed
vulnerabilities. It launches with an isolated --user-data-dir and background
networking, sync, extensions and component updates disabled, and is only meant
to be pointed at 127.0.0.1. Don't browse the web with it.
macOS caveat: a 2014 unsigned x86-64 app will likely need Rosetta and a Gatekeeper exception. Untested there.
From resources.pak at snapshot position 303346 (Chromium r303346, Blink
r184994 — Chrome 40.0.2214's branch point):
- 95 files, 4.09 MB, resource ids
22000..22094, fully contiguous - 24 code/markup files (
devtools.html,devtools.js,devtools.css,toolbox.*, 17 lazily-loaded*_module.jsbundles,devtools_extension_api.js) - 71 images under
Images/
The frontend is platform-neutral, so the Linux snapshot is used regardless of your OS. It's also the only platform with a build at exactly 303346, and the smallest of the three.
This is the only genuinely hard part. A .pak is a flat uint16 id -> blob
store — there are no filenames in it anywhere. Unpacking gives you 95
anonymous blobs. Names have to come from an oracle:
builduses the loose, correctly-namedchrome-linux/resources/inspector/tree that snapshot builds happen to ship alongside the pak. Hashing those against the pak blobs (sha256) yields an exact, assumption-freeid -> namemap, which is written tomanifests/<position>.json.extractuses that stored manifest against anyresources.pak, which is what you need for builds that ship only a pak and no loose tree — official Chrome and Chromium releases, for instance. It locatesdevtools.htmlby content and shifts the manifest to match, so a build whose id block sits at a different offset still lands correctly.
Both routes were checked to produce byte-identical output.
Because snapshot builds ship that loose resources/inspector/ directory, and
its contents are byte-identical to what's in the pak (verified: 95 files
compared, 0 differing), you don't strictly need pak extraction for a snapshot
— copying the directory gets you the same bytes. The pak path earns its keep
when your only source is a build that doesn't ship the loose tree.
Extraction isn't trusted, it's checked. collectReferences() walks the
extracted HTML/CSS/JS and collects every relative path they mention —
src=/href= in HTML, url() in CSS, bare Images/*.png strings, and
*_module.js bundle names. Every one must resolve to a file in the output.
A misaligned id block shows up immediately as dangling references.
Current status: 75 referenced paths, 0 dangling. All 75 also verified to return HTTP 200 through the bundled server.
pa-devtools.mjs CLI: build | extract | serve | browser | doctor
lib/pak.mjs Chromium DataPack v4/v5 reader
lib/zip.mjs minimal zip reader (unzip/Expand-Archive aren't portable)
lib/snapshot.mjs snapshot resolution + download, with caching
lib/extract.mjs name recovery + reference-closure verification
manifests/303346.json learned id -> name map for Chrome 40
front_end/ generated output (gitignored)
.cache/ downloaded snapshot zips (gitignored)
- Coherent UI is a fork. The live handshake above covers the calls the frontend makes on attach, but anything it changed behind an identical method name deeper into a panel's use won't show up in either that or a string diff.
- Don't point a modern DevTools frontend at PA. Current Edge/Chrome can't
complete the WebSocket upgrade against this backend — PA logs
GetMimeType doesn't know mime type for: page/<id>and the Coherent UI host process has been observed crashing shortly after, killing port 9999 while the game keeps running (symptom: PA alive, connection refused; fix: restart PA).chrome://inspectalso rejects the target, since/json/versionreports protocol 1.1 against a modern expectation of 1.3. Avoiding exactly this is the reason for era-matching the frontend. - The
?ws=parameter must not be percent-encoded. The M40 frontend reads it verbatim and prependsws://without decoding, so an encoded:or/givesFailed to construct 'WebSocket': The URL ... is invalidand the UI loads but never connects. PA's owndevtoolsFrontendUrlpasses it raw for this reason. - Only one client may attach to a target at a time. While one is attached the
backend omits
webSocketDebuggerUrlanddevtoolsFrontendUrlfrom/jsonfor that target;serveuses that absence to flag it as busy. SupportedCSSProperties.jsandInspectorBackendCommands.jsexist loose in the snapshot but are absent from the pak. That's expected: release builds inline both intodevtools.js(which carries 259registerCommandcalls), so nothing references them and nothing is missing.