Skip to content
TiPSYSPiTPublic

About

CoD2 Demo Inspector / Analyzer

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

28 Commits

Folders and files

Repository files navigation

CoD2 Demo Viewer

A single-page replay viewer for Call of Duty 2 demos (.dm_1): CoD2 1.0 – 1.3 and CoD2x 1.4, stock servers and zPAM. Drop a demo on the page and watch the match top-down in 2D or in the real map in 3D; toggle between the two at any time - the time, the selected player and the filters stay as they are. Modeled on cod4-demo3d, a Call of Duty 4 demo viewer (same layout, controls and panels). Source: github.com/TiPSYSPiT/cod2-demo (also linked in the top bar).

The demo is parsed in your browser (a Web Worker). Nothing is uploaded, and the page loads nothing from other servers (but a demo you open by link, ?demoUrl=, from its own website): three.js r187 comes with the project in vendor/, the fonts (Inter, JetBrains Mono, SIL Open Font License) in assets/fonts/. The site is plain HTML, CSS and JavaScript with no build step and runs on GitHub Pages. The game data (3D maps, textures, 2D backgrounds, sounds, player models and animations, particle effects) is extracted once from your own game files with Python scripts (below) and committed with the site (the whole site without the sample demos: ~750 MB in ~4,200 files, the largest 18 MB).

Features

Part What it does
Top bar 2D / 3D toggle (key V; a demo opens in 3D, in the first person of the player who recorded it, with the game's sounds on at 20 % - view, camera, sound and volume the viewer changed are remembered instead), Match details (chart icon: scoreboard, round by round, chat, events), Export JSON (file icon), GitHub (the source), Open demo
Score pill Over the view: the score at the playback time, the round and its time, map and mode; after a plant the bomb timer (the server's own countdown clock; also next to the bomb in 2D)
Rail (left) Icon buttons with popovers: Layers (names, trails 5 s (off by default), kill lines, the recording player's hits from zPAM's damage log, last known position, dropped weapons (2D, off by default), grenade types, the map's intermission camera and hurt triggers; in 2D the map background), Heatmap (2D), Camera and Display (3D: players, X-Ray, props, effects, kill feed, bullet tracers, enemy names, head icons, grenades and dropped weapons, shell casings, shadows, sound and volume), Match info (final score on request, map, mode, ruleset, server, POV, length, record date, protocol, the map data details; the rules - friendly fire, kill cam, limits, the bomb timer, antilag, zPAM's weapon limits -; zPAM's match info - teams, round, half, match time, timeouts; keyboard shortcuts)
Drawer (right) Round and status; per team the faction's icon, the side, score and alive bars, per player the weapon in hand (kill icon) and K / D up to now. Click a player to select them (highlighted in 2D and 3D, followed by the 3D cameras); the eye shows / hides a player, the chart icon opens the player's stats. Below it the kill feed & bomb: the game's own kill icons and bomb plants / defuses, the entry at the playback time highlighted, click one to jump to 2 s before it (that entry stays the highlighted one); separators mark each round ("R11" - from the same round detection as the timeline and the score pill; the current round's in the accent, click one for the round's start), the warmup and each halftime / side switch (also every overtime swap). Sections fold, the drawer hides (both remembered)
Scoreboard Hold Tab: the game's scoreboard (the last scores the server sent) over the view - 2D, 3D, first person and fullscreen -, gone when Tab is released or the window loses the focus. Only the viewer's own Tab shows it, not the demo's (the recording player's scoreboard, the intermission); in a form field, a dropdown, a popover or a dialog Tab moves the focus as usual. Not part of the video export
Fullscreen The button at the top right (or F; in the free-fly camera F stays "down"): only the view (2D or 3D) with its HUD fills the screen - top bar, score pill, rail, panel and player bar hidden. The player bar slides in over the view when the mouse comes to the bottom of the screen (or on a touch, or briefly on a ← / → seek) and out 2 s after the mouse left, not while the mouse is on it, the playhead is dragged or a dropdown is open; the view and the HUD do not move. The cursor and the exit button go when the mouse rests over the view. Esc or the button returns
Player bar (bottom) ⏪ / ⏩ 5 s (← / →), play / pause (Space), the time, the timeline (rounds as segments underlined in the winner's colour, kills as ticks - one team above, the other below -, bomb plants / defuses, halftimes; under it the round labels - "R11", "HT", "Warmup" and the like, from the same list as the kill feed's separators, thinned out to "11" or every 2nd, 5th... number when the bar is short; hovering the lane shows "Round 11 – 10:44, score 5:5" also for a round without a label, a click jumps to the round's start), range (whole match or one round), 0.5–4× speed, export range [ ] (I / O), Export video
Match details Weapons (every weapon of the demo with the weapon file's numbers - damage and falloff in metres, hit-location multipliers, hits to kill, RPM, clip, reload, ADS, speed - and how it was used: kills, HS %, average kill distance, users; in zPAM demos the damage model checked against zPAM's damage log), Scoreboard (score, K / A / D, K/D, HS %, TK, nade kills / deaths, opening duels won–lost, trade kills, rounds with 3+ kills, clutches won / played, in zPAM 3 demos zPAM's own score and damage count, plants / defuses per player, halves; names in their colour codes), round-by-round table with win reasons and round times from the round clock (after zPAM's strat time), per round the first blood, 3K+ rounds, the clutches (1vX) and the trades, and in zPAM demos zPAM's round report of the recording player (his hits with damage and hit location, how he was killed) and zPAM's damage log (every rifle / scoped / shotgun / grenade hit he made: victim, damage, the victim's health before it, hit location, weapon, distance; also in his stats and as hit markers on the 2D map), chat, events (joins, team changes, ready-up, halftime, timeouts, bomb; optional: weapon and ammo pickups, out of grenades); the player stats show the ping over time

2D – stock CoD2 has no minimap image, so the 2D view offers (Layers popover, Background):

  • Render: a top-down render of the map's BSP (textures, lightmaps, props, wall outlines), aligned to world coordinates by construction;
  • zPAM radar: zPAM 4.09's hand-made radar image for the 16 maps that have one, placed with its .radar calibration (the CoD2x cg_hudRadar* formula);
  • Grid.

It shows players with team colours, names and view cones, trails, kill lines, death crosses, grenade flights and smoke / explosion circles, the bomb sites (A / B) and the planted bomb, the dropped weapons (the game's weapon icon, turned the way the weapon lies) and, as Layers Map, the map's intermission camera and its hurt triggers (the kill planes under the map, water, with their damage). Wheel zooms, dragging pans, double-click resets, Q / E (or the map tools bottom right) rotate in 90° steps. The heatmap button offers positions, kills, deaths, grenade landings, smokes and shots, filtered by team, side and player.

3D – the map geometry from the .d3dbsp (textures, lit as the game's world shader lights it: the baked lightmaps plus the sun where the map's sun mask lets it through, so buildings throw their shadows and walls facing the sun are bright, with the materials' normal maps, detail maps and the sun's specular highlights; the vertex colours tint every surface and fade the blended terrain layers (snow, ground, footprints) as in the game; alpha-tested foliage and fences with soft edges (alpha to coverage), the sky cube, the map's fog as its script sets it - exponential with its colour and density on the stock maps, none on the competitive versions -, textures up to 1024 px with the GPU's full anisotropic filtering), the map's static models (misc_model props: crates, sandbags, trees, vehicles, and the bomb-site objects; lit as the game lights them - from the light grid at their centre, or the compiler's ground light for the models made for it - with their normal, specular and detail maps; with the game's LODs: the coarser meshes by distance and field of view, bushes gone past the game's cull distance), the players (Display popover, Players: Models = the game's own character models - body, head and helmet or hat (on the head's j_helmet bone, as the game attaches it) from the demo's client state, the weapon in hand in their right hand - animated with the legs / torso animations the demo sends (crossfaded; runs, walks and crawls advance by the distance the player covers, so the feet keep step), turned like in the game (the legs follow diagonal movement while the upper body and head aim with the view's yaw and pitch, lean rolls and shifts the upper body, prone players tilt with the slope), lit as the game lights its models - from the map's light grid (the baked light from 8 directions at their position, plus the sun where it reaches them), optionally a team-coloured ring at the feet (Display: Player rings, off by default), and the corpses playing their death animation; Simple = capsules, standing / crouched / prone), optionally a view line from the eyes using their real pitch (Display: View lines, off by default; the selected player's own ring stays), X-Ray (Display popover or key X: Player = living players behind walls and props as a silhouette in the team colour, Player + name = also every name, with a badge for a player out of sight; remembered for the browser session), the mounted MGs turning with their user's aim (the user's own weapon hidden meanwhile), the S&D bomb-site targets swapped for their destroyed model when the bomb explodes, what the zPAM map pack's script removes left out (the aim trainer's targets and "Map supports Training" sign outside ready-up, the LAN posters), the models a map's level script spawns (mp_toujane_fix: the plaster under the A roof; zPAM's fixes of the stock maps - roof and window pieces on Toujane, box lids, fence posts, shelves and stairs on Burgundy, Carentan, Dawnville and Harbor, and the wrecks of Toujane's tank and armoured car after the explosion - shown when the demo's server ran that script: it precached the model), the game's own particle effects (Display popover, Effects: Game = grenade explosions by the surface hit, smoke grenades, bullet impacts by bullet type and surface, scripted effects, the map's ambient smoke, dust and fires when the server ran them (stock servers; zPAM's rules switch them off), every shot's muzzle flash at the weapon's muzzle and the game's bullet tracers (Display: Bullet tracers, off by default; as the server set them - zPAM gives every bullet one, 1000 units long at 20000 units/s: a streak flying from the muzzle, gone at the impact within a few frames), smoke and dust lit by the map's baked light under them, debris landing on the floor and bouncing; Simple = marker clouds and flash spheres), the effects' models - the shell casings each shot ejects at the gun's ejection port (bolt-action rifles and the shotgun: when the bolt is worked; as in the game only near the camera), the burnt smoke grenade, debris -, the grenades in flight (the game's grenade model, spinning as the demo has it) and the dropped weapons on the ground (Display: Grenades and dropped weapons, Shell casings - off by default; off, none is made -; the effects' dynamic lights are not drawn - their falloff is made in the game's renderer, not known), the game's head icons over the players (the team icon the server gives them, the talk balloon while a teammate talks in the voice chat, also as a speaker in the panel; in first person only what that player sees), the players' and corpses' shadows (Display popover: Shadows; soft shadows along the map's sun, as the game's character shadows - the world's own shadows are in its lightmaps; ?shadows=off), kill lines, death crosses, grenade arcs, the bomb and the site labels, a kill feed overlay and the game's sounds (Display popover: Game sounds and Volume, on at 20 % by default; the click or drop that opens the demo starts the audio - a demo opened by a link shows "Click to enable sound"), chosen as the game's client chooses them: each weapon's shots (and the M1 Garand's last-shot ping), reloads, bolt / pump actions, the weapon switch, the grenade's pin pull and throw, melee by weapon and its hit, empty clicks; footsteps, jumps and landings by surface with the gear rattle, foliage, fall damage; bullet impacts on the world and on players, near-miss whizzes at the camera, grenade bounces and explosions, smoke; the looping sounds of entities (the planted bomb's ticking, a firing MG42 / .30 cal, map fires); the server's sound-alias events (bomb plant / defuse / explosion, quick-chat voices, zPAM's switch and drop sounds), weapon and ammo pickups, and the recording player's local sounds (announcer, hit marker; in his first person his breath while he holds it on a scope - breathing in, the heartbeat, breathing out or gasping after 4.5 s - with the other sounds turned down as the game does). The player watched in first person hears his own sounds as the game plays them for the local player (the _plr variants, at the ear). zPAM demos use zPAM's alias changes (its silenced stock switch sound). One alias variant by probability (the variants of the map by the engine's loadspec rule), its volume / pitch ranges, the distance falloff along the alias's curve (the game's .vfcurve files) and stereo panning at the camera; pausing or seeking cuts the sounds, the pitch follows the speed up to 2×, faster is silent. Cameras (Camera popover, C cycles):

Camera Controls
Orbit Left drag rotates, right drag pans, wheel zooms. Click a player to select them
Free fly Drag to look, W A S D to move, R / F for up / down, Shift for fast, wheel sets the speed
Follow player Travels with the selected player (or the POV's followed player). Drag to turn around them
First person The selected player's eyes (none selected: the demo's own view - the recording player, or whom he spectated) with their view angles, the weapon in their hands, the game's crosshair and the game's HUD (Camera popover: Weapon, First person HUD), the game's camera shake (near the bomb's explosion, on a firing MG42; Camera popover) and the flinch when hit; hold Tab for the game's scoreboard

First-person weapon: the game's own view models - the gun of the weapon file in the hands the server gave the player (American, British, Russian, German sleeves) - animated with the weapon's own view animations as the demo drives them: idle, fire / last shot, rechamber, reload (also empty, and the start / loop / end of the bolt-action and shotgun reloads), raise / put away (quick for a grenade throw), melee, the grenade's pin and throw, aiming down the sights. The gun kick, the sway when turning and the bob (the gun's and the camera's) use the game's own formulas with the weapon file's numbers (the kick's random part is fixed per shot, so seeking shows the same); the idle sway and the stance and movement offsets follow the weapon file's numbers; the recording player's scope sways as the game sways the view while aiming (calm while he holds his breath, faster after a long hold). In third person a player's gun goes to the left hand during grenade throws and rifle reloads, as the animations' notetracks tell the game. Aiming down the sights zooms as the game does (the weapon's ADS fov), the sniper rifles show their scope overlay; the view flash at the muzzle on every shot. The crosshair is the game's: the weapon's reticle images at the spread of the moment (stance, movement, shots), shrinking and fading while the sights come up, hidden during reloads, switches and on a mounted MG, green while it is on a teammate (as in CoD2, which has no red crosshair over enemies). On a mounted MG the view is the game's: the eye on the turret's tag_player, the gun (MG42 / .30 cal) in front turned by the player's view, no own weapon, bob or sway, the MG's own crosshair (mg42_cross) in the middle, its muzzle flash and tracers at the barrel; mounting and leaving move the eye over 0.15 s. Exact for the recording player (the demo's player state: the animation the game plays, the weapon state, the ADS fraction, the spread, the hands model); the other players are approximated from their weapon events (shots, reloads, switches, melee, the offhand throw), their legs animation (aiming) and their movement. All of it is part of the video export.

First-person HUD: the game's own HUD, built from the game files (the HUD menu files, the fonts, the images, the English strings) and drawn as the 1.3 client draws it: on the game's 640 × 480 screen scaled by the height and anchored to the edges (wide screens keep it at the corners), clear of the viewer's own controls. Parts (Camera popover, remembered for the browser session): Compass (the compass face turning with the view, the teammates as arrows, the objectives such as the bomb sites and the bomb carrier, enemies only as a ping where they fire; CoD2's compass has no map image), Team score (the server's score display - the stock game's and zPAM's, shown and hidden when the server does it: zPAM shows it at strat time), Kill feed (the game's kill messages above the compass - killer, weapon or headshot / melee / fall / suicide icon, victim, in the team colours, 5 s and fading - and "You killed" / "Killed by" in the centre), Ammo (clip and reserve, red when low, the weapon name after a switch), Grenades (frag and smoke counts with the faction's icons) and Server HUD (everything else the server shows - zPAM's players left, sniper / shotgun info and round texts, the clocks, the game messages), Low health (off by default: the red screen edges pulsing after a hit), Health bar (off: the game hides it, cg_drawHealth 0), Damage direction (off by default: the arrows to where a hit came from and the short red flash), Hit marker (the cross on the crosshair when the player hits someone, as the server's script draws it: zPAM yellowish for a hit, white and larger for a kill, red for a teammate; with the game's hit sound), Stance (off by default: the stance icon after a change, "Prone Blocked"; stock HUD only, as in the game), Hints (off by default: plant / defuse the bomb, pick up or swap a weapon, mantle - with the game's default keys) and zPAM match info (off by default: as the recording player had it); the scoreboard is the viewer's Tab (see above). zPAM demos use zPAM's HUD layout (smaller ammo counter, grenades side by side), the others the stock game's. Exact for the recording player (the demo's player state); with another player in first person the compass, the kill feed and the team score. Part of the video export.

Video export (2D and 3D): MP4 (H.264 via WebCodecs) or GIF of a round, a kill or the [ ] range, with overlays (names, kill feed, score); rendered offline frame by frame. The 3D MP4 carries the game's sounds as heard at the camera of each frame (rendered offline, AAC - Opus where AAC is missing); where the browser cannot encode H.264 the WebM fallback records the live sound; GIFs are silent.

Names over the players (3D): Layers Names shows them; the enemies' names only with Display Enemy names (off by default - the game never shows an enemy's name: only a teammate's under the crosshair); enemies are those of the player in first person, else of the selected player, else of the player the demo follows. X-Ray Player + name names everyone.

Player names: the colour codes (^1, also CoD2x / zPAM's runs like ^^00) are removed on the 2D and 3D maps, in the kill feed and the panels; the scoreboard shows each name in its colours.

Assists (scoreboard column A): CoD2 sends none, but zPAM puts its own count in its scoreboard (ui_scoreboard_*); the viewer takes the final values from there - ≈ where only part of the match is in it, the column is left out for demos without (stock servers).

Narrow windows and touch screens: below 1100 px the page becomes one column (the view with the rail and the score pill, the player bar right under it - sized so that it fits in the window -, then the drawer) and scrolls; the match details fill the screen. On a touch screen one finger pans the 2D map and two fingers pinch-zoom it; the 3D orbit camera turns with one finger and zooms / pans with two; the timeline seeks by touch.

Deep links: index.html?demo=demos/my.dm_1&view=3d&t=300&focus=name loads a demo served from the same site (?demo= takes only a path on the site, no other website), opens the 3D view at 5:00 and selects the first player whose name contains "name"; &cam=x,y,z,yaw,pitch opens the free camera there; &sound=off leaves the sounds out. A link opens no audio by itself (the browsers wait for a click): the 3D view asks for one with a small "Click to enable sound". A demo on another website: ?demoUrl= (next section).

Open a demo by link

Another website (a match archive, a league page, a Discord bot) can open a demo in the viewer with a link or a redirect. The demo stays on that website; the viewer downloads it in the visitor's browser and opens it as a dropped file (3D, the recording player's view, sound at 20 % - or what the visitor chose before):

https://tipsyspit.github.io/cod2-demo/?demoUrl=https%3A%2F%2Fexample.com%2Fdemos%2Fmatch.dm_1
Parameter Value Example
demoUrl required - the demo's full https:// address, URL-encoded demoUrl=https%3A%2F%2Fexample.com%2Fdemos%2Fmatch.dm_1
name the file name shown (and used for exports); default: from the server's Content-Disposition (if it exposes it, below), else the last part of the URL's path name=Final%20-%20Team%20A%20vs%20B.dm_1
t start time on the player bar's clock: seconds or mm:ss / h:mm:ss (with round: from the round's start) t=95, t=1:35
round play only this round (as the bar's Range list): its number, 24* for a round interrupted by a timeout, A for an after-match round=7
player select this player and follow them (the 3D camera's POV): the exact name, else the first name containing it; colour codes (^1) ignored player=szabo
view 2d or 3d (default: 3d, or the visitor's last choice) view=2d
speed 0.5, 1, 2 or 4 speed=2

Everything also works in the hash (#demoUrl=...&t=1:35), which the browser does not send to the server; when a parameter is in both, the query wins. focus= is an older name of player=; sound=off, cam=, shadows=off (above) work in the query only. A parameter the demo does not have (a round, a player) or a value the viewer does not know is ignored with a short notice; without demoUrl the viewer works as before.

URL-encoding: demoUrl (and name, player) must be encoded - in JavaScript encodeURIComponent(url), in Python urllib.parse.quote(url, safe=''), in PHP rawurlencode($url). Unencoded, a & or # in the demo's own address would end the parameter. Example in JavaScript:

const link = 'https://tipsyspit.github.io/cod2-demo/?demoUrl=' + encodeURIComponent('https://example.com/demos/match 1.dm_1')
  + '&t=' + encodeURIComponent('2:30') + '&player=' + encodeURIComponent('Not Ready :)');

The server must allow it (CORS). A browser lets the viewer read a file from another website only when that website says so. The server hosting the demos must send, with each demo file (and with any redirect leading to it):

Access-Control-Allow-Origin: https://tipsyspit.github.io

(or Access-Control-Allow-Origin: *). To let the viewer read the file name from Content-Disposition, add Access-Control-Expose-Headers: Content-Disposition. Examples: nginx add_header Access-Control-Allow-Origin "https://tipsyspit.github.io" always; in the demos' location; Apache Header set Access-Control-Allow-Origin "https://tipsyspit.github.io"; GitHub Pages and raw.githubusercontent.com send * already. No cookies or login are sent (the request is made with credentials: 'omit' and no referrer), so the file must be public. Without the header the viewer says "Blocked by CORS" and names the header.

Limits and errors: only https: links (not http:, data:, blob:, javascript:, file:; a redirect to such an address is refused too); at most 100 MB (a full zPAM match is 5 - 20 MB); 30 s without data ends the download (a timeout); the visitor can cancel it. Each failure is shown on the page with Try again and Open demo manually: an invalid or non-https link, CORS, 404 / other HTTP errors, no connection, a timeout, a file too large, empty, a web page instead of the file, or not a CoD2 demo. The downloaded file is untrusted data, parsed by the same hardened parser (in a Web Worker) as a dropped one; the URL, the name and the server's messages are shown as text only.

Sound and start: the browsers start audio only after a click, so a linked demo opens paused under Click to start (Space / Enter work too); that click starts the replay with its sound.

Copy link: a demo opened by link has a link button in the top bar that copies the link to it at the current time - with the round (when Range is a round), the selected player, the view and the speed.

Host allowlist (optional): LINK.ALLOWED_HOSTS at the top of js/app/urlDemo.js - null (the default: every https host) or a list such as ['demos.example.org', '.github.io'] (a leading dot: the domain and its sub domains). With a list, narrow connect-src in index.html's Content-Security-Policy from https: to the same hosts (connect-src 'self' https://demos.example.org https://*.github.io). The size limit and the timeout are LINK.MAX_BYTES and LINK.TIMEOUT_MS there.

Tests: node tools/audit/link_unit.mjs (the URL check, allowlist, times, names, share link) and the browser suite link against a local https "other site" (py tools/audit/linkhost.py, self-signed, port 8443: CORS / no CORS, 404, noise, a web page, empty, 200 MB, endless, slow, stalled, redirects, Content-Disposition, special characters, a redirect from the other site to the viewer; plus three real hosts) - node tools/audit/browser/run.mjs link --browsers chrome --base http://127.0.0.1:8790/cod2-demo/, one browser at a time (--cases a,b, --no-internet).

What a demo contains (and does not)

A client demo is the recording player's network view. Other players only exist while the server sends them, so positions have gaps: stale players are hidden, or drawn hollow with "last known position". CoD2's obituary names the means of death instead of the weapon for headshots and bashes: the weapon then comes from the killer's bullet hit on the victim (exact) or the weapon in the killer's hands (≈). The bomb messages (MP_EXPLOSIVESPLANTED / DEFUSED) name nobody, but the game holds the planter / defuser at the site without a weapon for the plant / defuse time - that player is named (exact; only when they were not in the POV's snapshots: the player of the side who stood still at the site, ≈). The planted bomb is drawn at its own model's position (else where the game puts it, 11 units in front of the planter) and labelled with the nearest site. Values marked ≈ are heuristics (hover for how they were derived).

Damaged files and unknown maps: a truncated demo (an interrupted recording) is shown up to the cut, with a warning; a file that is not a CoD2 demo is rejected with a message (a CoD4 / CoD4X demo is named as such). A demo on a map without exported data (a custom map) plays on a grid fitted to the players' positions, in 2D and 3D (players, effects and sounds work; the effects then have no map light and land on a flat ground).

zPAM: rounds come from the fast restarts (n) and zPAM's _match_round (Round 7 / 24, Ready-up, Timeout; zPAM 4.09 does not send it - its rounds come from the score changes), the win and its reason from the team scores (G / H) and the game messages, halftimes from the side swap of the scores and zPAM's half-time messages (the overtime starts without a swap: "Half-time Overtime Ready-Up Mode"), timeouts from "Timeout Ready-Up Mode"; a round interrupted by a timeout is shown as such and not counted. Stock S&D uses the score changes only; other game types have no rounds.

Run it locally

ES modules do not work from file://, so serve the folder:

py tools/serve.py

Open http://127.0.0.1:8765/ and drop a .dm_1 file on the page (or use Open demo). tools/serve.py is python -m http.server without browser caching (plain py -m http.server 8765 works too, but may keep old files after an update: reload with Ctrl+F5). Tested with Chrome 156, Edge 156 and Firefox 159 (Safari not yet); any browser with WebGL 2 and ES modules should work. py tools/serve.py 8790 --pages serves the site as GitHub Pages does (under /cod2-demo/, Cache-Control: max-age=600, ETag / 304, gzip for text files): open http://127.0.0.1:8790/cod2-demo/.

Regenerate the map data (Python)

Needs Python 3.10+ and Pillow with WebP (pip install pillow), nothing else. The game folder is the CoD2 install with main/ (stock iw_*.iwd, localized localized_*_iw*.iwd, and for the zPAM map pack and radar images zpam_maps_v7.iwd / zpam409.iwd).

Language. The exporters read the English localized archives (localized_english_iw00..11.iwd): the voice overs and battle chatter (the quick messages, the announcer), the localized strings (weapon, map and game type names, HUD texts, obituaries), the localized materials and images. As the game does, they are searched after every other archive and only those of one language - whatever the install's localization.txt says. The one exception is the censored textures: the German release's versions of the 58 localized materials that draw insignia (flags, banners, crates, posters, uniforms, the scoreboard's German flag) and their *_local images, so the site shows no swastikas; for these the German archives (localized_german_iw*.iwd) must be in main/ too, else the export stops. Both are set at the top of tools/export/cod2/iwd.py (LANGUAGE, CENSORED_FROM). After a change of the localized archives, run sound_export.py (the voices) and hud_export.py (the strings); the maps, characters and effects only use the censored materials, which do not depend on the language.

25 of the game's images are wavelet compressed (mp_matmata's well, mp_toujane's vase, a few HUD and effect images): their decode tables are read from the game folder's own gfx_d3d_mp_x86_s.dll (or gfx_d3d_x86_s.dll) - not shipped here.

py tools/export/export_maps.py --game "C:/Games/Call of Duty 2"
py tools/export/export_maps.py --game "C:/Games/Call of Duty 2" --map mp_toujane --map mp_carentan
py tools/export/sound_export.py --game "C:/Games/Call of Duty 2"
py tools/export/characters_export.py --game "C:/Games/Call of Duty 2"
py tools/export/fx_export.py --game "C:/Games/Call of Duty 2"
py tools/export/viewmodels_export.py --game "C:/Games/Call of Duty 2"
py tools/export/hud_export.py --game "C:/Games/Call of Duty 2"
Option (export_maps.py) Meaning
--game DIR the install folder (with main/)
--map NAME only this map (repeatable); default: every maps/mp/*.d3dbsp in the archives
--out DIR output folder (default maps3d/)
--tex-size N largest colour / detail map edge (default 1024; normal and specular maps: 512)
--topdown-size N edge of the top-down image (default 2048)
--force re-encode textures already in maps3d/_textures
--no-icons skip the kill icons
--icons-only only the kill icons (assets/killfeed/: each weapon's killIcon, mirrored where the weapon file sets flipKillIcon - as the game's kill feed shows it)
--grid-only only add the light grid (lightgrid.bin), its collision (collision.bin), the sun keys and the props' lighting keys to maps already exported
--entities-only only add the hurt triggers (trigger_hurt: boxes and damage) to the entities.json of maps already exported

Per map it writes into maps3d/<map>/: world.bin/json (BSP triangle soups: positions, normals, vertex colours, UVs, lightmap UVs, indices; one draw group per material + lightmap with its blend / alpha test), lightmap<N>.webp (the baked light: each texel's 4 coefficients weighted as the game does for a surface without normal map), lmc<N>_<k>.webp (the 4 coefficients, weighted per pixel by the normal maps) and sun<N>.webp (the sun's visibility, 1024 × 1024), lightgrid.bin (the models' light grid: the BSP's grid points and colours as stored), collision.bin (the world's solid brushes and terrain triangles: the grid's visibility traces), sky.webp (the six cube faces), props.bin/json (each misc_model's LODs from its XModel - their switch distances, the coarser ones' meshes - with its flags and, for normal-mapped models, the tangents; placements as 3 × 4 matrices with the compiler's ground light and the entity's light scales), entities.json (spawns, bomb sites, props), topdown.webp and radar.webp; shared textures in maps3d/_textures/ (colour and detail maps; normal maps with the specular power in blue, <normal>+<specular>.webp; the specular maps' colour), the index maps3d/index.json, the 2D registry assets/maps/backgrounds.js and the kill icons in assets/killfeed/. All 32 maps (15 stock + 17 zPAM map pack) take about 13 minutes with --force and ~560 MB (+ ~119 MB shared textures); world.json also holds the map's fog (setExpFog / setCullFog of its level script) and each group's sort key and polygon offset level. sound_export.py writes maps3d/_sound/ (761 aliases + zPAM's 3 redefinitions, ~28 MB, 22 kHz mono WAV - also from zPAM's 48 kHz stereo WAVE_FORMAT_EXTENSIBLE files, its FG42 reload sounds -, the volume falloff curves and hold_breath.shock's channel volumes; no map ambience / music - but zPAM's own aliases in those folders: its aim trainer's piano notes and horse). characters_export.py writes maps3d/_characters/ (116 body / head / helmet models skinned to their skeletons - with their coarser LODs and switch distances -, the 35 weapons' world models, the 7 grenade / rocket projectile models and the 58 models of the effects' model elements - shell casings, the burnt smoke grenade, debris -, each surface with its material's colour, normal + specular and detail maps, tangents, alpha test or blend, sort key and polygon offset, ~18 MB, and the 280 animations of the multiplayer anim tree by the index the demo sends, with their gun-hand notetracks, ~6 MB; textures into maps3d/_textures/). Without it the 3D view falls back to capsules. fx_export.py writes maps3d/_fx/effects.json (all 380 effects of fx/ with their curves, the impact tables, the weapons' flash / explosion / shell eject effects and which are bolt-action, 102 effect materials; ~2.1 MB, textures into maps3d/_textures/ at their own size up to --tex-size 1024 px; --force encodes the effect textures again, not the ones a map or a model wrote larger); without it the 3D view draws the marker clouds. viewmodels_export.py writes maps3d/_characters/viewmodels.json + .bin (the 44 weapons' gun models and the 9 hands models with their normal, specular and detail maps, ~3.7 MB, with each weapon file's view animation names, its numbers for the motion, the ADS and the crosshair, the reticle and scope overlay images) and vanims.json + .bin (the 309 view animations, ~2.1 MB); without it first person shows no weapon. hud_export.py writes maps3d/_hud/ (hud.json: both HUD menu layouts - stock and zPAM - as parsed from ui_mp/hud.menu, zPAM's scoreboard menu, the six fonts' glyph tables, the English text of 1746 localized strings, the weapon files' HUD fields and combat numbers, the default key binds, the game type and map names; and 205 HUD images as PNG - hints, stance, damage direction, scoreboard flags and status icons included; ~2.8 MB); without it first person shows no HUD. Display names of new maps go into assets/maps/maps.js.

tools/gen_tables.py regenerates js/c2/parser/tables.js (the Huffman code table built from the engine's msg_hData and the net field tables).

Validation

py tools/validate/check_alignment.py --spawns mp_toujane mp_carentan mp_burgundy mp_dawnville mp_matmata

checks a map's spawn points (or a demo's positions: positions.json dumped from window.cod2.data in the browser console - {map, pts: [[client, x, y, z, flags]]}) against the export: inside the top-down image and on rendered geometry, inside the zPAM radar rectangle, and standing on a triangle of the 3D world. Results (2026-10-26), 14 demos and the spawn points of all 32 maps:

Demo (map) Samples Inside top-down On rendered 2D Inside radar On a 3D triangle
zPAM 3.34 match (mp_burgundy_fix) 33,622 100 % 99.9 % 100 % 99.5 %
zPAM 4.09 match (mp_burgundy_fix) 21,589 100 % 99.9 % 100 % 98.4 %
zPAM 4.09 match (mp_carentan_fix) 85,724 100 % 94.2 % 100 % 98.0 %
zPAM 4.09 match (mp_dawnville_fix) 30,204 100 % 99.9 % 100 % 99.6 %
zPAM 4.09 match (mp_matmata_fix) 26,463 100 % 100 % 100 % 97.3 %
zPAM 4.09 match (mp_toujane_fix) 36,949 100 % 100 % 100 % 97.5 %
zPAM 4.09 match (mp_railyard_mjr) 34,939 100 % 99.7 % 100 % 97.8 %
zPAM 4.09 match (mp_breakout_tls) 31,322 100 % 98.7 % 100 % 98.5 %
zPAM 4.09 match (mp_trainstation_fix) 33,070 100 % 99.8 % 100 % 98.1 %
zPAM 4.09 match (mp_vallente_fix) 50,606 100 % 100 % 100 % 99.3 %

The 3D misses are players on props or in the air (jumps, ladders). The 2D check is colour-based and counts very dark ground as empty (Carentan's shaded streets). The spawn points of all 32 maps: 100 % inside the top-down, 98–100 % on a world triangle (mp_brecourt 87.5 %: spawns in grass props).

All 14 sample demos parse without a warning: one zPAM 3.34 match on mp_burgundy_fix (CoD2x 1.4.3.1), nine zPAM 4.09 matches (CoD2x 1.4.6.8, incl. a 46-round overtime match on Carentan and a 29-round one on Vallente), short zPAM 3.34 clips (1.3) and stock 1.0 / 1.3 DM clips. Every match ends on the score the server sent (e.g. Burgundy 13:11 with halftime after round 12 and a timeout-interrupted round; Carentan 25:21 in 2 + 8 halves - zPAM's overtime halves of 3 rounds; Vallente 13:16).

Audits

tools/audit/ checks the whole project before a release (read-only; reports in tools/audit/out/, git-ignored):

Script Checks
py tools/audit/site_audit.py GitHub Pages readiness: manifest against the files, every reference with its exact case, .nojekyll, sizes, leftovers (local paths, console.log), the page head
py tools/audit/map_audit.py --game DIR every map of the game files against its export: geometry, model placements, textures, bounds, entities
py tools/audit/model_audit.py --game DIR the game's player sets (mptype / character scripts) per faction and map type against the export - bodies, heads, hats, attachments, LODs, material layers, alpha modes -, the anim tree, every weapon's world / view / projectile model, view animations, hands and tags, the textures decoded
py tools/audit/map_quality.py --game DIR [--decode] per map the materials' layers, alpha modes, sort keys and polygon offsets against the export, every source image decoded, lightmap pages, sun, fog, the props' LODs and heaviest models
node tools/audit/demo_audit.mjs every demo in demos/: players, teams, kills, scores, rounds, bomb events, MG tracks
py tools/audit/leftovers.py flash / stun grenades (CoD4 template, not in CoD2) left in the code, styles or exported data
node tools/audit/looks_compare.mjs <tagA> <tagB> two looks runs compared: per map and view the pixels that changed (> 8 / 255), the mean difference, the brightness before / after, difference images in out/looks/
py tools/serve.py 8778 --three <folder> the site with vendor/three/ from another folder (an npm package of three.js): the old and the new version side by side for a before / after
node tools/audit/turret_scan.mjs [demo ...] every use of a mounted MG: per demo the turrets (entity, model, weapon) and the ranges each player is on one - the recording player's with its turret entity and arc - (--dump a b: the player state's raw turret fields between a and b s); out/turrets.json
node tools/audit/demo_detail.mjs <demo> [client] the details behind a demo-audit finding: kill records, deaths against the scoreboard by phase, the positions around a jump
ui_audit.js, xray_audit.js pasted into the DevTools console on a loaded demo (the console is not bound by the page's CSP - eval from the page is): every control of both views, the X-Ray rules
node tools/audit/browser/run.mjs [suite ...] [--browsers chrome,edge,firefox] the browser checks, headless, against the dev server (py tools/serve.py 8776): ui (the UI audit, a 3D MP4, a 2D GIF), xray, start (the start state, remembered settings), features (tracers, enemy names, camera shake, low-health overlay, Tab, a first-person MP4), scores (the defaults fresh and after the settings migration, Kill icons gone, the scoreboard only while Tab is held - all views, form fields, the export), hit (the hit marker on every demo), sweep (every demo in a row: 2D, 3D, follow, first person, seek, X-Ray), badfiles (broken / non-demo files), layout (five window sizes), bump (the players' normal maps), alpha (the 3D canvas opaque in every frame: images and videos as the page shows them), baseline (load and playback per mode and speed, seeks - the performance figures), profile (time and allocations per frame step, the sampling heap profiler, shader programs derived again, seeks), fxhash (the effects' output hashed), closeup (a player close-up PNG, --pick x,y the surface at a pixel), spot (camera shots of a map spot as PNGs, --spot '{"shots":[...]}', with the map script's parts shown or hidden), info (the demos' zPAM version, server variables, phases), feed (the kill feed's clicks and round separators), vmscan (the first-person weapon's motion on every demo and player: zig-zags of its offsets), timebar (the round labels at three window sizes, matching the feed and the score banner, no overlaps, tooltips, clicks, the ranges, none in the export; the fullscreen player bar by real mouse and key input - real fullscreen in Chrome and Edge, emulated in headless Firefox, --headed for a real window), looks (the 3D view as PNGs per map from fixed views - overview, eye, first person, X-Ray, a player close up, an effect, ADS -; --tag x, --base another server: a before / after, compared by looks_compare.mjs), turretscan (every turret use of every demo in first person: the eye on tag_player, the gun aimed by the view, the MG crosshair, no view model, smooth mounting / leaving, a video), turret (one demo frame by frame on a turret, --probe '{"demo":...,"times":[...]}'), hudon (the Server HUD and Kill feed HUD parts on by default, fresh and migrated; on every demo and the stock clips inside the safe area, the kill feed's pixels clear of the other parts, in the export), vmprobe / samples (a client's first-person weapon frame by frame, the raw samples), decals (decal flicker), perf (load and frame time per map, --maps), leak (memory on demo changes); a line per run, details in tools/audit/out/browser/, exit code 1 on a failure or any console error / warning / failed request

tools/perf/ measures speed and memory (Chrome headless over the DevTools protocol, against py tools/serve.py 8790 --pages; results in tools/perf/results/):

Script Measures
node tools/perf/bench.mjs [phase ...] --label x [--base url] [--runs n] [--net cable] pageload (FCP, requests, bytes, render-blocking; desktop and Slow 4G + 4x CPU, cold and warm), coverage (JS / CSS used at the start and after a demo), demoload (a demo dropped in, cold and warm: parse, first 3D frame, world, props, light grid, all textures, all data; requests and MB per folder, main-thread blocking; --net cable: over 50 Mbit/s), play (2D, 3D free / follow / first person at 1x and 4x, uncapped: FPS, frame-time percentiles, work and render ms, draw calls), interact (seeks, panels, 2D / 3D switches, a first-person MP4), memory (heap, GPU estimate) -> results/<label>.json
node tools/perf/cpuprofile.mjs load|seek|play3d|playfp|play2d a CPU profile of the task: self time by file and function, results/cpu-<task>.cpuprofile
node tools/perf/probe.mjs <file.js> [--uncapped] [--no-demo] [--demo name] [--base url] a JS file (an async body, __perf helpers of page.js) run in the page after a demo load; prints its return value

Deploy to GitHub Pages

  1. Run py tools/audit/site_audit.py (0 problems expected but the ?debug picker's console.log). Create a repository and push the project (maps3d/ included - it is the site's data; the demos/ folder is git-ignored). Binary files are marked in .gitattributes; no Git LFS (Pages would serve the pointer files).
  2. Repository Settings → Pages: Deploy from a branch, branch main, folder / (root).
  3. The site is at https://<user>.github.io/<repository>/. .nojekyll makes Pages serve every file as is.
  4. maps3d/manifest.json lists size and hash of every data file; the 3D view loads each with ?v=<hash>, so a browser or CDN cache never mixes an old JSON header with a new binary. The exporters rewrite it at the end; after copying or editing files under maps3d/ by hand, run py tools/check_deploy.py manifest.
  5. index.html sets a Content-Security-Policy (<meta http-equiv>: only the site's own files, blob: for the parser and GIF workers, connect-src https: for a demo opened by link - "Open a demo by link"). Its sha256-… is the hash of the inline import map: after changing the import map (e.g. to the CDN, which then also needs its host in script-src), compute the new hash (base64(sha256(<the text between the import map's script tags>))) or the page stops loading three.js.
  6. After a push, compare the live site with the local folder (missing files, other sizes or contents, a 404 page or a Git LFS pointer served instead of the data; exit code 1 on a mismatch):
py tools/check_deploy.py check --url https://<user>.github.io/<repository>/

Project layout

LICENSE                           GPL-3.0 (the code; the game data: see "Credits and licence")
index.html, css/                  page and styles (from cod4-demo3d)
js/c2/parser/                     CoD2 netcode: Huffman, bit reader, delta states, demo records
js/c2/analysis/                   collector + DemoData (players, teams, kills, rounds, grenades, sounds)
js/c2/common/, js/c2/ui/          constants, server commands, text; tables, 2D renderer, heatmap, export
js/app/                           app shell, match model, shared replay state
js/view2d/, js/view3d/            the two views (3D: map loader, props, actors, characters, effects, particle
                                  effects + light probe, sky, sound)
assets/maps/                      map registry and 2D backgrounds (generated), assets/killfeed: icons,
                                  assets/fonts: the self-hosted fonts with their licences
vendor/three/                     three.js r187 (build: three.module.js + three.core.js, OrbitControls; MIT licence)
maps3d/                           exported map data (generated)
tools/export/                     Python export (iwd, iwi, materials, bsp, xmodel, xanim, top-down, sounds,
                                  characters, effects, view models, HUD)
tools/validate/                   alignment check;   tools/serve.py: local server
tools/audit/                      release audits (site, maps, demos, UI, X-Ray)
tools/check_deploy.py             data manifest + check of the deployed site

See PLAN.md for the format notes, decisions and what is still open.

Credits and licence

The code is GPL-3.0 (see LICENSE). Projects it builds on or was checked against:

  • cod4-demo3d (GPL-3.0): the app shell, the match and replay model, the structure of the 2D and 3D views, the panels and the video export are ported from it.
  • cod2-demoparser: the CoD2 demo format - records, the Huffman table, the net field tables.
  • CoD2x by callofduty2x: its decompiled CoD2 1.3 client (src/other) is the reference the demo format, the net field tables, the events and the scoreboard command were checked against and the light-grid lighting of the models was taken from; also CoD2x 1.4's protocol, its radar formula (cg_hudRadar*) and how it draws colour codes in names. Last checked against CoD2x 1.4.6.9-test.1 (2026-10-18; protocol generation 6 as 1.4.6.8 - its new automatic demo recording and upload does not change the demo format).
  • zPAM 3 by eyza-cod2: the competitive match logic of zPAM 3.34 / 4.09 - rounds, halftimes, overtime, timeouts and scores from its server info and messages, its own assist count and the scoreboard values it sends (sd.gsc, _menu_scoreboard.gsc). Last checked against zPAM 4.12 (2026-11-12; 4.10 / 4.11 add server-side match data - weapon-class kills, multi-kills, trades, leaver and ready-up timeouts - and name the zPAM weapons in the round report; 4.12 adds an AFK kick, a longer server log and a birthday event in ready-up with its own models, effects and sounds - those are in zpam412.iwd, not in the exported game data: a 4.12 demo plays without them. The game data the viewer exports stays that of zpam409.iwd / zpam_maps_v7.iwd; the map scripts read from it - maps/mp/_bug_fix.gsc and zPAM's versions of the stock maps' scripts - are the same in 4.12).

three.js is MIT (vendor/three/LICENSE); the fonts Inter and JetBrains Mono are under the SIL Open Font License 1.1 (assets/fonts/*-OFL.txt).

Game data: the 3D maps, textures, top-down images, sounds, player and weapon models, animations, effects and kill icons (maps3d/, assets/killfeed/) are extracted from Call of Duty 2's game files; the maps of the zPAM map pack and the radar images from the zPAM files. They are not covered by the GPL - they belong to their owners (Activision, the authors of the custom maps and of zPAM) and are included only so that the viewer can show the maps. Call of Duty is a trademark of Activision; this project is not affiliated with Activision or Infinity Ward.

About

CoD2 Demo Inspector / Analyzer

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages