Doom Demo Playback: Tic Commands, Version Headers, and Reproducible Runs
Inspect Doom demo framing, shorttics and longtics input records, playback dependencies, and safe ways to reproduce desyncs without treating a demo as video.
A Doom demo is a compact simulation-input recording, not a video. Instead of storing pixels, it stores the commands that player controllers supplied over successive game tics. During playback the engine runs the game again and feeds those commands back into the simulation. The same file can therefore produce different outcomes when the game version, loaded data, or demo format assumptions differ.
This deep dive is deliberately narrow: it describes the classic v1.9 shorttics layout and Chocolate Doom’s recognized v1.91 longtics extension, using the project’s pinned 3.1.1 source. It is not a universal specification for Boom-family extended demos, multiplayer extensions, every source port, or every early DOS release. Keep an original demo unchanged and use only game data and recordings you are authorized to access.
A demo replays commands, not captured frames
The classic Doom simulation advances in discrete tics. A recording stores a ticcmd_t for each active player at each step. In the shorttics representation, the command bytes are forward movement, side movement, a low-resolution turn value, and buttons. Chocolate Doom reconstructs shorttics turning by placing the recorded byte in the high byte of its angle-turn field. In its longtics path it reads two bytes for that value instead, while the other command fields remain present. That changes the record from four bytes per player per tic to five; it does not turn the demo into a higher-frame-rate video.
The player’s movement and buttons are only part of the replay contract. The engine must also begin from compatible initial conditions: the selected game and map, skill, gameplay flags, active-player mask, and the loaded IWAD and any add-on data that affect the level. The demo file does not bundle those game assets. A valid file can parse correctly and still diverge when replayed against a different map, patch, engine behavior, or compatibility mode.
Read the header before counting tic records
For the classic v1.9 path in Chocolate Doom 3.1.1, the header is 13 bytes before the first command: one version byte; skill, episode, and map; deathmatch, respawn, fast, and no-monsters flags; the console-player index; then four player-presence bytes. The source writes and reads these fields in that order. The presence bytes are important: a tic contains one command for every active player, not necessarily one fixed-size command total.
The v1.91 longtics extension changes the version byte and the angle-turn encoding. Chocolate Doom’s source labels it a non-vanilla extension and gates its use through the project’s non-vanilla compatibility handling. Do not infer that a file beginning with a familiar value is valid simply because one implementation recognizes that marker.
Older demo streams need separate treatment. The playback code recognizes legacy version values from 0 through 4 as a format with no leading version byte, rewinds to the start, and supplies defaults for fields that are absent from that older header. A parser that assumes every file starts with the v1.9 version byte will shift every later field for those recordings. The sample below rejects legacy streams instead of guessing how to interpret them.
At the end of the stream, the classic reader recognizes 0x80 as DEMOMARKER before decoding another command. In a multiplayer recording, the marker belongs after the commands for the active players in that tic; it is not a per-player field. A structural reader should advance by the entire active-player group, then look for the marker at the next tic boundary.
A bounded inspector for one explicitly supported subset
This Python example reports the header version, active player slots, and number of complete tic groups. It accepts only the 13-byte-header v1.9 (109) and v1.91 longtics (111) layouts described above. The 64 MiB limit is an example of an operator-selected resource policy, not a limit in the Doom file format. The code does not decode button meanings, verify that a chosen IWAD is correct, prove deterministic behavior, or validate extended demo dialects.
from pathlib import Path
import sys
MAX_BYTES = 64 * 1024 * 1024 # Local inspection policy, not a format maximum.
DEMO_MARKER = 0x80
SUPPORTED_VERSIONS = {109: 4, 111: 5} # Version: bytes per player command.
HEADER_SIZE = 13
def read_bounded(path: Path) -> bytes:
with path.open("rb") as stream:
data = stream.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise ValueError("demo exceeds this inspector's size policy")
return data
def inspect_classic_demo(data: bytes) -> dict:
if len(data) < HEADER_SIZE:
raise ValueError("truncated versioned demo header")
version = data[0]
try:
command_size = SUPPORTED_VERSIONS[version]
except KeyError as exc:
raise ValueError(
f"unsupported demo version {version}; this inspector handles only 109 and 111"
) from exc
# Bytes 9..12 are the four player-presence fields in this scoped layout.
player_slots = [index for index, present in enumerate(data[9:13]) if present]
if not player_slots:
raise ValueError("header has no active players")
bytes_per_tic = len(player_slots) * command_size
cursor = HEADER_SIZE
tic_count = 0
while cursor < len(data):
if data[cursor] == DEMO_MARKER:
# Strict local policy: the terminator must be the final byte.
if cursor != len(data) - 1:
raise ValueError("unexpected trailing bytes after demo marker")
return {
"version": version,
"player_slots": player_slots,
"tic_count": tic_count,
}
if len(data) - cursor < bytes_per_tic:
raise ValueError("truncated player-command group")
cursor += bytes_per_tic
tic_count += 1
raise ValueError("demo marker is missing")
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} DEMO.lmp")
summary = inspect_classic_demo(read_bounded(Path(sys.argv[1])))
print(summary)
Test a parser like this with synthetic byte arrays before pointing it at a preservation copy: one active player with zero commands and a marker; two active players with complete groups; shorttics and longtics; a missing marker; a partial final group; an unsupported version; a header with no active players; trailing bytes; and a file one byte over the local read cap. Synthetic fixtures make the expected structure explicit and avoid distributing or modifying commercial demo files. This is a strict inspector policy, not an assertion that every historical engine rejected every trailing byte the same way.
Reproduce a run with its inputs recorded
For an authorized v1.9 demo and the exact game data it expects, ordinary playback in Chocolate Doom is a useful first reproduction. The project’s startup code accepts -playdemo with a demo argument, tries to add the supplied file to the loaded data list, and retains the name-based fallback used for built-in demos. Its current source also makes the .lmp extension optional in the same way the original command-line convention did. With explicit paths, record what was actually supplied:
IWAD="$HOME/games/doom/DOOM.WAD" # Your authorized copy
DEMO="$HOME/demos/e1m1-reference.lmp" # Your authorized recording
sha256sum "$IWAD" "$DEMO"
chocolate-doom --version
chocolate-doom -iwad "$IWAD" -playdemo "$DEMO"
The hashes identify the exact bytes used in the test; they do not prove that the demo and IWAD belong together or establish provenance. Add the source-port release or commit, operating system, command line, ordered PWAD/DEH inputs, selected compatibility settings, and the visible point of divergence to the test record. Never rename or rewrite the original just to make a mismatch disappear. Run against a working copy when the workflow may write configuration or save data.
If playback diverges, first verify the file hash and the expected IWAD and add-on order. Then compare the demo’s version byte and player mask with the format your chosen engine says it supports. Only after those checks should you investigate simulation compatibility, such as a source-port version or gameplay behavior that differs from the recorder. A mismatch is evidence to investigate, not proof that a file is corrupt.
-timedemo is a different experiment. Chocolate Doom’s implementation enables single-tic timing and reports elapsed real tics and an FPS value; -nodraw can disable rendering for that measurement. This can help compare performance on a fixed test, but a fast run is not evidence that a port reproduces the same game behavior. Use normal -playdemo when the question is playback compatibility, and keep performance results separate from synchronization results.
Turn demos into regression evidence, carefully
The Chocolate Doom project’s Quickcheck repository describes a small demo-based presubmit check for source-port changes. Its README contrasts it with the more involved Statcheck setup, which uses Compet-N demos that depend on copyrighted IWADs, and says Quickcheck uses a selected set of fan-made PWAD files plus D2ALL long-form demos from the Doomed Speed Demos Archive. That is a useful testing pattern, but not a blanket license statement: confirm the rights and acquisition terms for each fixture before redistributing it. Keep a small authorized fixture set, pin the engine and content revisions, replay it after relevant changes, and compare an explicit expected result.
A demo-based test has a boundary. Reaching the terminator proves only that this run consumed the stream without the observed early exit; it does not prove that every frame matched an original recording. Stronger regression checks can capture deterministic state checkpoints or compare a project-supported status output at chosen tics, while ensuring that the measurement itself does not alter timing. Keep graphics, audio, input-device, and benchmark tests separate when their host-side behavior is not represented by the input stream.
For archival work, retain the unmodified demo, its cryptographic hash, provenance, expected game-data hashes or lawful local identifiers, the precise port/version, command line, and a concise playback outcome. If a conversion is required for another port, save it as a derivative with its own hash and document the tool and options. A demo is a valuable record of an interaction, but it is not self-describing proof of its own compatibility.
Related:
- Doom WAD Internals: Lump Tables, Search Order, and Defensive Parsing
- DOSBox, DOSBox Staging, or DOSBox-X? Choose by Compatibility Evidence
Sources:
- Chocolate Doom 3.1.1 pinned
g_game.csource - relevant sections includeG_Ticker(lines 960-975),G_VanillaVersionCode(2117-2130),G_BeginRecording(2138-2175),G_ReadDemoTiccmd(1973-1999),G_DoPlayDemo(2231-2305), andG_TimeDemo(2317-2360). The URL is pinned to commit410d96855b5df5410ff591a90efeafa889119224so these physical line references remain reproducible; raw-text links do not support clickable line fragments. - Chocolate Doom 3.1.1 pinned
doomdef.hsource - line 37 definesDOOM_191_VERSIONfor the 1.91 longtics extension. - Chocolate Doom 3.1.1 pinned
d_main.csource - lines 1628-1685 cover-playdemo/-timedemo,.lmphandling, and file/lump fallback. - Chocolate Doom Quickcheck README - upstream description of a small demo-based presubmit check and its authorized fixture approach.