Skip to content

WIP: Advanced demo tools — seekable playback + cinematic cameras (#377)#849

Draft
Eclipse1982 wants to merge 18 commits into
jdolan:mainfrom
Eclipse1982:feature/demo-system
Draft

WIP: Advanced demo tools — seekable playback + cinematic cameras (#377)#849
Eclipse1982 wants to merge 18 commits into
jdolan:mainfrom
Eclipse1982:feature/demo-system

Conversation

@Eclipse1982

Copy link
Copy Markdown
Contributor

Summary

⚠️ Work in progress / draft — not ready to merge. Opening early for visibility and feedback on the overall direction.

This implements "Advanced demo tools" (#377): a seekable v2 demo container, server-hosted scrubbable playback, multiple spectator/cinematic cameras, omniscient sv_record, and the supporting UI. Built on the feature/demo-system branch.

Addresses #377.

Changes

  • v2 demo container (src/common/demo.{c,h}): timecoded, self-framing records with a keyframe seek index; legacy v1 demos still play through the original forward-only path.
  • Seekable playback: the client recorder splits each packet into reliable-prefix / isolated-frame / datagram-suffix records; on load the server decodes the whole demo through a PACKET_BACKUP-deep ring (so each frame decodes against its true delta base) and re-emits a clean keyframe + sequential-delta stream, so seeking cannot break the delta chain. Seek catch-up is throttled to stay within the loopback ring.
  • Cameras: free-fly spectator, follow/cycle any player's POV, cinematic auto-orbit, and a Catmull-Rom keyframed dolly path — plus follow/orbit smoothing, an FOV override, letterbox bars, free-cam roll, HUD/timeline toggles, and a one-shot demo_cinematic preset.
  • sv_record: omniscient server-side multi-POV recording that plays back through the same path.
  • UI: Demos browser in the main menu, an in-game timeline scrubber bar, and a Settings → Controls → Demo key-bindings tab.
  • Robustness: guard NULL precached assets in several cgame effect paths so demo playback survives an install that is missing some assets.

Testing

  • Builds warning-free on Windows (MSBuild, ClangCL, x64 Release)
  • make / make check on Linux not run this round (tree still has the known pre-existing check_master link failure)
  • Tested in-game on Windows: record, forward playback, and seeking/scrubbing on both freshly-recorded and older demos; free / follow / orbit cameras
  • Not exhaustively validated (see Notes)

Notes

This is a draft and still has rough edges — please don't merge as-is. Sharing it now to get a read on the approach before further polish.

Known unfinished / unvalidated areas:

  • Seeking/scrubbing works in local testing on old and new demos, but the playback re-encode path is intricate and may still have edge cases on unusual demos.
  • sv_record (omniscient server demos) shares the playback path but has not been exercised end-to-end yet (needs a live multiplayer game to record one).
  • The cinematic view options (letterbox / FOV / orbit framing) are crash-free but not yet thoroughly visually validated.
  • The change set is large and spans client / server / cgame / UI; it would likely be better reviewed and merged in smaller pieces.

Open questions for the maintainer: is server-hosted scrubbing the right playback model, and does the v2 container/record interaction warrant a PROTOCOL_MINOR bump? Feedback welcome.

Server-hosted, client-controlled v2 demo playback; free/follow camera;
seek/pause/speed/reverse; serverrecord omniscient multi-POV; director
camera paths + ObjectivelyMVC timeline scrubber. OBS plugin deferred.
New src/common/demo.{h,c}: a versioned, timecoded, keyframed demo container
shared by the (forthcoming) client/server recorders and playback engine.

- QDM2 magic + header + epoch block; legacy v1 detection via Demo_IsV2.
- Self-framing timecoded records (FRAME_DELTA/FRAME_KEY/RELIABLE/CAMERA).
- Seek index footer (write/read) with a scan-based rebuild fallback for
  footer-less (crash-truncated) files, and Demo_IndexFloor for seek lookup.
- Portable little-endian field I/O; depends only on common (no libnet).

Unit-tested in src/tests/check_demo.c (8 checks: header/record/index
round-trips, magic detection, floor lookup, scan fallback). No engine
behavior changed; module is not yet referenced.
Adds cg_demo.{c,h}: a detached free-fly camera (PM_SPECTATOR) controllable
during demo playback, toggled with the new 'demo_freecam' command (cvar
cg_demo_freecam). The recorded player stays frozen; only the view is
overridden, so this works on existing v1 demos as well as v2.

- Cg_UsePrediction grants prediction to the freecam (bypassing the demo
  freeze) so the camera integrates from local input via Cg_PredictMovement.
- Cg_PrepareView routes to Cg_UpdateDemoCamera when active, skipping the
  recorded-player origin/angles, third-person and bob.
- First-person weapon model suppressed while the freecam is active.
- Camera speed is real-time (driven by command msec), independent of demo
  playback speed, so it works during slow-motion/fast-forward.

Follow/cycle POV is deferred to land with multi-POV server demos (P5).
Records demos in the v2 container (cvar cl_demo_v2, default on) by wrapping
the exact v1 wire messages as timecoded, self-framing records: startup
(server_data/config strings/baselines) as RELIABLE records at timecode 0,
then per-frame FRAME_KEY/FRAME_DELTA records (datagram-only packets as
RELIABLE), stamped with a demo-relative timecode. v1 recording is preserved
when cl_demo_v2 is 0.

Server-hosted playback detects the QDM2 magic (Demo_IsV2) and drives a demo
clock (sv.demo_time, advanced per tick, scaled by time_scale). Sv_SendClientPackets
transmits every record whose timecode has been reached, reframed exactly as
the client expects, so playback parses identically to live. Legacy v1 demos
fall through to the original one-message-per-tick path unchanged.

This lays the timecoded substrate for seeking (P4) without touching the
delta-compression or re-encoding frames. No protocol change.
Recorder now emits a re-encoded self-contained keyframe every
DEMO_KEYFRAME_INTERVAL frames (full player state + every entity from its
baseline, byte-identical to a natural uncompressed frame), giving seeking
nearby entry points. The server scans these on load (Demo_ScanIndex) into a
keyframe index.

New server commands (listen-server console during v2 playback):
- demo_seek <s|+s|-s>  jump to nearest keyframe <= target, replay to target
- demo_pause           freeze/resume the demo clock
- demo_speed [x]       playback rate 0.1-8.0 (independent of the realtime cam)
- demo_step [n]        step n frames and pause

Seeks need no client-side discontinuity handling: Cl_UpdateLerp already
clamps cl.time into the current frame's range each frame, and a keyframe
forces a no-lerp snap. Reverse = repeated backward seeks.
Adds a 'Demo' tab to Settings > Controls (DemoBindsViewController, mirroring
MovementCombatViewController) with BindTextView rows for: toggle free camera,
pause/resume, skip back/forward 5s, step back/forward, slower/faster.

Adds demo_speed_up / demo_speed_down relative server commands so playback
speed is bindable. Wired into both the autotools (ui/controls/Makefile.am)
and MSBuild (cgame.vcxproj) builds.
Adds a 'Demos' main-menu entry (DemosViewController in ui/play) that lists
recorded demos/*.demo in a table (cgi.EnumerateFiles) and plays the selected
one via cgi.Cbuf("demo <name>") on the Play button or a double-click, with a
Refresh button. Wired into autotools (ui/play/Makefile.am) and MSBuild
(cgame.vcxproj).
The P1 demo module was wired into the autotools build but the matching
Windows (MSBuild) project entry was added during local build setup and not
committed. This ensures a fresh checkout compiles demo.c on Windows.
…lan#377)

New src/server/sv_demo.c records a v2 demo entirely server-side: sv_record
[name] / sv_stoprecord. Each tick (after the game frame) it captures an
omniscient snapshot of every visible entity (no per-client PVS limiting) plus
a default player state following the first in-game client, delta-encoded into
v2 FRAME records with periodic keyframes, behind the same epoch (server data /
config strings / baselines) a client demo uses. Plays back through the
existing server-hosted v2 path; the free camera detaches as usual.

Sv_WriteEntities / Sv_WritePlayerState are exposed (un-static) for reuse.
Recording stops cleanly on map change (Sv_ClearState). No protocol change;
default single POV. Per-player POV switching follows via client-side
follow/cycle (chase of player entities, which are all present omnisciently).
Generalizes the demo camera into a mode enum (locked / free / follow) and adds
demo_follow_next / demo_follow_prev: a third-person chase that cycles through
the player entities present in the frame. Since every player's entity is
recorded (omnisciently for serverrecord, and present in any demo), this gives
per-player POV switching with no protocol change. Wired into the Demo binds
tab. cg_view/cg_entity now gate on Cg_DemoOverridingView (free OR follow).
Adds a director camera: fly the free camera and demo_cam_add drops a keyframe
(time + origin + angles); demo_cam_play flies a smooth Catmull-Rom dolly
through them (shortest-arc angle blend), driven by the demo timeline so it
tracks scrubbing. demo_cam_clear, and demo_cam_save/load to a sidecar
(demos/cinematic.cam). Drop-keyframe and play are bindable in the Demo tab.
The demo-playback server pushes its status (time, duration, paused, speed) to
the cgame each frame via the CS_DEMO_STATUS config string (transmitted
explicitly, since the demo path does not flush the multicast). The cgame draws
a non-modal timeline bar at the bottom of the screen during playback
(Cg_DrawDemoBar): progress fill + playhead + MM:SS / MM:SS readout with the
speed and a PAUSED indicator. Completes the P6 director tooling.
…lan#377)

Seeking dropped the client with 'Delta frame too old' because recorded delta
frames can reference a base older than the seek-target keyframe (listen-server
ack timing means delta_frame_num isn't always frame-1). Once seeking skips
those bases, the chain breaks.

The recorder now re-encodes any frame whose delta_frame_num precedes the last
keyframe as a clean delta from the immediately-preceding frame (mirroring
Sv_WriteEntities), so every frame after a keyframe is reconstructable from that
keyframe forward. Frames whose delta is already safe are still captured
verbatim (datagrams/sounds preserved); only the unsafe few are rebased.
Re-architect v2 demo playback so seeking can't break the delta chain:

- The recorder splits each server packet into [reliable prefix]
  [isolated frame][datagram suffix] records, so the frame command is in
  pure engine wire format the server can decode without the cgame.
- On load the server decodes the whole demo through a PACKET_BACKUP-deep
  ring (mirroring the client, so each frame decodes against its true
  delta base, not just the previous frame) and re-emits a clean stream:
  a keyframe every DEMO_KEYFRAME_INTERVAL frames plus sequential deltas,
  renumbered from 1 so no delta ever bases off frame 0 (which the client
  reads as uncompressed). Older un-split demos are handled by skipping
  the engine-command prefix.
- Throttle the seek catch-up to a few records per tick so the replayed
  backlog cannot overrun the 64-slot loopback ring and silently drop
  frames ("Delta frame too old").
…olan#377)

Demo playback replays sounds and effects that may reference assets absent
from the local install; several cgame paths dereferenced them blind:

- Cg_ParseSound consumed only part of its message on a NULL sample,
  desyncing the rest of the packet ("Illegible server message"); it now
  reads the whole message before bailing.
- Guard NULL precached assets at the choke points: Cg_AddSprite (NULL
  media, replacing a release-stripped assert), Cg_AnimationLifetime (NULL
  animation), Cg_AddDecal (NULL image), and Cg_RippleSplashEffect (NULL
  brush-side material plus an off-by-one bound). Cg_ClientEffectColor now
  range-checks the wire client index instead of asserting.
- Add an auto-orbit camera (demo_orbit), time-constant smoothing for the
  follow/orbit cameras, a cinematic FOV override, letterbox bars, free-cam
  roll, HUD and timeline toggles, and a one-shot demo_cinematic preset.
- Reset all demo camera state between demos (Cg_ClearDemo via Cg_ClearState)
  and re-acquire a live follow target each frame, so the camera never locks
  onto a stale "ghost" entity after a seek or a player leaving the view.
- Expand the Settings -> Controls -> Demo bindings tab with all the new
  commands, organized into Camera / Cinematic / Playback / Camera path.
@Eclipse1982 Eclipse1982 force-pushed the feature/demo-system branch from 8aa15af to 8e77c8e Compare June 14, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant