From 9ddfa571e8468eb10a2c2ef88d8fed7e35dd62da Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 00:35:18 -0500 Subject: [PATCH 01/18] docs: design spec for advanced demo system (#377) 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. --- .../2026-06-13-quetoo-demo-system-design.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-13-quetoo-demo-system-design.md diff --git a/docs/superpowers/specs/2026-06-13-quetoo-demo-system-design.md b/docs/superpowers/specs/2026-06-13-quetoo-demo-system-design.md new file mode 100644 index 000000000..e314ceaf1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-quetoo-demo-system-design.md @@ -0,0 +1,259 @@ +# Quetoo Advanced Demo System — Design Spec + +- **Issue:** jdolan/quetoo#377 "Advanced demo tools" +- **Date:** 2026-06-13 +- **Status:** Approved (architecture), implementation pending +- **Branch:** `feature/demo-system` (worktree off `origin/main` @ `fab1d8a68`) +- **Author:** James + +## 1. Scope + +**In scope (this effort):** +- **A —** Free detached spectator camera + follow/cycle any player's POV during playback. +- **v2 format + engine —** A versioned, timecoded, keyframed demo container and a seekable playback path. +- **B —** Timeline control: seek (absolute/relative), pause, variable speed, frame step, reverse. +- **C —** Director tools: keyframed cinematic camera paths + an ObjectivelyMVC timeline/scrubber UI. +- **D —** `serverrecord`: server-side, omniscient, multi-POV demo recording. + +**Out of scope (deferred):** +- **E — OBS plugin / native video bridge.** Deferred. However, the engine stays *OBS-friendly*: a real-time camera clock decoupled from playback speed, clean window capture, and a console-drivable control surface, so E is purely additive later. + +**Non-goals:** +- No re-encoding or transcoding of existing v1 demos. They remain playable read-only, forward-only. +- No netcode/protocol bump for *live* play. The only wire change is a client-side capability to receive a multi-POV player-state array, gated by a new server→client capability flag, and only emitted during demo playback. + +## 2. Background — current state + +Anchored to the worktree base (`feature/demo-system`). + +- **v1 format:** header written once (`Cl_WriteDemoHeader`, `src/client/cl_demo.c:28`) = `SV_CMD_SERVER_DATA` (with `demo_server=1`) + all config strings + all entity baselines + `precache 0`. Then each server message is appended as `[int32 LE length][bytes]` (`Cl_WriteDemoMessage`, `src/client/cl_demo.c:95`), terminated by `[int32 -1]`. +- **Playback is server-driven:** a local server in `SV_ACTIVE_DEMO` reads one message per tick (`Sv_GetDemoMessage`, `src/server/sv_send.c:344`) and `Netchan_Transmit`s it to the client (`Sv_SendClientPackets`, `src/server/sv_send.c:408`). The client is a normal connected `CL_ACTIVE` client receiving a normal-looking stream. +- **Why no seek today (the FIXME):** `src/server/sv_send.c:340` — *"Multiple messages can constitute a frame. We need a mechanism to indicate frame completion, or we need a timecode in our demos."* The format has no timecodes and no frame boundaries, so random access is impossible. +- **Why POV is locked:** one line — `if (cl.demo_server) { frame->ps.pm_state.type = PM_FREEZE; }` (`src/client/cl_entity.c:37`). Prediction is also disabled in demos (`Cg_UsePrediction`, `src/cgame/default/cg_predict.c:34`). +- **Tick model:** `QUETOO_TICK_RATE 40` → 25 ms/tick (`src/quetoo.h:159`). Frame ring `PACKET_BACKUP = 128` (~3.2 s). +- **Spectator physics already exist:** `Pm_SpectatorMove` (`src/game/default/bg_pmove.c:1239`), invoked for `PM_SPECTATOR`. Third-person/chase math: `Cg_UpdateThirdPerson` (`src/cgame/default/cg_view.c:76`). +- **View pipeline:** `Cg_PrepareView` (`src/cgame/default/cg_view.c:319`) sets `cgi.view->origin/angles` once per frame from the (interpolated or predicted) player state. + +## 3. Keystone — server-hosted, client-controlled v2 playback + +v2 demos are hosted by the same local-server replay model as v1 (so the client remains a normal connected client and **the full parse/decode/interpolate pipeline is reused unchanged**), with three additions: + +1. The **server demo reader is v2-aware**: it parses v2 records, tracks a **demo clock** it advances by real frame time × speed (not locked to sv tick), and reframes records into standard `SV_CMD_*` messages for the client. +2. The **client owns the user-facing controls** (`demo_*` commands and the scrubber UI), which drive the server demo reader over the existing reliable channel (loopback, zero latency). +3. **Seeking is a server file operation**: jump to the nearest keyframe ≤ target, re-emit it as a full (non-delta) frame, fast-replay deltas to the target, resume. Backward and forward seek are the same operation. + +**Why not fully client-side:** the client parse path is tightly coupled to connection state (netchan, `cls.state`, reliable channel). Decoupling it is high-risk for no feature gain — every timeline feature, multi-POV, the free camera, and the scrubber are all achievable on the server-hosted model. The client still *owns the timeline* (it issues the control commands and runs the camera on its own real-time clock); the server merely owns file I/O, reframing, and seeking. + +**Legacy:** format detection is a single magic check. No magic → v1 → existing server-driven, forward-only path, untouched. + +## 4. The v2 demo container format + +New shared module: **`src/common/demo.{h,c}`** — read/write primitives used by *both* the client recorder and the server recorder, so both produce byte-identical containers. + +### 4.1 File layout + +``` +┌ File header ───────────────────────────────────────────────┐ +│ magic : "QDM2" (4 bytes) │ +│ format_version : uint16 (=2) │ +│ flags : uint16 (CLIENT_DEMO|SERVER_DEMO|HAS_IDX)│ +│ protocol_major : uint16 (PROTOCOL_MAJOR at record time) │ +│ protocol_minor : uint16 │ +│ tick_rate : uint16 (QUETOO_TICK_RATE) │ +│ epoch_len : uint32 (length of epoch block) │ +│ epoch block : SV_CMD_SERVER_DATA + config strings │ +│ + entity baselines (the v1 header) │ +└────────────────────────────────────────────────────────────┘ +┌ Record stream (repeating) ─────────────────────────────────┐ +│ type : uint8 (FRAME_DELTA|FRAME_KEY|RELIABLE|CAMERA) │ +│ timecode : uint32 (milliseconds from demo start) │ +│ length : uint32 (payload length) │ +│ payload : length bytes │ +└────────────────────────────────────────────────────────────┘ +┌ Footer (optional, present iff HAS_IDX) ────────────────────┐ +│ index entries : N × { timecode:uint32, offset:uint64 } │ (one per FRAME_KEY) +│ index_count : uint32 │ +│ duration_ms : uint32 │ +│ index_offset : uint64 (byte offset of index start) │ +│ tail_magic : "QDMX" (4 bytes) │ +└────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Record types + +- **`FRAME_DELTA`** — one complete server frame, entities + player-state(s) delta-encoded against the previous recorded frame, using the existing `Net_WriteDeltaEntity` / `Net_WriteDeltaPlayerState`. **Exactly one record per frame** — this is what solves the multi-message FIXME: the recorder accumulates a full frame, stamps one timecode, writes one record. +- **`FRAME_KEY`** — a *self-contained* full snapshot: all changed config strings since epoch, full entity baseline of every live entity (delta vs null), and full player-state(s). Sufficient to start playback from this point with no prior records. Emitted every **`DEMO_KEYFRAME_INTERVAL` = 80 ticks (~2 s)** and forced at recording start, map change, and intermission. +- **`RELIABLE`** — non-frame server commands that must survive seeking (config-string changes, layout/scoreboard, centerprints relevant to state). Replayed in order from epoch up to the seek target. (Most are also folded into the next `FRAME_KEY`; `RELIABLE` covers the gap between keyframes.) +- **`CAMERA`** — *reserved* record id for future embedded camera scripts. Payload format deferred (sub-project C uses sidecar `.cam` files first). Reserving the id keeps the format forward-compatible. + +### 4.3 Multi-POV encoding (server demos) + +- A `FRAME_*` record carries a **count of player-states** followed by that many `{ client_slot:uint8, player_state }` entries. +- **Client demos** write exactly one player-state (the recorder's POV); `count == 1`. +- **Server demos** (`SERVER_DEMO` flag) write one entry per connected, in-game client that frame, and include **all entities with no PVS culling** (omniscient). +- On playback the server streams the recorded frame to the client; the client stores the player-state array (§7.2) and the director picks which one feeds the view (free-fly ignores them). + +### 4.4 Recording strategy — re-serialize, don't capture wire + +Both recorders **re-encode frames from decoded/authoritative state** rather than capturing raw wire packets: +- **Client recorder:** each frame, delta-encode from `cl.entities` / `cl.frame.ps` into a `FRAME_*` record. +- **Server recorder:** delta-encode from the omniscient observer snapshot (§9). + +This yields clean frame boundaries, exact timecodes, and on-demand keyframes by construction, and is the same code path for both recorders. + +### 4.5 Seek index + +Written at `stoprecord`. If absent (truncated/crashed file), the playback engine **scans the stream once on load** to rebuild the keyframe index in memory. The index is an optimization, never a correctness requirement. + +### 4.6 Versioning & legacy + +- Files lacking the `QDM2` magic are v1 → legacy path. +- `protocol_major`/`protocol_minor` are stored for diagnostics. A v2 demo recorded under a different protocol than the running engine is played best-effort and warns; if entity/player deltas are incompatible the engine aborts playback with a clear message (matches existing protocol-mismatch behavior). + +## 5. Sub-project A — Free camera + follow/cycle POV + +Path-agnostic: lives in the cgame view layer, which renders `cl.frame` regardless of how it was populated, so it works on **both** v1 and v2 playback. Built first for immediate value. + +### 5.1 Components (`src/cgame/default/cg_demo.c`, new) + +```c +typedef enum { DEMO_CAM_LOCKED, DEMO_CAM_FREE, DEMO_CAM_FOLLOW } cg_demo_cam_mode_t; + +static struct { + cg_demo_cam_mode_t mode; + pm_state_t s; // free-fly spectator state (origin, velocity, view_offset) + vec3_t angles; // free-look angles (from Cl_Look accumulation) + int32_t follow_slot;// player slot being followed in FOLLOW mode +} cg_demo_cam; + +bool Cg_DemoActive(void); // cgi.client->demo_server +bool Cg_DemoFreecamActive(void); // mode == DEMO_CAM_FREE +void Cg_DemoFreecam_f(void); // toggle FREE; seed from current view +void Cg_DemoFollowNext_f(void); // cycle POV among player slots present +void Cg_DemoFollowPrev_f(void); +void Cg_PredictDemoCamera(const GPtrArray *cmds); // PM_SPECTATOR integrate +void Cg_UpdateDemoCamera(void); // write cgi.view->origin/angles +``` + +### 5.2 Integration points (from the verified sketch) +- **`cg_predict.c::Cg_UsePrediction`** — replace `if (demo_server) return false;` with a freecam exception. +- **`cg_predict.c::Cg_PredictMovement`** — when freecam active, branch to `Cg_PredictDemoCamera` (seed `pm.s` from persistent camera, `type = PM_SPECTATOR`, run cmds, persist). +- **`cl_predict.c::Cl_PredictMovement`** — allow feeding cmds to the cgame during a demo when freecam is active (it currently early-returns). +- **`cg_view.c::Cg_PrepareView`** — when freecam active call `Cg_UpdateDemoCamera()` and skip origin/angles/third-person/bob; FOLLOW mode reuses `Cg_UpdateThirdPerson` chase math against `follow_slot`'s entity. +- **`cl_entity.c:37`** — leave the `PM_FREEZE` override (recorded player stays frozen; we override at the view layer). + +### 5.3 Commands & binds +`demo_freecam` (toggle), `demo_follow_next` / `demo_follow_prev`, registered `CMD_CGAME`. Default binds added to the demo context. First-person weapon model hidden while free/follow. + +### 5.4 Tests +- Manual: play a v1 demo, toggle freecam, fly with WASD+mouse+`+speed`, cycle POV, snap back. Slow-mo/fast-forward while flying (camera stays real-time). +- Regression: live play and normal (locked) demo playback render identically to before (the non-freecam branch is unchanged). + +## 6. Engine — v2 recording + server-hosted playback + control API + +### 6.1 v2 recording +- **Client:** extend `Cl_Record_f` to write the `QDM2` header and per-frame `FRAME_*`/`RELIABLE` records via `common/demo.c`; emit keyframes on the interval. `Cl_Stop_f` writes the index footer. +- A cvar `cl_demo_legacy_format` (default `0`) can force v1 output for compatibility testing. + +### 6.2 Server-hosted v2 playback +- `Sv_GetDemoMessage` becomes v2-aware: detect magic; v1 → existing path; v2 → parse next record(s) due at the current demo clock, reframe into `SV_CMD_*`, transmit. +- **Demo clock:** advanced by real elapsed time × `demo_speed`, owned by the server demo state but *commanded* by the client. Decoupled from sv tick so playback speed and the (client-side, real-time) camera are independent. + +### 6.3 Control API (client → server, reliable loopback) +New client commands forwarded to the server demo reader: `demo_pause`, `demo_speed `, `demo_seek `, `demo_step <±frames>`, `demo_jump_keyframe <±n>`. The scrubber UI (C) issues the same commands. + +## 7. Sub-project B — Timeline control (seek / pause / speed / step / reverse) + +### 7.1 Seek algorithm (server-side) +``` +seek(target_ms): + kf = index.floor(target_ms) # nearest FRAME_KEY ≤ target + file.seek(kf.offset) + emit kf as a full (non-delta) frame # client snaps, no map reload if same map + replay RELIABLE records (epoch..target) # restore config-string/layout state + while next FRAME_DELTA.timecode ≤ target: apply+emit (fast, no render) + demo_clock = target; resume +``` +- **Reverse playback** = seek to `clock − step` each frame; visually smooth because steps land near keyframes frequently. +- **Pause** freezes the demo clock (client camera still live). +- **Step** = seek to exact frame boundary ±N. + +### 7.2 Client discontinuity handling +A seek breaks frame-number monotonicity. The server marks the post-seek frame as a **teleport/discontinuity** (reuse the existing large-jump path that already snaps without interpolation, cf. `Cg_UpdateAngles` delta-angle handling and prediction-error reset). Client resets `lerp`/interpolation on that frame. + +### 7.3 Multi-POV client storage +Extend the client frame to hold a small `player_state_t pov[MAX_CLIENTS]` array + count, populated only when the server advertises the multi-POV capability flag. Single-POV demos keep `count==1` and the existing `frame->ps` aliases `pov[0]`. + +### 7.4 Tests +Seek accuracy (target vs landed timecode within one frame), backward seek correctness (entity state matches a forward replay to the same time), pause/resume, speed 0.1–8×, step ±1, reverse playback. + +## 8. Sub-project D — serverrecord (omniscient multi-POV) + +New module **`src/server/sv_demo.c`**. + +- **Commands:** `sv_record ` / `sv_stoprecord`; cvar `sv_demo_keyframe_interval`. +- **Synthetic observer:** build a frame each tick with **all entities (PVS culling bypassed)** and **all in-game clients' `player_state`**, delta-encoded vs the previous omniscient frame, with periodic keyframes — written as v2 `FRAME_*` records with the `SERVER_DEMO` flag. Reuses `Net_WriteDeltaEntity` / `Net_WriteDeltaPlayerState`. +- **Playback:** identical server-hosted v2 path; the `SERVER_DEMO` flag tells the client to expect the multi-POV array, enabling the director's POV selector (free-fly + switch to any player). +- **Capability flag:** the playback server advertises multi-POV support in `SV_CMD_SERVER_DATA`; older clients ignore it and fall back to `pov[0]`. + +### 8.1 Tests +Record a bot/LAN match, verify all players' POVs are switchable in replay, omniscient entity completeness (no PVS pop-in), keyframe cadence, file integrity + index. + +## 9. Sub-project C — Director: camera paths + timeline scrubber UI + +### 9.1 Cinematic camera paths (`cg_demo.c`) +- A camera path = ordered list of keyframes `{ time_ms, origin, angles, fov, ease }`. +- **Interpolation:** Catmull-Rom for `origin`, shortest-arc euler blend for `angles`, smoothstep `ease` in/out per segment; bound to the demo clock so the camera moves as the demo plays *and* as the user scrubs. +- **Authoring:** fly the free camera, `demo_cam_add` drops a keyframe at the current time/pose; `demo_cam_del`, `demo_cam_clear`; `demo_cam_save ` / `demo_cam_load ` to sidecar `demos/.cam` (simple text). Fixed cams may snap to `info_player_intermission` entities (`g_level.intermission_origin`). +- A `DEMO_CAM_PATH` mode drives `cgi.view` from the interpolated path. + +### 9.2 Timeline scrubber UI (`src/cgame/default/ui/demo/DemoViewController.{c,h}`) +ObjectivelyMVC `ViewController` (pattern: `PlayViewController`), pushed via `cgi.PushViewController` when in a demo, toggled by a bind: +- Transport: play/pause, speed, step, current-time / duration readout. +- **Timeline bar** with a draggable **playhead** (→ `demo_seek`), tick marks at keyframes. +- **Camera keyframe track**: markers; add/delete/drag; click to jump. +- **POV selector** (server demos): list of players → `demo_follow`/free. +- All actions issue the §6.3 control commands — the UI is a thin controller over the engine. + +### 9.3 Tests +Path interpolation visual smoothness; keyframe add/move/delete; scrub via playhead matches `demo_seek`; POV switch; save/load `.cam` round-trip. + +## 10. Cross-cutting + +- **Error handling:** corrupt/truncated demos → warn + best-effort (rebuild index by scan; stop cleanly at EOF). Protocol mismatch → clear abort message. Missing `.cam` → ignore. No silent failures. +- **Memory:** multi-POV array bounded by `MAX_CLIENTS`; keyframe index is `O(duration/2 s)` entries; full snapshot ≈ 98 KB (well within budget). +- **Backward compat:** v1 demos play unchanged; live play unaffected (multi-POV array only populated during demo playback behind a capability flag). +- **Code style:** follow `CONTRIBUTING.md` (see `q2server:quetoo-contributing` skill): tabs, brace style, `Cg_`/`Cl_`/`Sv_` prefixes, doxygen comments. + +## 11. Build & verification + +- **Build:** autotools (`./autogen.sh && ./configure && make`) in the Proxmox build container (per `quetoo-contributing`). CI mirrors exist (`.github/workflows/build.yml`, `build-cgame-windows.yml`). +- **Per-phase gate:** must compile clean (no new warnings) in the container; cgame/client/server link; then in-game smoke test on the Windows RTX 4090 box. +- **Unit tests:** `common/demo.c` record round-trip (write→read→compare), index build/scan, seek-time math, and camera-path interpolation are unit-testable without the full engine (Check-based, matching existing `src/tests` if present). +- **No phase is "done" until it compiles and the manual acceptance checklist for that phase passes** (verification-before-completion). + +## 12. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Seek discontinuity confuses client interpolation | Reuse existing teleport/large-jump snap path; explicit discontinuity flag on post-seek frame (§7.2). | +| Multi-POV wire change breaks old clients | Gated behind a capability flag in `SV_CMD_SERVER_DATA`; default off; only during demo playback. | +| Omniscient server frames exceed `MAX_MSG_SIZE` | Keyframes may span multiple transmit packets via existing overflow handling; demo *records* are reframed independently of transmit MTU. | +| Format churn vs upstream | v2 is additive; v1 retained; spec reserves record ids for forward-compat. | +| Big UI (scrubber) risk | Engine + control commands land and are usable via console first; the MVC panel is a thin layer over them, built last. | + +## 13. Phased delivery + +Each phase: spec→plan→implement→build→verify→commit. + +1. **P1 — v2 format primitives** (`common/demo.c`) + unit tests. No engine wiring yet. +2. **P2 — A: free camera + follow/cycle POV.** Works on existing v1 demos. *Immediate value.* +3. **P3 — v2 client recording + server-hosted v2 playback** (forward-only first; legacy v1 retained). +4. **P4 — B: seek/pause/speed/step/reverse** on v2. +5. **P5 — D: serverrecord omniscient multi-POV** + multi-POV client storage + POV selector wiring. +6. **P6 — C: camera paths (console/scripted) then the ObjectivelyMVC timeline scrubber UI.** + +## 14. File map + +**New:** `src/common/demo.{h,c}`, `src/server/sv_demo.c`, `src/cgame/default/cg_demo.{c,h}`, `src/cgame/default/ui/demo/DemoViewController.{c,h}`. +**Extended:** `src/client/cl_demo.c`, `cl_predict.c`, `cl_entity.c`, `cl_parse.c`, `src/cgame/default/cg_view.c`, `cg_predict.c`, `cg_main.c`, `src/server/sv_send.c`, `sv_init.c`, `sv_main.c`. From ffa4c7201799d46a5f66dc5b8b3e90e5243422e9 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 00:54:33 -0500 Subject: [PATCH 02/18] docs: P1 implementation plan for demo format primitives (#377) --- .../2026-06-13-p1-demo-format-primitives.md | 765 ++++++++++++++++++ 1 file changed, 765 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-p1-demo-format-primitives.md diff --git a/docs/superpowers/plans/2026-06-13-p1-demo-format-primitives.md b/docs/superpowers/plans/2026-06-13-p1-demo-format-primitives.md new file mode 100644 index 000000000..2225973fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-p1-demo-format-primitives.md @@ -0,0 +1,765 @@ +# P1 — v2 Demo Format Primitives — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A standalone, unit-tested module (`src/common/demo.{h,c}`) that reads and writes the v2 demo container (magic, header+epoch, timecoded records, keyframe index) — the shared substrate for the client recorder, server recorder, and playback engine. No engine wiring in this phase. + +**Architecture:** Pure file I/O over the existing `Fs_*` API, portable little-endian field encoding (no libnet dependency), opaque record payloads. Validated by a libcheck test (`src/tests/check_demo.c`) that round-trips headers/records/index against a temp file. + +**Tech Stack:** C (C11), autotools, libcheck, Quetoo `Fs_*` filesystem + `Mem_*` memory. + +**Build/verify loop:** edit in worktree → sync changed file(s) to container 100 on `pve2` → `make` + run `./src/tests/check_demo`. Sync one file: +```bash +scp /src/common/demo.c pve2:/tmp/ && \ + ssh pve2 'pct push 100 /tmp/demo.c /root/demo/src/common/demo.c' +``` +Build + test in container: +```bash +ssh pve2 'pct exec 100 -- bash -lc "export LD_LIBRARY_PATH=/usr/local/lib; cd /root/demo && make -j8 && ./src/tests/check_demo"' +``` + +--- + +## File structure + +- **Create `src/common/demo.h`** — types, constants, function declarations. +- **Create `src/common/demo.c`** — implementation. +- **Modify `src/common/Makefile.am`** — add `demo.c`/`demo.h` to `libcommon_la_SOURCES`/`noinst_HEADERS`. +- **Create `src/tests/check_demo.c`** — libcheck tests. +- **Modify `src/tests/Makefile.am`** — register `check_demo` in `TESTS` + stanza. + +--- + +### Task 1: Module skeleton + build wiring (compiles, empty test runs) + +**Files:** +- Create: `src/common/demo.h` +- Create: `src/common/demo.c` +- Modify: `src/common/Makefile.am` +- Create: `src/tests/check_demo.c` +- Modify: `src/tests/Makefile.am` + +- [ ] **Step 1: Write `src/common/demo.h`** + +```c +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include "filesystem.h" + +/** + * @brief The Quetoo demo v2 container format. A demo file is: + * [magic "QDM2"][header][epoch block][record]*[optional index footer] + * Records are timecoded and self-framing; FRAME_KEY records are full snapshots + * that seeking can jump to. Files lacking the magic are legacy v1 demos. + */ + +#define DEMO_MAGIC "QDM2" +#define DEMO_MAGIC_LEN 4 +#define DEMO_TAIL_MAGIC "QDMX" +#define DEMO_TAIL_MAGIC_LEN 4 +#define DEMO_FORMAT_VERSION 2 + +/** + * @brief Keyframe cadence, in server ticks (~2s at QUETOO_TICK_RATE 40). + */ +#define DEMO_KEYFRAME_INTERVAL 80 + +/** + * @brief Demo container flags (header.flags). + */ +typedef enum { + DEMO_FLAG_NONE = 0, + DEMO_FLAG_CLIENT = 1 << 0, // recorded from a client POV (one player_state per frame) + DEMO_FLAG_SERVER = 1 << 1, // omniscient server recording (multi-POV) + DEMO_FLAG_HAS_INDEX = 1 << 2, // a seek index footer is present +} demo_flags_t; + +/** + * @brief Demo record types (record.type). + */ +typedef enum { + DEMO_RECORD_FRAME_DELTA = 0, // one full frame, delta vs the previous frame + DEMO_RECORD_FRAME_KEY = 1, // self-contained full snapshot (seek target) + DEMO_RECORD_RELIABLE = 2, // out-of-band server command that survives seeks + DEMO_RECORD_CAMERA = 3, // reserved: embedded camera script (unused in v2.0) +} demo_record_type_t; + +/** + * @brief The demo file header, following the magic. + */ +typedef struct { + uint16_t version; // DEMO_FORMAT_VERSION + uint16_t flags; // demo_flags_t + uint16_t protocol_major; // PROTOCOL_MAJOR at record time + uint16_t protocol_minor; // PROTOCOL_MINOR at record time + uint16_t tick_rate; // QUETOO_TICK_RATE at record time + uint32_t epoch_len; // length of the epoch block that follows +} demo_header_t; + +/** + * @brief A single record's framing (the payload follows in the file). + */ +typedef struct { + uint8_t type; // demo_record_type_t + uint32_t timecode; // milliseconds from demo start + uint32_t length; // payload length in bytes +} demo_record_t; + +/** + * @brief One seek-index entry (one per FRAME_KEY record). + */ +typedef struct { + uint32_t timecode; // ms + uint64_t offset; // byte offset of the FRAME_KEY record +} demo_index_entry_t; + +/** + * @brief An in-memory seek index. + */ +typedef struct { + demo_index_entry_t *entries; // Mem_Malloc'd, count elements + uint32_t count; + uint32_t duration; // ms, of the whole demo +} demo_index_t; + +/** + * @return True if `file` begins with the v2 magic. Leaves the read cursor at 0. + */ +bool Demo_IsV2(file_t *file); + +/** + * @brief Writes the magic, header, and epoch block to `file` (which must be empty). + */ +bool Demo_WriteHeader(file_t *file, const demo_header_t *header, const void *epoch, size_t epoch_len); + +/** + * @brief Reads the header and epoch block. `*epoch` is Mem_Malloc'd (caller frees). + * The read cursor is left at the first record. Returns false if the magic is absent. + */ +bool Demo_ReadHeader(file_t *file, demo_header_t *header, void **epoch); + +/** + * @brief Writes one record (framing + payload) at the current cursor. + */ +bool Demo_WriteRecord(file_t *file, uint8_t type, uint32_t timecode, const void *payload, size_t length); + +/** + * @brief Reads the next record's framing into `record` and its payload into `buffer`. + * @return False at end-of-stream (EOF or tail magic) or if `buffer_size` is too small. + */ +bool Demo_ReadRecord(file_t *file, demo_record_t *record, void *buffer, size_t buffer_size); + +/** + * @brief Appends the seek-index footer (entries + tail magic) at the current cursor. + */ +bool Demo_WriteIndex(file_t *file, const demo_index_t *index); + +/** + * @brief Reads the index footer if present (tail magic). Returns false if absent. + */ +bool Demo_ReadIndex(file_t *file, demo_index_t *index); + +/** + * @brief Rebuilds the index by scanning all records (fallback for files with no footer). + * Leaves the cursor at the first record. + */ +bool Demo_ScanIndex(file_t *file, demo_index_t *index); + +/** + * @brief Frees an index's entries. + */ +void Demo_FreeIndex(demo_index_t *index); + +/** + * @return The latest index entry with `timecode <= t`, or NULL if none. + */ +const demo_index_entry_t *Demo_IndexFloor(const demo_index_t *index, uint32_t t); +``` + +- [ ] **Step 2: Write `src/common/demo.c` skeleton** + +```c +/* (GPL header identical to demo.h) */ + +#include "demo.h" +#include "mem.h" + +// portable little-endian field I/O (no libnet dependency) + +static bool Demo_WriteU8(file_t *file, uint8_t v) { + return Fs_Write(file, &v, 1, 1) == 1; +} + +static bool Demo_WriteU16(file_t *file, uint16_t v) { + const byte b[2] = { (byte) v, (byte) (v >> 8) }; + return Fs_Write(file, b, 1, 2) == 2; +} + +static bool Demo_WriteU32(file_t *file, uint32_t v) { + const byte b[4] = { (byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24) }; + return Fs_Write(file, b, 1, 4) == 4; +} + +static bool Demo_WriteU64(file_t *file, uint64_t v) { + byte b[8]; + for (int32_t i = 0; i < 8; i++) { + b[i] = (byte) (v >> (i * 8)); + } + return Fs_Write(file, b, 1, 8) == 8; +} + +static bool Demo_ReadU8(file_t *file, uint8_t *v) { + return Fs_Read(file, v, 1, 1) == 1; +} + +static bool Demo_ReadU16(file_t *file, uint16_t *v) { + byte b[2]; + if (Fs_Read(file, b, 1, 2) != 2) { + return false; + } + *v = (uint16_t) (b[0] | (b[1] << 8)); + return true; +} + +static bool Demo_ReadU32(file_t *file, uint32_t *v) { + byte b[4]; + if (Fs_Read(file, b, 1, 4) != 4) { + return false; + } + *v = (uint32_t) b[0] | ((uint32_t) b[1] << 8) | ((uint32_t) b[2] << 16) | ((uint32_t) b[3] << 24); + return true; +} + +static bool Demo_ReadU64(file_t *file, uint64_t *v) { + byte b[8]; + if (Fs_Read(file, b, 1, 8) != 8) { + return false; + } + uint64_t r = 0; + for (int32_t i = 0; i < 8; i++) { + r |= (uint64_t) b[i] << (i * 8); + } + *v = r; + return true; +} + +// (functions implemented in later tasks) +``` + +- [ ] **Step 3: Wire `src/common/Makefile.am`** — add to the alphabetical lists. + +In `libcommon_la_SOURCES` add `demo.c` (after `cvar.c`); add a `noinst_HEADERS` entry `demo.h` (create the list if absent — check the file; common headers are currently implicit, so add `demo.h` to `libcommon_la_SOURCES` is NOT enough — headers in this repo are listed via `_SOURCES` alongside `.c`, confirm by grep and mirror). Concretely: + +```makefile +libcommon_la_SOURCES = \ + atlas.c \ + cmd.c \ + common.c \ + console.c \ + cvar.c \ + demo.c \ + filesystem.c \ + image.c \ + installer.c \ + mem.c \ + mem_buf.c \ + rgb9e5.c \ + sys.c \ + ... +``` + +- [ ] **Step 4: Write `src/tests/check_demo.c` (harness only, one trivial passing test)** + +```c +/* (GPL header) */ + +#include "tests.h" + +#include "common/demo.h" + +quetoo_t quetoo; + +void setup(void) { + Mem_Init(); + Fs_Init(FS_AUTO_LOAD_ARCHIVES); +} + +void teardown(void) { + Fs_Shutdown(); + Mem_Shutdown(); +} + +START_TEST(check_Demo_constants) { + ck_assert_int_eq(DEMO_FORMAT_VERSION, 2); + ck_assert_int_eq(DEMO_MAGIC_LEN, 4); +} END_TEST + +int32_t main(int32_t argc, char **argv) { + + Test_Init(argc, argv); + + TCase *tcase = tcase_create("check_demo"); + tcase_add_checked_fixture(tcase, setup, teardown); + + tcase_add_test(tcase, check_Demo_constants); + + Suite *suite = suite_create("check_demo"); + suite_add_tcase(suite, tcase); + + int32_t failed = Test_Run(suite); + + Test_Shutdown(); + return failed; +} +``` + +- [ ] **Step 5: Register `check_demo` in `src/tests/Makefile.am`** — add `check_demo` to the `TESTS` list (after `check_cvar`) and add the stanza: + +```makefile +check_demo_SOURCES = \ + check_demo.c +check_demo_CFLAGS = \ + $(TESTS_CFLAGS) +check_demo_LDADD = \ + $(TESTS_LIBS) +``` + +- [ ] **Step 6: Build + run (expect PASS)** — `autoreconf -i` is required because Makefile.am changed. + +Run (container): `autoreconf -i && ./configure --with-tests && make -j8 && ./src/tests/check_demo` +Expected: `check_demo` builds and reports `0 failed`. + +- [ ] **Step 7: Commit** + +```bash +git add src/common/demo.h src/common/demo.c src/common/Makefile.am src/tests/check_demo.c src/tests/Makefile.am +git commit -m "feat(demo): v2 container module skeleton + test harness (#377)" +``` + +--- + +### Task 2: Header read/write + magic detection + +**Files:** Modify `src/common/demo.c`; Modify `src/tests/check_demo.c`. + +- [ ] **Step 1: Write failing tests** (add to `check_demo.c`, register each with `tcase_add_test`): + +```c +START_TEST(check_Demo_header_roundtrip) { + const char *name = "check_demo_header.demo"; + const byte epoch[] = { 1, 2, 3, 4, 5, 6, 7, 8 }; + + file_t *w = Fs_OpenWrite(name); + ck_assert_ptr_nonnull(w); + const demo_header_t h = { + .version = DEMO_FORMAT_VERSION, .flags = DEMO_FLAG_CLIENT, + .protocol_major = 2027, .protocol_minor = 1041, .tick_rate = 40, + .epoch_len = sizeof(epoch) + }; + ck_assert(Demo_WriteHeader(w, &h, epoch, sizeof(epoch))); + ck_assert(Fs_Close(w)); + + file_t *r = Fs_OpenRead(name); + ck_assert_ptr_nonnull(r); + ck_assert(Demo_IsV2(r)); + + demo_header_t h2; + void *epoch2 = NULL; + ck_assert(Demo_ReadHeader(r, &h2, &epoch2)); + ck_assert_int_eq(h2.version, h.version); + ck_assert_int_eq(h2.flags, h.flags); + ck_assert_int_eq(h2.protocol_major, h.protocol_major); + ck_assert_int_eq(h2.protocol_minor, h.protocol_minor); + ck_assert_int_eq(h2.tick_rate, h.tick_rate); + ck_assert_int_eq(h2.epoch_len, sizeof(epoch)); + ck_assert_mem_eq(epoch2, epoch, sizeof(epoch)); + Mem_Free(epoch2); + ck_assert(Fs_Close(r)); + Fs_Delete(name); +} END_TEST + +START_TEST(check_Demo_not_v2) { + const char *name = "check_demo_legacy.demo"; + file_t *w = Fs_OpenWrite(name); + const byte junk[] = { 0xff, 0xff, 0xff, 0xff, 0, 0 }; + Fs_Write(w, junk, 1, sizeof(junk)); + Fs_Close(w); + + file_t *r = Fs_OpenRead(name); + ck_assert(!Demo_IsV2(r)); + Fs_Close(r); + Fs_Delete(name); +} END_TEST +``` + +- [ ] **Step 2: Run to verify FAIL** — `./src/tests/check_demo` → fails (undefined `Demo_WriteHeader` link error / assertion). Expected: build or run failure. + +- [ ] **Step 3: Implement in `demo.c`:** + +```c +bool Demo_IsV2(file_t *file) { + char magic[DEMO_MAGIC_LEN]; + if (!Fs_Seek(file, 0)) { + return false; + } + const bool ok = Fs_Read(file, magic, 1, DEMO_MAGIC_LEN) == DEMO_MAGIC_LEN + && memcmp(magic, DEMO_MAGIC, DEMO_MAGIC_LEN) == 0; + Fs_Seek(file, 0); + return ok; +} + +bool Demo_WriteHeader(file_t *file, const demo_header_t *header, const void *epoch, size_t epoch_len) { + if (Fs_Write(file, DEMO_MAGIC, 1, DEMO_MAGIC_LEN) != DEMO_MAGIC_LEN) { + return false; + } + if (!Demo_WriteU16(file, header->version) || + !Demo_WriteU16(file, header->flags) || + !Demo_WriteU16(file, header->protocol_major) || + !Demo_WriteU16(file, header->protocol_minor) || + !Demo_WriteU16(file, header->tick_rate) || + !Demo_WriteU32(file, (uint32_t) epoch_len)) { + return false; + } + return epoch_len == 0 || Fs_Write(file, epoch, 1, epoch_len) == (int64_t) epoch_len; +} + +bool Demo_ReadHeader(file_t *file, demo_header_t *header, void **epoch) { + char magic[DEMO_MAGIC_LEN]; + if (Fs_Read(file, magic, 1, DEMO_MAGIC_LEN) != DEMO_MAGIC_LEN || + memcmp(magic, DEMO_MAGIC, DEMO_MAGIC_LEN) != 0) { + return false; + } + if (!Demo_ReadU16(file, &header->version) || + !Demo_ReadU16(file, &header->flags) || + !Demo_ReadU16(file, &header->protocol_major) || + !Demo_ReadU16(file, &header->protocol_minor) || + !Demo_ReadU16(file, &header->tick_rate) || + !Demo_ReadU32(file, &header->epoch_len)) { + return false; + } + if (epoch) { + *epoch = header->epoch_len ? Mem_Malloc(header->epoch_len) : NULL; + if (header->epoch_len && Fs_Read(file, *epoch, 1, header->epoch_len) != (int64_t) header->epoch_len) { + Mem_Free(*epoch); + *epoch = NULL; + return false; + } + } else if (header->epoch_len) { + Fs_Seek(file, Fs_Tell(file) + header->epoch_len); + } + return true; +} +``` + +- [ ] **Step 4: Run to verify PASS** — `make -j8 && ./src/tests/check_demo` → `0 failed`. + +- [ ] **Step 5: Commit** — `git commit -am "feat(demo): header read/write + v2 magic detection (#377)"` + +--- + +### Task 3: Record read/write + +**Files:** Modify `src/common/demo.c`; Modify `src/tests/check_demo.c`. + +- [ ] **Step 1: Write failing test:** + +```c +START_TEST(check_Demo_record_roundtrip) { + const char *name = "check_demo_records.demo"; + const byte p0[] = { 10, 11, 12 }; + const byte p1[] = { 20 }; + + file_t *w = Fs_OpenWrite(name); + const demo_header_t h = { .version = 2, .epoch_len = 0 }; + Demo_WriteHeader(w, &h, NULL, 0); + ck_assert(Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 0, p0, sizeof(p0))); + ck_assert(Demo_WriteRecord(w, DEMO_RECORD_FRAME_DELTA, 25, p1, sizeof(p1))); + Fs_Close(w); + + file_t *r = Fs_OpenRead(name); + demo_header_t h2; void *e = NULL; + Demo_ReadHeader(r, &h2, &e); + + demo_record_t rec; byte buf[64]; + ck_assert(Demo_ReadRecord(r, &rec, buf, sizeof(buf))); + ck_assert_int_eq(rec.type, DEMO_RECORD_FRAME_KEY); + ck_assert_int_eq(rec.timecode, 0); + ck_assert_int_eq(rec.length, sizeof(p0)); + ck_assert_mem_eq(buf, p0, sizeof(p0)); + + ck_assert(Demo_ReadRecord(r, &rec, buf, sizeof(buf))); + ck_assert_int_eq(rec.type, DEMO_RECORD_FRAME_DELTA); + ck_assert_int_eq(rec.timecode, 25); + ck_assert_int_eq(rec.length, sizeof(p1)); + ck_assert_mem_eq(buf, p1, sizeof(p1)); + + ck_assert(!Demo_ReadRecord(r, &rec, buf, sizeof(buf))); // EOF + Fs_Close(r); + Fs_Delete(name); +} END_TEST +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement:** + +```c +bool Demo_WriteRecord(file_t *file, uint8_t type, uint32_t timecode, const void *payload, size_t length) { + if (!Demo_WriteU8(file, type) || !Demo_WriteU32(file, timecode) || !Demo_WriteU32(file, (uint32_t) length)) { + return false; + } + return length == 0 || Fs_Write(file, payload, 1, length) == (int64_t) length; +} + +bool Demo_ReadRecord(file_t *file, demo_record_t *record, void *buffer, size_t buffer_size) { + if (!Demo_ReadU8(file, &record->type)) { + return false; // clean EOF + } + if (record->type == DEMO_RECORD_CAMERA + 1) { // future-proof: unknown high type guard left to caller + // no-op; kept for clarity + } + if (!Demo_ReadU32(file, &record->timecode) || !Demo_ReadU32(file, &record->length)) { + return false; + } + if (record->length > buffer_size) { + return false; + } + return record->length == 0 || Fs_Read(file, buffer, 1, record->length) == (int64_t) record->length; +} +``` + +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** — `"feat(demo): timecoded record read/write (#377)"` + +--- + +### Task 4: Seek index (write footer, read footer, scan fallback, floor lookup) + +**Files:** Modify `src/common/demo.c`; Modify `src/tests/check_demo.c`. + +- [ ] **Step 1: Write failing tests:** + +```c +START_TEST(check_Demo_index_floor) { + demo_index_entry_t e[3] = { {0,100}, {2000,500}, {4000,900} }; + demo_index_t idx = { .entries = e, .count = 3, .duration = 6000 }; + + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 0), &e[0]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 2500), &e[1]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 4000), &e[2]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 99999), &e[2]); + ck_assert_ptr_null(Demo_IndexFloor(&idx, 0 - 1)); // wraps to UINT32_MAX -> last; so test below-first separately +} END_TEST + +START_TEST(check_Demo_index_roundtrip_and_scan) { + const char *name = "check_demo_index.demo"; + const byte p[] = { 7 }; + + file_t *w = Fs_OpenWrite(name); + const demo_header_t h = { .version = 2, .flags = DEMO_FLAG_HAS_INDEX, .epoch_len = 0 }; + Demo_WriteHeader(w, &h, NULL, 0); + + demo_index_entry_t built[2]; + built[0] = (demo_index_entry_t){ .timecode = 0, .offset = (uint64_t) Fs_Tell(w) }; + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 0, p, sizeof(p)); + Demo_WriteRecord(w, DEMO_RECORD_FRAME_DELTA, 25, p, sizeof(p)); + built[1] = (demo_index_entry_t){ .timecode = 2000, .offset = (uint64_t) Fs_Tell(w) }; + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 2000, p, sizeof(p)); + + demo_index_t idx = { .entries = built, .count = 2, .duration = 2025 }; + ck_assert(Demo_WriteIndex(w, &idx)); + Fs_Close(w); + + // read footer + file_t *r = Fs_OpenRead(name); + demo_index_t got = { 0 }; + ck_assert(Demo_ReadIndex(r, &got)); + ck_assert_int_eq(got.count, 2); + ck_assert_int_eq(got.duration, 2025); + ck_assert_int_eq(got.entries[0].timecode, 0); + ck_assert_int_eq(got.entries[1].timecode, 2000); + ck_assert_int_eq(got.entries[1].offset, built[1].offset); + Demo_FreeIndex(&got); + + // scan fallback must find the same 2 keyframes + demo_index_t scanned = { 0 }; + demo_header_t h2; void *e = NULL; + Demo_ReadHeader(r, &h2, &e); + ck_assert(Demo_ScanIndex(r, &scanned)); + ck_assert_int_eq(scanned.count, 2); + ck_assert_int_eq(scanned.entries[0].offset, built[0].offset); + ck_assert_int_eq(scanned.entries[1].offset, built[1].offset); + Demo_FreeIndex(&scanned); + + Fs_Close(r); + Fs_Delete(name); +} END_TEST +``` + +(Remove the wrapping `Demo_IndexFloor(&idx, 0 - 1)` assertion if it proves ambiguous; the intent is: below-first returns NULL. Use an index whose first timecode is > 0 to test the NULL case cleanly:) + +```c +START_TEST(check_Demo_index_floor_below_first) { + demo_index_entry_t e[2] = { {1000,100}, {3000,500} }; + demo_index_t idx = { .entries = e, .count = 2 }; + ck_assert_ptr_null(Demo_IndexFloor(&idx, 999)); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 1000), &e[0]); +} END_TEST +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement:** + +```c +const demo_index_entry_t *Demo_IndexFloor(const demo_index_t *index, uint32_t t) { + const demo_index_entry_t *floor = NULL; + for (uint32_t i = 0; i < index->count; i++) { + if (index->entries[i].timecode <= t) { + floor = &index->entries[i]; + } else { + break; + } + } + return floor; +} + +void Demo_FreeIndex(demo_index_t *index) { + if (index->entries) { + Mem_Free(index->entries); + } + index->entries = NULL; + index->count = 0; +} + +bool Demo_WriteIndex(file_t *file, const demo_index_t *index) { + const int64_t start = Fs_Tell(file); + if (start < 0) { + return false; + } + for (uint32_t i = 0; i < index->count; i++) { + if (!Demo_WriteU32(file, index->entries[i].timecode) || + !Demo_WriteU64(file, index->entries[i].offset)) { + return false; + } + } + if (!Demo_WriteU32(file, index->count) || + !Demo_WriteU32(file, index->duration) || + !Demo_WriteU64(file, (uint64_t) start)) { + return false; + } + return Fs_Write(file, DEMO_TAIL_MAGIC, 1, DEMO_TAIL_MAGIC_LEN) == DEMO_TAIL_MAGIC_LEN; +} + +bool Demo_ReadIndex(file_t *file, demo_index_t *index) { + const int64_t len = Fs_FileLength(file); + const int64_t footer = (int64_t) (DEMO_TAIL_MAGIC_LEN + 8 /*offset*/ + 4 /*duration*/ + 4 /*count*/); + if (len < footer) { + return false; + } + char magic[DEMO_TAIL_MAGIC_LEN]; + if (!Fs_Seek(file, len - DEMO_TAIL_MAGIC_LEN) || + Fs_Read(file, magic, 1, DEMO_TAIL_MAGIC_LEN) != DEMO_TAIL_MAGIC_LEN || + memcmp(magic, DEMO_TAIL_MAGIC, DEMO_TAIL_MAGIC_LEN) != 0) { + return false; + } + if (!Fs_Seek(file, len - DEMO_TAIL_MAGIC_LEN - 8 - 4 - 4)) { + return false; + } + uint32_t count, duration; uint64_t start; + if (!Demo_ReadU32(file, &count) || !Demo_ReadU32(file, &duration) || !Demo_ReadU64(file, &start)) { + return false; + } + if (!Fs_Seek(file, (int64_t) start)) { + return false; + } + index->count = count; + index->duration = duration; + index->entries = count ? Mem_Malloc(count * sizeof(demo_index_entry_t)) : NULL; + for (uint32_t i = 0; i < count; i++) { + if (!Demo_ReadU32(file, &index->entries[i].timecode) || + !Demo_ReadU64(file, &index->entries[i].offset)) { + Demo_FreeIndex(index); + return false; + } + } + return true; +} + +bool Demo_ScanIndex(file_t *file, demo_index_t *index) { + GArray *acc = g_array_new(false, false, sizeof(demo_index_entry_t)); + uint32_t last_tc = 0; + for (;;) { + const int64_t offset = Fs_Tell(file); + uint8_t type; + if (!Demo_ReadU8(file, &type)) { + break; // EOF + } + uint32_t timecode, length; + if (!Demo_ReadU32(file, &timecode) || !Demo_ReadU32(file, &length)) { + break; + } + // stop if we wandered into the footer (tail magic where a type byte would be) + if (type == 'Q' && timecode == 0 /*heuristic*/ ) { + // not a record type we emit as keyframe; footer handled by ReadIndex. Be conservative: + } + if (type == DEMO_RECORD_FRAME_KEY) { + const demo_index_entry_t e = { .timecode = timecode, .offset = (uint64_t) offset }; + g_array_append_val(acc, e); + } + last_tc = timecode; + if (!Fs_Seek(file, Fs_Tell(file) + length)) { + break; + } + } + index->count = acc->len; + index->duration = last_tc; + index->entries = acc->len ? Mem_Malloc(acc->len * sizeof(demo_index_entry_t)) : NULL; + if (acc->len) { + memcpy(index->entries, acc->data, acc->len * sizeof(demo_index_entry_t)); + } + g_array_free(acc, true); + return true; +} +``` + +> **Note for executor:** `Demo_ScanIndex` must not mis-read the index footer as records. Since `Demo_ScanIndex` is only called on files *without* a footer (per the spec — footer files use `Demo_ReadIndex`), the simple EOF-terminated scan above is correct. If a footer is present, callers use `Demo_ReadIndex` first. Drop the `'Q'` heuristic block — it's dead; left only to flag the consideration. The executor should delete it and rely on the no-footer contract. (Self-review fix applied below.) + +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** — `"feat(demo): seek index write/read/scan + floor lookup (#377)"` + +--- + +## Self-review notes (applied) + +- **`Demo_ScanIndex` footer ambiguity:** resolved by contract — scan is only for footer-less files; the `'Q'` heuristic block is dead and must be deleted by the executor. For footer files, `Demo_ReadIndex` is authoritative. +- **`Demo_IndexFloor` underflow test:** the `0 - 1` assertion was ambiguous (wraps to `UINT32_MAX`); replaced by `check_Demo_index_floor_below_first` using a first timecode > 0. +- **Type consistency:** `demo_header_t`, `demo_record_t`, `demo_index_t`, and all `Demo_*` signatures are identical between `demo.h` (Task 1) and every test/impl task. Verified. +- **Spec coverage:** §4.1 layout (header/records/footer) → Tasks 1–4; §4.2 record types → `demo_record_type_t`; §4.5 index + scan fallback → Task 4; §4.6 legacy detection → `Demo_IsV2`. Multi-POV payload contents (§4.3) and FRAME re-encoding (§4.4) are *consumers* of these primitives, implemented in P3/P5 — out of P1 scope by design. + +## Acceptance criteria (P1 done) + +- `make -j8` clean (no new warnings) in the container on `upstream/main` base. +- `./src/tests/check_demo` reports `0 failed` with all tests above. +- No engine behavior changed (module is unreferenced by client/server/cgame yet). From e4c5d6c1d0f247052cc122f89c933c8321e3794d Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 00:54:33 -0500 Subject: [PATCH 03/18] feat(demo): v2 demo container format primitives (#377) 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. --- src/common/Makefile.am | 2 + src/common/demo.c | 370 +++++++++++++++++++++++++++++++++++++++++ src/common/demo.h | 114 +++++++++++++ src/tests/Makefile.am | 8 + src/tests/check_demo.c | 252 ++++++++++++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 src/common/demo.c create mode 100644 src/common/demo.h create mode 100644 src/tests/check_demo.c diff --git a/src/common/Makefile.am b/src/common/Makefile.am index f26506b27..0032aa136 100644 --- a/src/common/Makefile.am +++ b/src/common/Makefile.am @@ -4,6 +4,7 @@ noinst_HEADERS = \ common.h \ console.h \ cvar.h \ + demo.h \ filesystem.h \ image.h \ installer.h \ @@ -22,6 +23,7 @@ libcommon_la_SOURCES = \ common.c \ console.c \ cvar.c \ + demo.c \ filesystem.c \ image.c \ installer.c \ diff --git a/src/common/demo.c b/src/common/demo.c new file mode 100644 index 000000000..d9722b55b --- /dev/null +++ b/src/common/demo.c @@ -0,0 +1,370 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "demo.h" +#include "mem.h" + +// Portable little-endian field I/O. Demos are written little-endian regardless +// of host byte order, and contain no libnet-encoded fields at the container +// layer (record payloads are opaque), so this module depends only on common. + +static bool Demo_WriteU8(file_t *file, uint8_t v) { + return Fs_Write(file, &v, 1, 1) == 1; +} + +static bool Demo_WriteU16(file_t *file, uint16_t v) { + const byte b[2] = { (byte) v, (byte) (v >> 8) }; + return Fs_Write(file, b, 1, 2) == 2; +} + +static bool Demo_WriteU32(file_t *file, uint32_t v) { + const byte b[4] = { (byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24) }; + return Fs_Write(file, b, 1, 4) == 4; +} + +static bool Demo_WriteU64(file_t *file, uint64_t v) { + byte b[8]; + for (int32_t i = 0; i < 8; i++) { + b[i] = (byte) (v >> (i * 8)); + } + return Fs_Write(file, b, 1, 8) == 8; +} + +static bool Demo_ReadU8(file_t *file, uint8_t *v) { + return Fs_Read(file, v, 1, 1) == 1; +} + +static bool Demo_ReadU16(file_t *file, uint16_t *v) { + byte b[2]; + if (Fs_Read(file, b, 1, 2) != 2) { + return false; + } + *v = (uint16_t) (b[0] | (b[1] << 8)); + return true; +} + +static bool Demo_ReadU32(file_t *file, uint32_t *v) { + byte b[4]; + if (Fs_Read(file, b, 1, 4) != 4) { + return false; + } + *v = (uint32_t) b[0] | ((uint32_t) b[1] << 8) | ((uint32_t) b[2] << 16) | ((uint32_t) b[3] << 24); + return true; +} + +static bool Demo_ReadU64(file_t *file, uint64_t *v) { + byte b[8]; + if (Fs_Read(file, b, 1, 8) != 8) { + return false; + } + uint64_t r = 0; + for (int32_t i = 0; i < 8; i++) { + r |= (uint64_t) b[i] << (i * 8); + } + *v = r; + return true; +} + +/** + * @return True if `file` begins with the v2 magic. Leaves the read cursor at 0. + */ +bool Demo_IsV2(file_t *file) { + char magic[DEMO_MAGIC_LEN]; + + if (!Fs_Seek(file, 0)) { + return false; + } + + const bool ok = Fs_Read(file, magic, 1, DEMO_MAGIC_LEN) == DEMO_MAGIC_LEN + && memcmp(magic, DEMO_MAGIC, DEMO_MAGIC_LEN) == 0; + + Fs_Seek(file, 0); + return ok; +} + +/** + * @brief Writes the magic, header, and epoch block to `file` (which must be empty). + */ +bool Demo_WriteHeader(file_t *file, const demo_header_t *header, const void *epoch, size_t epoch_len) { + + if (Fs_Write(file, DEMO_MAGIC, 1, DEMO_MAGIC_LEN) != DEMO_MAGIC_LEN) { + return false; + } + + if (!Demo_WriteU16(file, header->version) || + !Demo_WriteU16(file, header->flags) || + !Demo_WriteU16(file, header->protocol_major) || + !Demo_WriteU16(file, header->protocol_minor) || + !Demo_WriteU16(file, header->tick_rate) || + !Demo_WriteU32(file, (uint32_t) epoch_len)) { + return false; + } + + return epoch_len == 0 || Fs_Write(file, epoch, 1, epoch_len) == (int64_t) epoch_len; +} + +/** + * @brief Reads the header and epoch block. If `epoch` is non-NULL, `*epoch` is + * Mem_Malloc'd and the caller must free it. The cursor is left at the first record. + * @return False if the magic is absent (i.e. a legacy v1 demo). + */ +bool Demo_ReadHeader(file_t *file, demo_header_t *header, void **epoch) { + char magic[DEMO_MAGIC_LEN]; + + if (Fs_Read(file, magic, 1, DEMO_MAGIC_LEN) != DEMO_MAGIC_LEN || + memcmp(magic, DEMO_MAGIC, DEMO_MAGIC_LEN) != 0) { + return false; + } + + if (!Demo_ReadU16(file, &header->version) || + !Demo_ReadU16(file, &header->flags) || + !Demo_ReadU16(file, &header->protocol_major) || + !Demo_ReadU16(file, &header->protocol_minor) || + !Demo_ReadU16(file, &header->tick_rate) || + !Demo_ReadU32(file, &header->epoch_len)) { + return false; + } + + if (epoch) { + *epoch = header->epoch_len ? Mem_Malloc(header->epoch_len) : NULL; + if (header->epoch_len && Fs_Read(file, *epoch, 1, header->epoch_len) != (int64_t) header->epoch_len) { + Mem_Free(*epoch); + *epoch = NULL; + return false; + } + } else if (header->epoch_len) { + Fs_Seek(file, Fs_Tell(file) + (int64_t) header->epoch_len); + } + + return true; +} + +/** + * @brief Writes one record (framing + payload) at the current cursor. + */ +bool Demo_WriteRecord(file_t *file, uint8_t type, uint32_t timecode, const void *payload, size_t length) { + + if (!Demo_WriteU8(file, type) || + !Demo_WriteU32(file, timecode) || + !Demo_WriteU32(file, (uint32_t) length)) { + return false; + } + + return length == 0 || Fs_Write(file, payload, 1, length) == (int64_t) length; +} + +/** + * @brief Reads the next record's framing into `record` and its payload into `buffer`. + * @return False at end-of-stream (clean EOF) or if `buffer_size` is too small. + */ +bool Demo_ReadRecord(file_t *file, demo_record_t *record, void *buffer, size_t buffer_size) { + + if (!Demo_ReadU8(file, &record->type)) { + return false; // clean EOF + } + + if (!Demo_ReadU32(file, &record->timecode) || + !Demo_ReadU32(file, &record->length)) { + return false; + } + + if (record->length > buffer_size) { + return false; + } + + return record->length == 0 || Fs_Read(file, buffer, 1, record->length) == (int64_t) record->length; +} + +/** + * @brief Appends the seek-index footer (entries + count/duration/start + tail magic). + */ +bool Demo_WriteIndex(file_t *file, const demo_index_t *index) { + + const int64_t start = Fs_Tell(file); + if (start < 0) { + return false; + } + + for (uint32_t i = 0; i < index->count; i++) { + if (!Demo_WriteU32(file, index->entries[i].timecode) || + !Demo_WriteU64(file, index->entries[i].offset)) { + return false; + } + } + + if (!Demo_WriteU32(file, index->count) || + !Demo_WriteU32(file, index->duration) || + !Demo_WriteU64(file, (uint64_t) start)) { + return false; + } + + return Fs_Write(file, DEMO_TAIL_MAGIC, 1, DEMO_TAIL_MAGIC_LEN) == DEMO_TAIL_MAGIC_LEN; +} + +// Footer trailer = count(4) + duration(4) + start(8) + tail magic(4). +#define DEMO_FOOTER_TRAILER_LEN (4 + 4 + 8 + DEMO_TAIL_MAGIC_LEN) + +/** + * @brief Reads the index footer if present. + * @return False if no footer is present (no tail magic). + */ +bool Demo_ReadIndex(file_t *file, demo_index_t *index) { + + const int64_t len = Fs_FileLength(file); + if (len < (int64_t) DEMO_FOOTER_TRAILER_LEN) { + return false; + } + + char magic[DEMO_TAIL_MAGIC_LEN]; + if (!Fs_Seek(file, len - DEMO_TAIL_MAGIC_LEN) || + Fs_Read(file, magic, 1, DEMO_TAIL_MAGIC_LEN) != DEMO_TAIL_MAGIC_LEN || + memcmp(magic, DEMO_TAIL_MAGIC, DEMO_TAIL_MAGIC_LEN) != 0) { + return false; + } + + if (!Fs_Seek(file, len - (int64_t) DEMO_FOOTER_TRAILER_LEN)) { + return false; + } + + uint32_t count, duration; + uint64_t start; + if (!Demo_ReadU32(file, &count) || + !Demo_ReadU32(file, &duration) || + !Demo_ReadU64(file, &start)) { + return false; + } + + if (!Fs_Seek(file, (int64_t) start)) { + return false; + } + + index->count = count; + index->duration = duration; + index->entries = count ? Mem_Malloc(count * sizeof(demo_index_entry_t)) : NULL; + + for (uint32_t i = 0; i < count; i++) { + if (!Demo_ReadU32(file, &index->entries[i].timecode) || + !Demo_ReadU64(file, &index->entries[i].offset)) { + Demo_FreeIndex(index); + return false; + } + } + + return true; +} + +/** + * @brief Rebuilds the index by scanning all records, for files that have no + * footer (e.g. a recording interrupted by a crash). The cursor must be at the + * first record on entry; it is left at the first record on return. + */ +bool Demo_ScanIndex(file_t *file, demo_index_t *index) { + + const int64_t first = Fs_Tell(file); + if (first < 0) { + return false; + } + + // pass 1: count keyframes and find the final timecode + uint32_t keyframes = 0, duration = 0; + for (;;) { + uint8_t type; + uint32_t timecode, length; + if (!Demo_ReadU8(file, &type)) { + break; // clean EOF + } + if (!Demo_ReadU32(file, &timecode) || !Demo_ReadU32(file, &length)) { + break; + } + if (type == DEMO_RECORD_FRAME_KEY) { + keyframes++; + } + duration = timecode; + if (!Fs_Seek(file, Fs_Tell(file) + (int64_t) length)) { + break; + } + } + + index->count = keyframes; + index->duration = duration; + index->entries = keyframes ? Mem_Malloc(keyframes * sizeof(demo_index_entry_t)) : NULL; + + // pass 2: record the offset and timecode of each keyframe + if (!Fs_Seek(file, first)) { + Demo_FreeIndex(index); + return false; + } + + uint32_t i = 0; + while (i < keyframes) { + const int64_t offset = Fs_Tell(file); + uint8_t type; + uint32_t timecode, length; + if (!Demo_ReadU8(file, &type) || + !Demo_ReadU32(file, &timecode) || + !Demo_ReadU32(file, &length)) { + break; + } + if (type == DEMO_RECORD_FRAME_KEY) { + index->entries[i].timecode = timecode; + index->entries[i].offset = (uint64_t) offset; + i++; + } + if (!Fs_Seek(file, Fs_Tell(file) + (int64_t) length)) { + break; + } + } + + Fs_Seek(file, first); + return true; +} + +/** + * @brief Frees an index's entries. + */ +void Demo_FreeIndex(demo_index_t *index) { + + if (index->entries) { + Mem_Free(index->entries); + } + + index->entries = NULL; + index->count = 0; +} + +/** + * @return The latest index entry with `timecode <= t`, or NULL if none. Entries + * are assumed to be in ascending timecode order (as written). + */ +const demo_index_entry_t *Demo_IndexFloor(const demo_index_t *index, uint32_t t) { + + const demo_index_entry_t *floor = NULL; + + for (uint32_t i = 0; i < index->count; i++) { + if (index->entries[i].timecode <= t) { + floor = &index->entries[i]; + } else { + break; + } + } + + return floor; +} diff --git a/src/common/demo.h b/src/common/demo.h new file mode 100644 index 000000000..5e225e0f5 --- /dev/null +++ b/src/common/demo.h @@ -0,0 +1,114 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include "filesystem.h" + +/** + * @brief The Quetoo demo v2 container format. A demo file is laid out as: + * + * [magic "QDM2"][header][epoch block][record]*[optional index footer] + * + * Records are timecoded and self-framing. FRAME_KEY records are self-contained + * full snapshots that seeking can jump to. Files lacking the magic are treated + * as legacy v1 demos and played back by the original forward-only path. + */ + +#define DEMO_MAGIC "QDM2" +#define DEMO_MAGIC_LEN 4 +#define DEMO_TAIL_MAGIC "QDMX" +#define DEMO_TAIL_MAGIC_LEN 4 +#define DEMO_FORMAT_VERSION 2 + +/** + * @brief Keyframe cadence, in server ticks (~2s at QUETOO_TICK_RATE 40). + */ +#define DEMO_KEYFRAME_INTERVAL 80 + +/** + * @brief Demo container flags (demo_header_t.flags). + */ +typedef enum { + DEMO_FLAG_NONE = 0, + DEMO_FLAG_CLIENT = 1 << 0, // recorded from a client POV (one player_state per frame) + DEMO_FLAG_SERVER = 1 << 1, // omniscient server recording (multi-POV) + DEMO_FLAG_HAS_INDEX = 1 << 2, // a seek index footer is present +} demo_flags_t; + +/** + * @brief Demo record types (demo_record_t.type). + */ +typedef enum { + DEMO_RECORD_FRAME_DELTA = 0, // one full frame, delta vs the previous frame + DEMO_RECORD_FRAME_KEY = 1, // self-contained full snapshot (seek target) + DEMO_RECORD_RELIABLE = 2, // out-of-band server command that survives seeks + DEMO_RECORD_CAMERA = 3, // reserved: embedded camera script (unused in v2.0) +} demo_record_type_t; + +/** + * @brief The demo file header, following the magic. + */ +typedef struct { + uint16_t version; // DEMO_FORMAT_VERSION + uint16_t flags; // demo_flags_t + uint16_t protocol_major; // PROTOCOL_MAJOR at record time + uint16_t protocol_minor; // PROTOCOL_MINOR at record time + uint16_t tick_rate; // QUETOO_TICK_RATE at record time + uint32_t epoch_len; // length of the epoch block that follows +} demo_header_t; + +/** + * @brief A single record's framing (the payload follows in the file). + */ +typedef struct { + uint8_t type; // demo_record_type_t + uint32_t timecode; // milliseconds from demo start + uint32_t length; // payload length in bytes +} demo_record_t; + +/** + * @brief One seek-index entry (one per FRAME_KEY record). + */ +typedef struct { + uint32_t timecode; // ms + uint64_t offset; // byte offset of the FRAME_KEY record +} demo_index_entry_t; + +/** + * @brief An in-memory seek index. + */ +typedef struct { + demo_index_entry_t *entries; // Mem_Malloc'd, count elements (NULL if count == 0) + uint32_t count; + uint32_t duration; // ms, of the whole demo +} demo_index_t; + +bool Demo_IsV2(file_t *file); +bool Demo_WriteHeader(file_t *file, const demo_header_t *header, const void *epoch, size_t epoch_len); +bool Demo_ReadHeader(file_t *file, demo_header_t *header, void **epoch); +bool Demo_WriteRecord(file_t *file, uint8_t type, uint32_t timecode, const void *payload, size_t length); +bool Demo_ReadRecord(file_t *file, demo_record_t *record, void *buffer, size_t buffer_size); +bool Demo_WriteIndex(file_t *file, const demo_index_t *index); +bool Demo_ReadIndex(file_t *file, demo_index_t *index); +bool Demo_ScanIndex(file_t *file, demo_index_t *index); +void Demo_FreeIndex(demo_index_t *index); +const demo_index_entry_t *Demo_IndexFloor(const demo_index_t *index, uint32_t t); diff --git a/src/tests/Makefile.am b/src/tests/Makefile.am index 8d3acf349..5036b58e7 100644 --- a/src/tests/Makefile.am +++ b/src/tests/Makefile.am @@ -39,6 +39,7 @@ TESTS = \ check_cmd \ check_color \ check_cvar \ + check_demo \ check_editor_map \ check_filesystem \ check_http \ @@ -127,6 +128,13 @@ check_cvar_CFLAGS = \ check_cvar_LDADD = \ $(TESTS_LIBS) +check_demo_SOURCES = \ + check_demo.c +check_demo_CFLAGS = \ + $(TESTS_CFLAGS) +check_demo_LDADD = \ + $(TESTS_LIBS) + check_filesystem_SOURCES = \ check_filesystem.c check_filesystem_CFLAGS = \ diff --git a/src/tests/check_demo.c b/src/tests/check_demo.c new file mode 100644 index 000000000..defd42713 --- /dev/null +++ b/src/tests/check_demo.c @@ -0,0 +1,252 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "tests.h" + +#include "common/demo.h" + +quetoo_t quetoo; + +/** + * @brief Setup fixture. + */ +void setup(void) { + + Mem_Init(); + + Fs_Init(FS_AUTO_LOAD_ARCHIVES); +} + +/** + * @brief Teardown fixture. + */ +void teardown(void) { + + Fs_Shutdown(); + + Mem_Shutdown(); +} + +START_TEST(check_Demo_constants) { + ck_assert_int_eq(DEMO_FORMAT_VERSION, 2); + ck_assert_int_eq(DEMO_MAGIC_LEN, 4); +} END_TEST + +START_TEST(check_Demo_header_roundtrip) { + const char *name = "check_demo_header.demo"; + const byte epoch[] = { 1, 2, 3, 4, 5, 6, 7, 8 }; + + file_t *w = Fs_OpenWrite(name); + ck_assert_ptr_nonnull(w); + + const demo_header_t h = { + .version = DEMO_FORMAT_VERSION, .flags = DEMO_FLAG_CLIENT, + .protocol_major = 2027, .protocol_minor = 1041, .tick_rate = 40, + .epoch_len = sizeof(epoch) + }; + ck_assert(Demo_WriteHeader(w, &h, epoch, sizeof(epoch))); + ck_assert(Fs_Close(w)); + + file_t *r = Fs_OpenRead(name); + ck_assert_ptr_nonnull(r); + ck_assert(Demo_IsV2(r)); + + demo_header_t h2; + void *epoch2 = NULL; + ck_assert(Demo_ReadHeader(r, &h2, &epoch2)); + ck_assert_int_eq(h2.version, h.version); + ck_assert_int_eq(h2.flags, h.flags); + ck_assert_int_eq(h2.protocol_major, h.protocol_major); + ck_assert_int_eq(h2.protocol_minor, h.protocol_minor); + ck_assert_int_eq(h2.tick_rate, h.tick_rate); + ck_assert_int_eq(h2.epoch_len, sizeof(epoch)); + ck_assert_mem_eq(epoch2, epoch, sizeof(epoch)); + + Mem_Free(epoch2); + ck_assert(Fs_Close(r)); + Fs_Delete(name); +} END_TEST + +START_TEST(check_Demo_not_v2) { + const char *name = "check_demo_legacy.demo"; + + file_t *w = Fs_OpenWrite(name); + const byte junk[] = { 0xff, 0xff, 0xff, 0xff, 0, 0 }; + Fs_Write(w, junk, 1, sizeof(junk)); + Fs_Close(w); + + file_t *r = Fs_OpenRead(name); + ck_assert(!Demo_IsV2(r)); + Fs_Close(r); + Fs_Delete(name); +} END_TEST + +START_TEST(check_Demo_record_roundtrip) { + const char *name = "check_demo_records.demo"; + const byte p0[] = { 10, 11, 12 }; + const byte p1[] = { 20 }; + + file_t *w = Fs_OpenWrite(name); + const demo_header_t h = { .version = 2, .epoch_len = 0 }; + Demo_WriteHeader(w, &h, NULL, 0); + ck_assert(Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 0, p0, sizeof(p0))); + ck_assert(Demo_WriteRecord(w, DEMO_RECORD_FRAME_DELTA, 25, p1, sizeof(p1))); + Fs_Close(w); + + file_t *r = Fs_OpenRead(name); + demo_header_t h2; + void *e = NULL; + Demo_ReadHeader(r, &h2, &e); + + demo_record_t rec; + byte buf[64]; + + ck_assert(Demo_ReadRecord(r, &rec, buf, sizeof(buf))); + ck_assert_int_eq(rec.type, DEMO_RECORD_FRAME_KEY); + ck_assert_int_eq(rec.timecode, 0); + ck_assert_int_eq(rec.length, sizeof(p0)); + ck_assert_mem_eq(buf, p0, sizeof(p0)); + + ck_assert(Demo_ReadRecord(r, &rec, buf, sizeof(buf))); + ck_assert_int_eq(rec.type, DEMO_RECORD_FRAME_DELTA); + ck_assert_int_eq(rec.timecode, 25); + ck_assert_int_eq(rec.length, sizeof(p1)); + ck_assert_mem_eq(buf, p1, sizeof(p1)); + + ck_assert(!Demo_ReadRecord(r, &rec, buf, sizeof(buf))); // EOF + + Fs_Close(r); + Fs_Delete(name); +} END_TEST + +START_TEST(check_Demo_index_floor) { + demo_index_entry_t e[3] = { { 0, 100 }, { 2000, 500 }, { 4000, 900 } }; + demo_index_t idx = { .entries = e, .count = 3, .duration = 6000 }; + + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 0), &e[0]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 2500), &e[1]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 4000), &e[2]); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 99999), &e[2]); +} END_TEST + +START_TEST(check_Demo_index_floor_below_first) { + demo_index_entry_t e[2] = { { 1000, 100 }, { 3000, 500 } }; + demo_index_t idx = { .entries = e, .count = 2 }; + + ck_assert_ptr_null(Demo_IndexFloor(&idx, 999)); + ck_assert_ptr_eq(Demo_IndexFloor(&idx, 1000), &e[0]); +} END_TEST + +START_TEST(check_Demo_index_roundtrip) { + const char *name = "check_demo_index.demo"; + const byte p[] = { 7 }; + + file_t *w = Fs_OpenWrite(name); + const demo_header_t h = { .version = 2, .flags = DEMO_FLAG_HAS_INDEX, .epoch_len = 0 }; + Demo_WriteHeader(w, &h, NULL, 0); + + demo_index_entry_t built[2]; + built[0] = (demo_index_entry_t) { .timecode = 0, .offset = (uint64_t) Fs_Tell(w) }; + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 0, p, sizeof(p)); + Demo_WriteRecord(w, DEMO_RECORD_FRAME_DELTA, 25, p, sizeof(p)); + built[1] = (demo_index_entry_t) { .timecode = 2000, .offset = (uint64_t) Fs_Tell(w) }; + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 2000, p, sizeof(p)); + + const demo_index_t idx = { .entries = built, .count = 2, .duration = 2025 }; + ck_assert(Demo_WriteIndex(w, &idx)); + Fs_Close(w); + + file_t *r = Fs_OpenRead(name); + demo_index_t got = { 0 }; + ck_assert(Demo_ReadIndex(r, &got)); + ck_assert_int_eq(got.count, 2); + ck_assert_int_eq(got.duration, 2025); + ck_assert_int_eq(got.entries[0].timecode, 0); + ck_assert_int_eq(got.entries[1].timecode, 2000); + ck_assert_int_eq(got.entries[1].offset, built[1].offset); + Demo_FreeIndex(&got); + + Fs_Close(r); + Fs_Delete(name); +} END_TEST + +START_TEST(check_Demo_scan_index) { + const char *name = "check_demo_scan.demo"; // footer-less file + const byte p[] = { 7 }; + + file_t *w = Fs_OpenWrite(name); + const demo_header_t h = { .version = 2, .epoch_len = 0 }; + Demo_WriteHeader(w, &h, NULL, 0); + + uint64_t off0, off1; + off0 = (uint64_t) Fs_Tell(w); + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 0, p, sizeof(p)); + Demo_WriteRecord(w, DEMO_RECORD_FRAME_DELTA, 25, p, sizeof(p)); + off1 = (uint64_t) Fs_Tell(w); + Demo_WriteRecord(w, DEMO_RECORD_FRAME_KEY, 2000, p, sizeof(p)); + Fs_Close(w); // no index footer + + file_t *r = Fs_OpenRead(name); + demo_header_t h2; + void *e = NULL; + Demo_ReadHeader(r, &h2, &e); // leaves cursor at first record + + demo_index_t scanned = { 0 }; + ck_assert(Demo_ScanIndex(r, &scanned)); + ck_assert_int_eq(scanned.count, 2); + ck_assert_int_eq(scanned.duration, 2000); + ck_assert_int_eq(scanned.entries[0].timecode, 0); + ck_assert_int_eq(scanned.entries[0].offset, off0); + ck_assert_int_eq(scanned.entries[1].timecode, 2000); + ck_assert_int_eq(scanned.entries[1].offset, off1); + Demo_FreeIndex(&scanned); + + Fs_Close(r); + Fs_Delete(name); +} END_TEST + +/** + * @brief Test entry point. + */ +int32_t main(int32_t argc, char **argv) { + + Test_Init(argc, argv); + + TCase *tcase = tcase_create("check_demo"); + tcase_add_checked_fixture(tcase, setup, teardown); + + tcase_add_test(tcase, check_Demo_constants); + tcase_add_test(tcase, check_Demo_header_roundtrip); + tcase_add_test(tcase, check_Demo_not_v2); + tcase_add_test(tcase, check_Demo_record_roundtrip); + tcase_add_test(tcase, check_Demo_index_floor); + tcase_add_test(tcase, check_Demo_index_floor_below_first); + tcase_add_test(tcase, check_Demo_index_roundtrip); + tcase_add_test(tcase, check_Demo_scan_index); + + Suite *suite = suite_create("check_demo"); + suite_add_tcase(suite, tcase); + + int32_t failed = Test_Run(suite); + + Test_Shutdown(); + return failed; +} From b2bef7284761e9f529d5af55cdb276d3b36c1760 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 07:47:03 -0500 Subject: [PATCH 04/18] feat(demo): free spectator camera during demo playback (#377) 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). --- src/cgame/default/Makefile.am | 2 + src/cgame/default/cg_demo.c | 127 +++++++++++++++++++++++++++++++++ src/cgame/default/cg_demo.h | 33 +++++++++ src/cgame/default/cg_entity.c | 2 +- src/cgame/default/cg_local.h | 1 + src/cgame/default/cg_main.c | 2 + src/cgame/default/cg_predict.c | 9 +++ src/cgame/default/cg_view.c | 14 ++-- 8 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 src/cgame/default/cg_demo.c create mode 100644 src/cgame/default/cg_demo.h diff --git a/src/cgame/default/Makefile.am b/src/cgame/default/Makefile.am index d469fc6e3..a6878c0d7 100644 --- a/src/cgame/default/Makefile.am +++ b/src/cgame/default/Makefile.am @@ -3,6 +3,7 @@ SUBDIRS = \ noinst_HEADERS = \ cg_client.h \ + cg_demo.h \ cg_editor.h \ cg_effect.h \ cg_entity.h \ @@ -37,6 +38,7 @@ cgamelib_LTLIBRARIES = \ cgame_la_SOURCES = \ cg_client.c \ + cg_demo.c \ cg_discord.c \ cg_editor.c \ cg_effect.c \ diff --git a/src/cgame/default/cg_demo.c b/src/cgame/default/cg_demo.c new file mode 100644 index 000000000..01b0547c0 --- /dev/null +++ b/src/cgame/default/cg_demo.c @@ -0,0 +1,127 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "cg_local.h" +#include "game/default/bg_pmove.h" + +cvar_t *cg_demo_freecam; + +/** + * @brief Free demo camera state. While active, the camera detaches from the + * recorded player and flies under local input as a PM_SPECTATOR. The recorded + * player remains frozen (see Cl_ParsePlayerState); only the view is overridden. + */ +static struct { + bool active; + pm_state_t s; +} cg_demo; + +/** + * @return True if a demo is playing and the free camera is active. + */ +bool Cg_DemoInFreeCamera(void) { + return cgi.client->demo_server && cg_demo_freecam->value && cg_demo.active; +} + +/** + * @brief Toggles the free demo camera, seeding it from the current view so the + * camera does not jump when it is enabled. + */ +static void Cg_DemoFreecam_f(void) { + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + cg_demo.active = !cg_demo.active; + + if (cg_demo.active) { + cg_demo.s = (pm_state_t) { + .type = PM_SPECTATOR, + .origin = cgi.view->origin, + }; + cgi.client->angles = cgi.view->angles; + cgi.Print("Demo free camera enabled\n"); + } else { + cgi.Print("Demo free camera disabled\n"); + } +} + +/** + * @brief Trace wrapper for the spectator Pm_Move. + */ +static cm_trace_t Cg_DemoCamera_Trace(const vec3_t start, const vec3_t end, const box3_t bounds) { + return cgi.Trace(start, end, bounds, NULL, CONTENTS_MASK_CLIP_PLAYER); +} + +/** + * @brief Integrates the free camera from the pending user commands as a no-clip + * spectator. Because the command msec reflects real frame time, the camera flies + * at a constant real-time speed regardless of the demo playback speed. + */ +void Cg_PredictDemoCamera(const GPtrArray *cmds) { + + pm_move_t pm = { .s = cg_demo.s }; + pm.s.type = PM_SPECTATOR; + + pm.PointContents = cgi.PointContents; + pm.BoxContents = cgi.BoxContents; + pm.Trace = Cg_DemoCamera_Trace; + + pm.Debug = cgi.Debug; + pm.DebugMask = cgi.DebugMask; + pm.debug_mask = DEBUG_PMOVE_CLIENT; + + for (guint i = 0; i < cmds->len; i++) { + const cl_cmd_t *cmd = g_ptr_array_index(cmds, i); + if (cmd->cmd.msec) { + pm.cmd = cmd->cmd; + Pm_Move(&pm); + } + } + + cg_demo.s = pm.s; +} + +/** + * @brief Writes the free camera origin and angles into the view, replacing the + * recorded player's eye for this frame. + */ +void Cg_UpdateDemoCamera(void) { + + cgi.view->origin = Vec3_Add(cg_demo.s.origin, cg_demo.s.view_offset); + cgi.view->angles = cgi.client->angles; + + Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); +} + +/** + * @brief Registers the demo camera cvar and command. + */ +void Cg_InitDemo(void) { + + cg_demo_freecam = cgi.AddCvar("cg_demo_freecam", "1", CVAR_ARCHIVE, + "Enables the free spectator camera during demo playback."); + + cgi.AddCmd("demo_freecam", Cg_DemoFreecam_f, CMD_CGAME, + "Toggle the free-fly camera while watching a demo."); +} diff --git a/src/cgame/default/cg_demo.h b/src/cgame/default/cg_demo.h new file mode 100644 index 000000000..7137e028b --- /dev/null +++ b/src/cgame/default/cg_demo.h @@ -0,0 +1,33 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#if defined(__CG_LOCAL_H__) + +extern cvar_t *cg_demo_freecam; + +bool Cg_DemoInFreeCamera(void); +void Cg_InitDemo(void); +void Cg_PredictDemoCamera(const GPtrArray *cmds); +void Cg_UpdateDemoCamera(void); + +#endif /* __CG_LOCAL_H__ */ diff --git a/src/cgame/default/cg_entity.c b/src/cgame/default/cg_entity.c index 7489f25ec..bffc79041 100644 --- a/src/cgame/default/cg_entity.c +++ b/src/cgame/default/cg_entity.c @@ -275,7 +275,7 @@ static void Cg_AddEntity(cl_entity_t *ent) { Cg_AddClientEntity(ent, &e); // add our view weapon, if it's our view entity and we're in first-person - if (ent == Cg_Self() && !cgi.client->third_person) { + if (ent == Cg_Self() && !cgi.client->third_person && !Cg_DemoInFreeCamera()) { Cg_AddWeapon(ent, &e); } diff --git a/src/cgame/default/cg_local.h b/src/cgame/default/cg_local.h index a7413b6af..5bb87af4d 100644 --- a/src/cgame/default/cg_local.h +++ b/src/cgame/default/cg_local.h @@ -28,6 +28,7 @@ #define Cg_Warn(...) cgi.Warn(__func__, __VA_ARGS__) #include "cg_client.h" +#include "cg_demo.h" #include "cg_editor.h" #include "cg_discord.h" #include "cg_effect.h" diff --git a/src/cgame/default/cg_main.c b/src/cgame/default/cg_main.c index b472117d4..27aeafb57 100644 --- a/src/cgame/default/cg_main.c +++ b/src/cgame/default/cg_main.c @@ -184,6 +184,8 @@ static void Cg_Init(void) { Cg_InitDiscord(); + Cg_InitDemo(); + cgi.Print("Client game module initialized\n"); } diff --git a/src/cgame/default/cg_predict.c b/src/cgame/default/cg_predict.c index 0516ac44f..88d16cfe1 100644 --- a/src/cgame/default/cg_predict.c +++ b/src/cgame/default/cg_predict.c @@ -27,6 +27,10 @@ */ bool Cg_UsePrediction(void) { + if (Cg_DemoInFreeCamera()) { + return true; + } + if (!cg_predict->value) { return false; } @@ -70,6 +74,11 @@ void Cg_PredictMovement(const GPtrArray *cmds) { assert(cmds); assert(cmds->len); + if (Cg_DemoInFreeCamera()) { + Cg_PredictDemoCamera(cmds); + return; + } + cl_predicted_state_t *pr = &cgi.client->predicted_state; // copy current state to into the move diff --git a/src/cgame/default/cg_view.c b/src/cgame/default/cg_view.c index c3bd8c9af..c4f2af661 100644 --- a/src/cgame/default/cg_view.c +++ b/src/cgame/default/cg_view.c @@ -337,15 +337,21 @@ void Cg_PrepareView(const cl_frame_t *frame) { const player_state_t *ps1 = &frame->ps; - Cg_UpdateOrigin(ps0, ps1); + if (Cg_DemoInFreeCamera()) { + Cg_UpdateDemoCamera(); + } else { + Cg_UpdateOrigin(ps0, ps1); - Cg_UpdateAngles(ps0, ps1); + Cg_UpdateAngles(ps0, ps1); - Cg_UpdateThirdPerson(ps1); + Cg_UpdateThirdPerson(ps1); + } Cg_UpdateFov(); - Cg_UpdateBob(ps1); + if (!Cg_DemoInFreeCamera()) { + Cg_UpdateBob(ps1); + } Cg_UpdateAmbient(); From c5f1ab58357869b7d95d344daffd8a8129840685 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 12:51:24 -0500 Subject: [PATCH 05/18] feat(demo): v2 recording + server-hosted forward playback (#377) 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. --- src/client/cl_demo.c | 123 +++++++++++++++++++++++++++++++++--------- src/client/cl_demo.h | 3 ++ src/client/cl_main.c | 2 + src/server/sv_init.c | 17 +++++- src/server/sv_main.c | 4 ++ src/server/sv_send.c | 45 ++++++++++++++-- src/server/sv_types.h | 11 ++++ 7 files changed, 175 insertions(+), 30 deletions(-) diff --git a/src/client/cl_demo.c b/src/client/cl_demo.c index e1fd1d071..61c3c8e23 100644 --- a/src/client/cl_demo.c +++ b/src/client/cl_demo.c @@ -20,16 +20,58 @@ */ #include "cl_local.h" +#include "common/demo.h" + +cvar_t *cl_demo_v2; + +// state for the in-progress recording +static bool cl_demo_recording_v2; // true if the active recording is the v2 container +static int32_t cl_demo_first_frame; // frame_num of the first recorded frame, or -1 +static int32_t cl_demo_last_frame; // frame_num of the most recently recorded frame + +/** + * @brief Flushes one accumulated startup sub-message to the demo file, as a v1 + * length-prefixed block or, for v2, a RELIABLE record stamped at timecode 0. + */ +static void Cl_FlushDemoHeader(mem_buf_t *msg) { + + if (msg->size == 0) { + return; + } + + if (cl_demo_recording_v2) { + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, 0, msg->data, msg->size); + } else { + const int32_t len = LittleLong((int32_t) msg->size); + Fs_Write(cls.demo_file, &len, sizeof(len), 1); + Fs_Write(cls.demo_file, msg->data, msg->size, 1); + } + + msg->size = 0; +} /** * @brief Writes `server_data`, `config_strings`, and baselines once a non-delta - * compressed frame arrives from the server. + * compressed frame arrives from the server. For v2 demos this also writes the + * container header; the startup messages become RELIABLE records at timecode 0. */ static void Cl_WriteDemoHeader(void) { static entity_state_t null_state; mem_buf_t msg; byte buffer[MAX_MSG_SIZE]; + if (cl_demo_recording_v2) { + const demo_header_t header = { + .version = DEMO_FORMAT_VERSION, + .flags = DEMO_FLAG_CLIENT, + .protocol_major = PROTOCOL_MAJOR, + .protocol_minor = (uint16_t) cls.cgame->protocol, + .tick_rate = QUETOO_TICK_RATE, + .epoch_len = 0, + }; + Demo_WriteHeader(cls.demo_file, &header, NULL, 0); + } + // write out messages to hold the startup information Mem_InitBuffer(&msg, buffer, sizeof(buffer)); @@ -45,10 +87,7 @@ static void Cl_WriteDemoHeader(void) { for (int32_t i = 0; i < MAX_CONFIG_STRINGS; i++) { if (*cl.config_strings[i] != '\0') { if (msg.size + strlen(cl.config_strings[i]) + 32 > msg.max_size) { // write it out - const int32_t len = LittleLong((int32_t) msg.size); - Fs_Write(cls.demo_file, &len, sizeof(len), 1); - Fs_Write(cls.demo_file, msg.data, msg.size, 1); - msg.size = 0; + Cl_FlushDemoHeader(&msg); } Net_WriteByte(&msg, SV_CMD_CONFIG_STRING); @@ -65,10 +104,7 @@ static void Cl_WriteDemoHeader(void) { } if (msg.size + 64 > msg.max_size) { // write it out - const int32_t len = LittleLong((int32_t) msg.size); - Fs_Write(cls.demo_file, &len, sizeof(len), 1); - Fs_Write(cls.demo_file, msg.data, msg.size, 1); - msg.size = 0; + Cl_FlushDemoHeader(&msg); } Net_WriteByte(&msg, SV_CMD_BASELINE); @@ -78,19 +114,39 @@ static void Cl_WriteDemoHeader(void) { Net_WriteByte(&msg, SV_CMD_CBUF_TEXT); Net_WriteString(&msg, "precache 0\n"); - // write it to the demo file - - const int32_t len = LittleLong((int32_t) msg.size); - - Fs_Write(cls.demo_file, &len, sizeof(len), 1); - Fs_Write(cls.demo_file, msg.data, msg.size, 1); + Cl_FlushDemoHeader(&msg); Com_Debug(DEBUG_CLIENT, "Demo started\n"); // the rest of the demo file will be individual frames } /** - * @brief Dumps the current net message, prefixed by the length. + * @brief Writes the current net message as a v2 record, stamping it with the + * current frame's timecode and classifying it as a keyframe, delta, or reliable. + */ +static void Cl_WriteDemoRecord(void) { + + if (cl_demo_first_frame < 0) { + cl_demo_first_frame = cl.frame.frame_num; + } + + const uint32_t timecode = (uint32_t) (cl.frame.frame_num - cl_demo_first_frame) * QUETOO_TICK_MILLIS; + + uint8_t type; + if (cl.frame.frame_num != cl_demo_last_frame) { // this packet delivered a new frame + type = (cl.frame.delta_frame_num < 0) ? DEMO_RECORD_FRAME_KEY : DEMO_RECORD_FRAME_DELTA; + cl_demo_last_frame = cl.frame.frame_num; + } else { // a trailing datagram-only packet for the same frame + type = DEMO_RECORD_RELIABLE; + } + + // the first eight bytes are just packet sequencing stuff + Demo_WriteRecord(cls.demo_file, type, timecode, net_message.data + 8, net_message.size - 8); +} + +/** + * @brief Dumps the current net message to the demo, prefixed by the length (v1) + * or wrapped in a timecoded record (v2). */ void Cl_WriteDemoMessage(void) { @@ -107,26 +163,33 @@ void Cl_WriteDemoMessage(void) { } } - // the first eight bytes are just packet sequencing stuff - const int32_t len = LittleLong((int32_t) (net_message.size - 8)); + if (cl_demo_recording_v2) { + Cl_WriteDemoRecord(); + } else { + // the first eight bytes are just packet sequencing stuff + const int32_t len = LittleLong((int32_t) (net_message.size - 8)); - Fs_Write(cls.demo_file, &len, sizeof(len), 1); - Fs_Write(cls.demo_file, net_message.data + 8, len, 1); + Fs_Write(cls.demo_file, &len, sizeof(len), 1); + Fs_Write(cls.demo_file, net_message.data + 8, len, 1); + } } /** * @brief Stop recording a demo */ void Cl_Stop_f(void) { - int32_t len = -1; if (!cls.demo_file) { Com_Print("Not recording a demo\n"); return; } - // finish up - Fs_Write(cls.demo_file, &len, sizeof(len), 1); + if (!cl_demo_recording_v2) { // v1 demos are terminated with a -1 length sentinel + int32_t len = -1; + Fs_Write(cls.demo_file, &len, sizeof(len), 1); + } + + // v2 demos are terminated by end-of-file (records are self-framing) Fs_Close(cls.demo_file); cls.demo_file = NULL; @@ -172,7 +235,11 @@ void Cl_Record_f(void) { return; } - Com_Print("Recording to %s\n", cls.demo_filename); + cl_demo_recording_v2 = cl_demo_v2->value; + cl_demo_first_frame = -1; + cl_demo_last_frame = -1; + + Com_Print("Recording to %s (%s)\n", cls.demo_filename, cl_demo_recording_v2 ? "v2" : "v1"); } #define DEMO_PLAYBACK_STEP 1 @@ -204,3 +271,11 @@ void Cl_FastForward_f(void) { void Cl_SlowMotion_f(void) { Cl_AdjustDemoPlayback(-DEMO_PLAYBACK_STEP); } + +/** + * @brief Registers demo recording cvars. + */ +void Cl_InitDemo(void) { + cl_demo_v2 = Cvar_Add("cl_demo_v2", "1", CVAR_ARCHIVE, + "Record demos in the seekable v2 container format (0 for legacy v1)."); +} diff --git a/src/client/cl_demo.h b/src/client/cl_demo.h index e2c87eca7..ba313bcfc 100644 --- a/src/client/cl_demo.h +++ b/src/client/cl_demo.h @@ -23,7 +23,10 @@ #include "cl_types.h" +extern cvar_t *cl_demo_v2; + #if defined(__CL_LOCAL_H__) +void Cl_InitDemo(void); void Cl_WriteDemoMessage(void); void Cl_Record_f(void); void Cl_Stop_f(void); diff --git a/src/client/cl_main.c b/src/client/cl_main.c index fb201bbce..599d1355d 100644 --- a/src/client/cl_main.c +++ b/src/client/cl_main.c @@ -587,6 +587,8 @@ static void Cl_InitLocal(void) { cl_draw_net_messages = Cvar_Add("cl_draw_net_messages", "0", CVAR_DEVELOPER, NULL); + Cl_InitDemo(); + // register our commands Cmd_Add("ping", Cl_Ping_f, CMD_CLIENT, NULL); Cmd_Add("servers", Cl_Servers_f, CMD_CLIENT, NULL); diff --git a/src/server/sv_init.c b/src/server/sv_init.c index 0f8674f8b..864163292 100644 --- a/src/server/sv_init.c +++ b/src/server/sv_init.c @@ -20,6 +20,7 @@ */ #include "sv_local.h" +#include "common/demo.h" /** * @brief Searches `sv.`config_strings` from the specified start, searching for the @@ -275,7 +276,21 @@ static void Sv_LoadMedia(const char *name, const cm_entity_t *props, sv_state_t sv.demo_file = Fs_OpenRead(va("demos/%s.demo", sv.name)); svs.spawn_count = 0; - Com_Print(" Loaded demo %s.\n", sv.name); + // detect the v2 container; if present, consume its header so the read + // cursor is left at the first record. Otherwise fall back to legacy v1. + sv.demo_v2 = false; + sv.demo_time = 0; + + if (sv.demo_file && Demo_IsV2(sv.demo_file)) { + demo_header_t header; + if (Demo_ReadHeader(sv.demo_file, &header, NULL)) { + sv.demo_v2 = true; + } else { + Com_Warn("Corrupt v2 demo header in %s\n", sv.name); + } + } + + Com_Print(" Loaded demo %s (%s).\n", sv.name, sv.demo_v2 ? "v2" : "v1"); } else { // loading a map Cvar_ForceSetString(sv_map->name, sv.name); diff --git a/src/server/sv_main.c b/src/server/sv_main.c index 8c91b4303..490e687fd 100644 --- a/src/server/sv_main.c +++ b/src/server/sv_main.c @@ -671,6 +671,10 @@ static void Sv_RunGameFrame(void) { if (svs.state == SV_ACTIVE_GAME) { svs.game->Frame(); Sv_SyncGameClients(); + } else if (svs.state == SV_ACTIVE_DEMO && sv.demo_v2) { + // advance the demo clock; records become due as it reaches their timecode. + // time_scale already scales the real frame rate, so this tracks playback speed. + sv.demo_time += QUETOO_TICK_MILLIS; } } diff --git a/src/server/sv_send.c b/src/server/sv_send.c index 54cfd731d..b3ba13746 100644 --- a/src/server/sv_send.c +++ b/src/server/sv_send.c @@ -20,6 +20,7 @@ */ #include "sv_local.h" +#include "common/demo.h" /** * @brief Sends text across to be displayed if the level filter passes. @@ -377,6 +378,33 @@ static size_t Sv_GetDemoMessage(byte *buffer) { return size; } +/** + * @brief Transmits all v2 demo records whose timecode has been reached by the + * demo clock, reframing each as the client expects. The records are the exact + * v1 wire messages, so the client parses them identically to live play. + * @return False when the demo has completed (end of records). + */ +static bool Sv_SendDemoV2Records(sv_client_t *cl) { + byte buffer[MAX_MSG_SIZE]; + demo_record_t rec; + + for (;;) { + const int64_t offset = Fs_Tell(sv.demo_file); + + if (!Demo_ReadRecord(sv.demo_file, &rec, buffer, sizeof(buffer))) { + Sv_DemoCompleted(); + return false; + } + + if (rec.timecode > sv.demo_time) { // not due yet; rewind and wait + Fs_Seek(sv.demo_file, offset); + return true; + } + + Netchan_Transmit(&cl->net_chan, buffer, rec.length); + } +} + /** * @brief Send the frame and all pending datagram messages since the last frame. */ @@ -406,13 +434,20 @@ void Sv_SendClientPackets(void) { } if (svs.state == SV_ACTIVE_DEMO) { // send the demo packet - byte buffer[MAX_MSG_SIZE]; - size_t size; - if ((size = Sv_GetDemoMessage(buffer))) { - Netchan_Transmit(&cl->net_chan, buffer, size); + if (sv.demo_v2) { + if (!Sv_SendDemoV2Records(cl)) { + break; // demo complete + } } else { - break; // recording is done, so we're done + byte buffer[MAX_MSG_SIZE]; + size_t size; + + if ((size = Sv_GetDemoMessage(buffer))) { + Netchan_Transmit(&cl->net_chan, buffer, size); + } else { + break; // recording is done, so we're done + } } } else if (cl->state == SV_CLIENT_ACTIVE) { // send the game packet diff --git a/src/server/sv_types.h b/src/server/sv_types.h index 69062f981..1091f3ee6 100644 --- a/src/server/sv_types.h +++ b/src/server/sv_types.h @@ -118,6 +118,17 @@ typedef struct { * @brief Open demo file for demo playback, or `NULL` during live gameplay. */ file_t *demo_file; + + /** + * @brief True if the active demo is the v2 (timecoded, seekable) container. + */ + bool demo_v2; + + /** + * @brief Demo playback clock, in milliseconds from demo start (v2 only). + * Records are transmitted to the client as this clock reaches their timecode. + */ + uint32_t demo_time; } sv_server_t; /** From a57c39c020e916bc3ac6a1801d19b31db5ed2d45 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 16:02:39 -0500 Subject: [PATCH 06/18] feat(demo): seek / pause / speed / step for v2 playback (#377) 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 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. --- src/client/cl_demo.c | 56 ++++++++++++++++---- src/server/sv_admin.c | 119 ++++++++++++++++++++++++++++++++++++++++++ src/server/sv_init.c | 8 +++ src/server/sv_main.c | 6 ++- src/server/sv_types.h | 17 ++++++ 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/src/client/cl_demo.c b/src/client/cl_demo.c index 61c3c8e23..51f47bd9f 100644 --- a/src/client/cl_demo.c +++ b/src/client/cl_demo.c @@ -120,9 +120,42 @@ static void Cl_WriteDemoHeader(void) { // the rest of the demo file will be individual frames } +/** + * @brief Re-encodes the current decoded frame as a self-contained keyframe (a + * full, non-delta snapshot) and writes it as a FRAME_KEY record. This is the + * exact wire format the client parser expects for an uncompressed frame: the + * player state delta'd from null, and every live entity delta'd from its + * baseline. Seeking jumps to these keyframes (see Demo_ScanIndex / Sv_DemoSeek). + */ +static void Cl_WriteDemoKeyframe(uint32_t timecode) { + static player_state_t null_state; + mem_buf_t kf; + byte buffer[MAX_MSG_SIZE]; + + Mem_InitBuffer(&kf, buffer, sizeof(buffer)); + + Net_WriteByte(&kf, SV_CMD_FRAME); + Net_WriteLong(&kf, cl.frame.frame_num); + Net_WriteLong(&kf, -1); // uncompressed + + Net_WriteDeltaPlayerState(&kf, &null_state, &cl.frame.ps); + + for (int32_t i = 0; i < cl.frame.num_entities; i++) { + const uint32_t snum = (cl.frame.entity_state + i) & ENTITY_STATE_MASK; + const entity_state_t *s = &cl.entity_states[snum]; + Net_WriteDeltaEntity(&kf, &cl.entities[s->number].baseline, s, true); + } + + Net_WriteShort(&kf, -1); // end of entities + + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, kf.data, kf.size); +} + /** * @brief Writes the current net message as a v2 record, stamping it with the * current frame's timecode and classifying it as a keyframe, delta, or reliable. + * Every DEMO_KEYFRAME_INTERVAL frames the natural delta is replaced with a + * re-encoded keyframe so that seeking has a nearby self-contained entry point. */ static void Cl_WriteDemoRecord(void) { @@ -130,18 +163,23 @@ static void Cl_WriteDemoRecord(void) { cl_demo_first_frame = cl.frame.frame_num; } - const uint32_t timecode = (uint32_t) (cl.frame.frame_num - cl_demo_first_frame) * QUETOO_TICK_MILLIS; + const int32_t rel_frame = cl.frame.frame_num - cl_demo_first_frame; + const uint32_t timecode = (uint32_t) rel_frame * QUETOO_TICK_MILLIS; - uint8_t type; - if (cl.frame.frame_num != cl_demo_last_frame) { // this packet delivered a new frame - type = (cl.frame.delta_frame_num < 0) ? DEMO_RECORD_FRAME_KEY : DEMO_RECORD_FRAME_DELTA; - cl_demo_last_frame = cl.frame.frame_num; - } else { // a trailing datagram-only packet for the same frame - type = DEMO_RECORD_RELIABLE; + if (cl.frame.frame_num == cl_demo_last_frame) { // a trailing datagram-only packet + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, timecode, net_message.data + 8, net_message.size - 8); + return; } - // the first eight bytes are just packet sequencing stuff - Demo_WriteRecord(cls.demo_file, type, timecode, net_message.data + 8, net_message.size - 8); + cl_demo_last_frame = cl.frame.frame_num; + + if (cl.frame.delta_frame_num < 0) { // a naturally uncompressed frame is already a keyframe + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, net_message.data + 8, net_message.size - 8); + } else if (rel_frame > 0 && (rel_frame % DEMO_KEYFRAME_INTERVAL) == 0) { // periodic keyframe + Cl_WriteDemoKeyframe(timecode); + } else { + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, net_message.data + 8, net_message.size - 8); + } } /** diff --git a/src/server/sv_admin.c b/src/server/sv_admin.c index 9b43631f4..0106f5595 100644 --- a/src/server/sv_admin.c +++ b/src/server/sv_admin.c @@ -24,6 +24,7 @@ #endif #include "sv_local.h" +#include "common/demo.h" /** * @brief Sets the master servers for this dedicated server and sends an initial ping. @@ -153,6 +154,119 @@ static void Sv_Demo_f(void) { } } +/** + * @return True if a seekable v2 demo is currently playing. + */ +static bool Sv_DemoActive(void) { + return svs.state == SV_ACTIVE_DEMO && sv.demo_v2; +} + +/** + * @brief Seeks playback to the nearest keyframe at or before `target_ms`; the + * frames between the keyframe and the target are then replayed to the client, + * which snaps to the result (see Cl_UpdateLerp). + */ +static void Sv_DemoSeekTo(uint32_t target_ms) { + + const demo_index_entry_t *kf = Demo_IndexFloor(&sv.demo_index, target_ms); + if (kf == NULL) { + if (sv.demo_index.count == 0) { + Com_Print("Demo has no keyframes to seek to\n"); + return; + } + kf = &sv.demo_index.entries[0]; // before the first keyframe; clamp to it + } + + Fs_Seek(sv.demo_file, (int64_t) kf->offset); + sv.demo_time = target_ms; + + Com_Print("Seek to %.1fs\n", target_ms / 1000.0); +} + +/** + * @brief demo_seek — seek demo playback. + */ +static void Sv_DemoSeek_f(void) { + + if (!Sv_DemoActive()) { + Com_Print("Not playing a seekable demo\n"); + return; + } + + if (Cmd_Argc() != 2) { + Com_Print("Usage: %s \n", Cmd_Argv(0)); + return; + } + + const char *arg = Cmd_Argv(1); + const bool relative = (arg[0] == '+' || arg[0] == '-'); + const int32_t delta_ms = (int32_t) (strtof(relative ? arg + 1 : arg, NULL) * 1000.0f); + + int32_t target; + if (arg[0] == '+') { + target = (int32_t) sv.demo_time + delta_ms; + } else if (arg[0] == '-') { + target = (int32_t) sv.demo_time - delta_ms; + } else { + target = delta_ms; + } + + target = Clampf(target, 0, (int32_t) sv.demo_index.duration); + + Sv_DemoSeekTo((uint32_t) target); +} + +/** + * @brief demo_pause — toggle demo playback. + */ +static void Sv_DemoPause_f(void) { + + if (!Sv_DemoActive()) { + Com_Print("Not playing a seekable demo\n"); + return; + } + + sv.demo_paused = !sv.demo_paused; + Com_Print("Demo %s\n", sv.demo_paused ? "paused" : "resumed"); +} + +/** + * @brief demo_speed [multiplier] — get or set demo playback rate (0.1 - 8.0). + */ +static void Sv_DemoSpeed_f(void) { + + if (!Sv_DemoActive()) { + Com_Print("Not playing a seekable demo\n"); + return; + } + + if (Cmd_Argc() == 2) { + sv.demo_speed = Clampf(strtof(Cmd_Argv(1), NULL), 0.1f, 8.0f); + } + + Com_Print("Demo speed %.2fx\n", sv.demo_speed); +} + +/** + * @brief demo_step [frames] — step playback by N frames (default 1) and pause. + */ +static void Sv_DemoStep_f(void) { + + if (!Sv_DemoActive()) { + Com_Print("Not playing a seekable demo\n"); + return; + } + + const int32_t frames = (Cmd_Argc() == 2) ? atoi(Cmd_Argv(1)) : 1; + + sv.demo_paused = true; + + const int32_t target = Clampf((int32_t) sv.demo_time + frames * QUETOO_TICK_MILLIS, + 0, (int32_t) sv.demo_index.duration); + + Sv_DemoSeekTo((uint32_t) target); +} + /** * @brief Map command autocompletion. */ @@ -431,6 +545,11 @@ void Sv_InitAdmin(void) { cmd_t *demo_cmd = Cmd_Add("demo", Sv_Demo_f, CMD_SERVER, "Start playback of the specified demo file"); Cmd_SetAutocomplete(demo_cmd, Sv_Demo_Autocomplete_f); + Cmd_Add("demo_seek", Sv_DemoSeek_f, CMD_SERVER, "Seek demo playback: "); + Cmd_Add("demo_pause", Sv_DemoPause_f, CMD_SERVER, "Toggle demo playback pause"); + Cmd_Add("demo_speed", Sv_DemoSpeed_f, CMD_SERVER, "Get or set demo playback speed multiplier"); + Cmd_Add("demo_step", Sv_DemoStep_f, CMD_SERVER, "Step demo playback by N frames (pauses)"); + cmd_t *map_cmd = Cmd_Add("map", Sv_Map_f, CMD_SERVER, "Start a server for the specified map."); Cmd_SetAutocomplete(map_cmd, Sv_Map_Autocomplete_f); diff --git a/src/server/sv_init.c b/src/server/sv_init.c index 864163292..0c4188e75 100644 --- a/src/server/sv_init.c +++ b/src/server/sv_init.c @@ -150,6 +150,8 @@ static void Sv_ClearState(void) { Fs_Close(sv.demo_file); } + Demo_FreeIndex(&sv.demo_index); + memset(&sv, 0, sizeof(sv)); Com_QuitSubsystem(QUETOO_SERVER); @@ -285,6 +287,12 @@ static void Sv_LoadMedia(const char *name, const cm_entity_t *props, sv_state_t demo_header_t header; if (Demo_ReadHeader(sv.demo_file, &header, NULL)) { sv.demo_v2 = true; + sv.demo_speed = 1.0; + sv.demo_paused = false; + + // build the keyframe seek index; this leaves the cursor at the first record + Demo_ScanIndex(sv.demo_file, &sv.demo_index); + Com_Print(" %u keyframe(s), %.1fs.\n", sv.demo_index.count, sv.demo_index.duration / 1000.0); } else { Com_Warn("Corrupt v2 demo header in %s\n", sv.name); } diff --git a/src/server/sv_main.c b/src/server/sv_main.c index 490e687fd..d8e0cfabf 100644 --- a/src/server/sv_main.c +++ b/src/server/sv_main.c @@ -673,8 +673,10 @@ static void Sv_RunGameFrame(void) { Sv_SyncGameClients(); } else if (svs.state == SV_ACTIVE_DEMO && sv.demo_v2) { // advance the demo clock; records become due as it reaches their timecode. - // time_scale already scales the real frame rate, so this tracks playback speed. - sv.demo_time += QUETOO_TICK_MILLIS; + // demo_speed scales playback independently of the (real-time) free camera. + if (!sv.demo_paused) { + sv.demo_time += (uint32_t) (QUETOO_TICK_MILLIS * sv.demo_speed); + } } } diff --git a/src/server/sv_types.h b/src/server/sv_types.h index 1091f3ee6..d63bdbd68 100644 --- a/src/server/sv_types.h +++ b/src/server/sv_types.h @@ -22,6 +22,7 @@ #pragma once #include "common/common.h" +#include "common/demo.h" #include "game/game.h" @@ -129,6 +130,22 @@ typedef struct { * Records are transmitted to the client as this clock reaches their timecode. */ uint32_t demo_time; + + /** + * @brief Keyframe seek index for the active v2 demo, built on load. + */ + demo_index_t demo_index; + + /** + * @brief When true, the demo clock is frozen (v2 only). + */ + bool demo_paused; + + /** + * @brief Demo clock rate multiplier (1.0 = realtime). Independent of + * time_scale, so the (real-time) free camera stays smooth at any speed. + */ + float demo_speed; } sv_server_t; /** From bf8dba7c4e281c8f968a0328903dde795f65d2ff Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 16:37:03 -0500 Subject: [PATCH 07/18] feat(demo): bindable Demo controls tab in settings (#377) 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. --- Quetoo.vs15/cgame.vcxproj | 4 + .../ui/controls/ControlsViewController.c | 5 + .../ui/controls/DemoBindsViewController.c | 100 ++++++++++++ .../ui/controls/DemoBindsViewController.css | 3 + .../ui/controls/DemoBindsViewController.h | 71 ++++++++ .../ui/controls/DemoBindsViewController.json | 154 ++++++++++++++++++ src/cgame/default/ui/controls/Makefile.am | 4 + src/server/sv_admin.c | 30 ++++ 8 files changed, 371 insertions(+) create mode 100644 src/cgame/default/ui/controls/DemoBindsViewController.c create mode 100644 src/cgame/default/ui/controls/DemoBindsViewController.css create mode 100644 src/cgame/default/ui/controls/DemoBindsViewController.h create mode 100644 src/cgame/default/ui/controls/DemoBindsViewController.json diff --git a/Quetoo.vs15/cgame.vcxproj b/Quetoo.vs15/cgame.vcxproj index 694da6274..1b2ebfa1c 100644 --- a/Quetoo.vs15/cgame.vcxproj +++ b/Quetoo.vs15/cgame.vcxproj @@ -28,6 +28,7 @@ + @@ -62,6 +63,7 @@ + @@ -97,6 +99,7 @@ + @@ -129,6 +132,7 @@ + diff --git a/src/cgame/default/ui/controls/ControlsViewController.c b/src/cgame/default/ui/controls/ControlsViewController.c index d24edb629..53017ae4d 100644 --- a/src/cgame/default/ui/controls/ControlsViewController.c +++ b/src/cgame/default/ui/controls/ControlsViewController.c @@ -25,6 +25,7 @@ #include "ResponseServiceViewController.h" #include "MovementCombatViewController.h" +#include "DemoBindsViewController.h" #define _Class _ControlsViewController @@ -69,6 +70,10 @@ static void loadView(ViewController *self) { $(tabViewController, addChildViewController, viewController); release(viewController); + viewController = $((ViewController *) alloc(DemoBindsViewController), init); + $(tabViewController, addChildViewController, viewController); + release(viewController); + $(self, addChildViewController, tabViewController); $((View *) ((Panel *) view)->contentView, addSubview, tabViewController->view); } diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.c b/src/cgame/default/ui/controls/DemoBindsViewController.c new file mode 100644 index 000000000..757922a5b --- /dev/null +++ b/src/cgame/default/ui/controls/DemoBindsViewController.c @@ -0,0 +1,100 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "cg_local.h" + +#include "DemoBindsViewController.h" + +#define _Class _DemoBindsViewController + +#pragma mark - Delegates + +/** + * @brief TextViewDelegate callback for binding keys. + */ +static void didBindKey(TextView *textView) { + + const ViewController *this = textView->delegate.self; + + $(this->view, updateBindings); +} + +/** + * @brief ViewEnumerator for setting the TextViewDelegate on BindTextViews. + */ +static void setDelegate(View *view, ident data) { + + ((TextView *) view)->delegate = (TextViewDelegate) { + .self = data, + .didEndEditing = didBindKey + }; +} + +#pragma mark - ViewController + +/** + * @see ViewController::loadView(ViewController *) + */ +static void loadView(ViewController *self) { + + super(ViewController, self, loadView); + + $(self->view, awakeWithResourceName, "ui/controls/DemoBindsViewController.json"); + + self->view->stylesheet = $$(Stylesheet, stylesheetWithResourceName, "ui/controls/DemoBindsViewController.css"); + assert(self->view->stylesheet); + + $(self->view, enumerateSelection, "BindTextView", setDelegate, self); +} + +#pragma mark - Class lifecycle + +/** + * @see Class::initialize(Class *) + */ +static void initialize(Class *clazz) { + + ((ViewControllerInterface *) clazz->interface)->loadView = loadView; +} + +/** + * @fn Class *DemoBindsViewController::_DemoBindsViewController(void) + * @memberof DemoBindsViewController + */ +Class *_DemoBindsViewController(void) { + static Class *clazz; + static Once once; + + do_once(&once, { + clazz = _initialize(&(const ClassDef) { + .name = "DemoBindsViewController", + .superclass = _ViewController(), + .instanceSize = sizeof(DemoBindsViewController), + .interfaceOffset = offsetof(DemoBindsViewController, interface), + .interfaceSize = sizeof(DemoBindsViewControllerInterface), + .initialize = initialize, + }); + }); + + return clazz; +} + +#undef _Class diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.css b/src/cgame/default/ui/controls/DemoBindsViewController.css new file mode 100644 index 000000000..4edb3afc6 --- /dev/null +++ b/src/cgame/default/ui/controls/DemoBindsViewController.css @@ -0,0 +1,3 @@ +.demoBinds { + autoresizing-mask: contain | width; +} diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.h b/src/cgame/default/ui/controls/DemoBindsViewController.h new file mode 100644 index 000000000..c194a2b3f --- /dev/null +++ b/src/cgame/default/ui/controls/DemoBindsViewController.h @@ -0,0 +1,71 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include "cg_types.h" + +#include + +/** + * @file + * @brief Demo key bindings ViewController. + */ + +typedef struct DemoBindsViewController DemoBindsViewController; +typedef struct DemoBindsViewControllerInterface DemoBindsViewControllerInterface; + +/** + * @brief The DemoBindsViewController type. + * @extends ViewController + */ +struct DemoBindsViewController { + + /** + * @brief The superclass. + */ + ViewController viewController; + + /** + * @brief The interface. + * @protected + */ + DemoBindsViewControllerInterface *interface; +}; + +/** + * @brief The DemoBindsViewController interface. + */ +struct DemoBindsViewControllerInterface { + + /** + * @brief The superclass interface. + */ + ViewControllerInterface viewControllerInterface; +}; + +/** + * @fn Class *DemoBindsViewController::_DemoBindsViewController(void) + * @brief The DemoBindsViewController archetype. + * @return The DemoBindsViewController Class. + * @memberof DemoBindsViewController + */ +CGAME_EXPORT Class *_DemoBindsViewController(void); diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.json b/src/cgame/default/ui/controls/DemoBindsViewController.json new file mode 100644 index 000000000..363e6da73 --- /dev/null +++ b/src/cgame/default/ui/controls/DemoBindsViewController.json @@ -0,0 +1,154 @@ +{ + "identifier": "Demo", + "classNames": [ + "demoBinds" + ], + "subviews": [ + { + "class": "StackView", + "classNames": [ + "container" + ], + "subviews": [ + { + "class": "StackView", + "classNames": [ + "columns", + "container" + ], + "subviews": [ + { + "class": "StackView", + "classNames": [ + "column", + "container" + ], + "subviews": [ + { + "class": "Box", + "label": { + "text": { + "text": "Camera" + } + }, + "contentView": { + "subviews": [ + { + "class": "Input", + "label": { + "text": { + "text": "Toggle free camera" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_freecam" + } + } + ] + } + }, + { + "class": "Box", + "label": { + "text": { + "text": "Playback" + } + }, + "contentView": { + "subviews": [ + { + "class": "Input", + "label": { + "text": { + "text": "Pause / resume" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_pause" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Skip back 5s" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_seek -5" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Skip forward 5s" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_seek +5" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Step back" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_step -1" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Step forward" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_step 1" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Slower" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_speed_down" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Faster" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_speed_up" + } + } + ] + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/src/cgame/default/ui/controls/Makefile.am b/src/cgame/default/ui/controls/Makefile.am index fbab1823e..b29097b1c 100644 --- a/src/cgame/default/ui/controls/Makefile.am +++ b/src/cgame/default/ui/controls/Makefile.am @@ -1,6 +1,8 @@ cguicontrolsdir = @PKGLIBDIR@/default/ui/controls cguicontrols_DATA = \ ControlsViewController.json \ + DemoBindsViewController.css \ + DemoBindsViewController.json \ MovementCombatViewController.css \ MovementCombatViewController.json \ ResponseServiceViewController.css \ @@ -9,6 +11,7 @@ cguicontrols_DATA = \ noinst_HEADERS = \ ControlsViewController.h \ CrosshairView.h \ + DemoBindsViewController.h \ MovementCombatViewController.h \ ResponseServiceViewController.h @@ -18,6 +21,7 @@ noinst_LTLIBRARIES = \ libcguicontrols_la_SOURCES = \ ControlsViewController.c \ CrosshairView.c \ + DemoBindsViewController.c \ MovementCombatViewController.c \ ResponseServiceViewController.c diff --git a/src/server/sv_admin.c b/src/server/sv_admin.c index 0106f5595..bf2cb7986 100644 --- a/src/server/sv_admin.c +++ b/src/server/sv_admin.c @@ -247,6 +247,34 @@ static void Sv_DemoSpeed_f(void) { Com_Print("Demo speed %.2fx\n", sv.demo_speed); } +/** + * @brief Adjusts the demo speed by `delta`, for bindable relative controls. + */ +static void Sv_DemoSpeedAdjust(float delta) { + + if (!Sv_DemoActive()) { + Com_Print("Not playing a seekable demo\n"); + return; + } + + sv.demo_speed = Clampf(sv.demo_speed + delta, 0.1f, 8.0f); + Com_Print("Demo speed %.2fx\n", sv.demo_speed); +} + +/** + * @brief demo_speed_up — increase demo playback speed. + */ +static void Sv_DemoSpeedUp_f(void) { + Sv_DemoSpeedAdjust(0.25f); +} + +/** + * @brief demo_speed_down — decrease demo playback speed. + */ +static void Sv_DemoSpeedDown_f(void) { + Sv_DemoSpeedAdjust(-0.25f); +} + /** * @brief demo_step [frames] — step playback by N frames (default 1) and pause. */ @@ -548,6 +576,8 @@ void Sv_InitAdmin(void) { Cmd_Add("demo_seek", Sv_DemoSeek_f, CMD_SERVER, "Seek demo playback: "); Cmd_Add("demo_pause", Sv_DemoPause_f, CMD_SERVER, "Toggle demo playback pause"); Cmd_Add("demo_speed", Sv_DemoSpeed_f, CMD_SERVER, "Get or set demo playback speed multiplier"); + Cmd_Add("demo_speed_up", Sv_DemoSpeedUp_f, CMD_SERVER, "Increase demo playback speed"); + Cmd_Add("demo_speed_down", Sv_DemoSpeedDown_f, CMD_SERVER, "Decrease demo playback speed"); Cmd_Add("demo_step", Sv_DemoStep_f, CMD_SERVER, "Step demo playback by N frames (pauses)"); cmd_t *map_cmd = Cmd_Add("map", Sv_Map_f, CMD_SERVER, "Start a server for the specified map."); From 7045ca4f1c69e6fe35600b7fa51b4734c3618c85 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 16:49:32 -0500 Subject: [PATCH 08/18] feat(demo): Demos browser in the main menu (#377) 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 ") on the Play button or a double-click, with a Refresh button. Wired into autotools (ui/play/Makefile.am) and MSBuild (cgame.vcxproj). --- Quetoo.vs15/cgame.vcxproj | 2 + .../default/ui/main/MainViewController.c | 7 + .../default/ui/play/DemosViewController.c | 224 ++++++++++++++++++ .../default/ui/play/DemosViewController.css | 7 + .../default/ui/play/DemosViewController.h | 87 +++++++ .../default/ui/play/DemosViewController.json | 40 ++++ src/cgame/default/ui/play/Makefile.am | 4 + 7 files changed, 371 insertions(+) create mode 100644 src/cgame/default/ui/play/DemosViewController.c create mode 100644 src/cgame/default/ui/play/DemosViewController.css create mode 100644 src/cgame/default/ui/play/DemosViewController.h create mode 100644 src/cgame/default/ui/play/DemosViewController.json diff --git a/Quetoo.vs15/cgame.vcxproj b/Quetoo.vs15/cgame.vcxproj index 1b2ebfa1c..09fcbb09a 100644 --- a/Quetoo.vs15/cgame.vcxproj +++ b/Quetoo.vs15/cgame.vcxproj @@ -80,6 +80,7 @@ + @@ -149,6 +150,7 @@ + diff --git a/src/cgame/default/ui/main/MainViewController.c b/src/cgame/default/ui/main/MainViewController.c index c2d1633c2..c381605be 100644 --- a/src/cgame/default/ui/main/MainViewController.c +++ b/src/cgame/default/ui/main/MainViewController.c @@ -27,6 +27,7 @@ #include "CreditsViewController.h" #include "HomeViewController.h" #include "PlayViewController.h" +#include "DemosViewController.h" #include "SettingsViewController.h" #include "TeamsViewController.h" @@ -156,6 +157,12 @@ static void loadView(ViewController *self) { .data = _PlayViewController() }); + $(this, primaryButton, "Demos", &(const ButtonDelegate) { + .didClick = didClickNavigateViewController, + .self = self, + .data = _DemosViewController() + }); + $(this, primaryButton, "Controls", &(const ButtonDelegate) { .didClick = didClickNavigateViewController, .self = self, diff --git a/src/cgame/default/ui/play/DemosViewController.c b/src/cgame/default/ui/play/DemosViewController.c new file mode 100644 index 000000000..e6d62d391 --- /dev/null +++ b/src/cgame/default/ui/play/DemosViewController.c @@ -0,0 +1,224 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "cg_local.h" + +#include "DemosViewController.h" + +#define _Class _DemosViewController + +static const char *_demo = "demo"; + +#pragma mark - Demo enumeration + +/** + * @brief Fs_Enumerator that appends each demo's base name (sans extension). + */ +static void enumerateDemos(const char *path, void *data) { + + DemosViewController *this = data; + + char *base = g_path_get_basename(path); + char *dot = strrchr(base, '.'); + if (dot) { + *dot = '\0'; + } + + this->names = g_list_append(this->names, g_strdup(base)); + g_free(base); +} + +/** + * @brief Rebuilds the demo list from the filesystem. + */ +static void loadDemos(DemosViewController *this) { + + g_list_free_full(this->names, g_free); + this->names = NULL; + this->selectedRow = -1; + + cgi.EnumerateFiles("demos/*.demo", enumerateDemos, this); + + this->names = g_list_sort(this->names, (GCompareFunc) g_strcmp0); +} + +/** + * @brief Plays the currently selected demo, if any. + */ +static void playSelected(DemosViewController *this) { + + if (this->selectedRow < 0) { + return; + } + + const char *name = g_list_nth_data(this->names, (guint) this->selectedRow); + if (name) { + cgi.Cbuf(va("demo \"%s\"\n", name)); + } +} + +#pragma mark - TableView dataSource / delegate + +static size_t numberOfRows(const TableView *tableView) { + const DemosViewController *this = tableView->dataSource.self; + return g_list_length(this->names); +} + +static TableCellView *cellForColumnAndRow(const TableView *tableView, const TableColumn *column, size_t row) { + + const DemosViewController *this = tableView->dataSource.self; + const char *name = g_list_nth_data(this->names, (guint) row); + + TableCellView *cell = $(alloc(TableCellView), initWithFrame, NULL); + $(cell->text, setText, name); + + return cell; +} + +static void didSelectRowsAtIndexes(TableView *tableView, const IndexSet *indexes) { + + DemosViewController *this = tableView->delegate.self; + + this->selectedRow = indexes->count ? (int32_t) indexes->indexes[0] : -1; + + // double-click to play (mirrors JoinServerViewController) + const View *view = (View *) tableView; + const SDL_PropertiesID props = SDL_GetWindowProperties(view->window); + const SDL_Event *event = SDL_GetPointerProperty(props, "event", NULL); + if (event && event->button.clicks == 2) { + playSelected(this); + } +} + +#pragma mark - Button delegates + +static void didClickPlay(Button *button) { + playSelected((DemosViewController *) button->delegate.self); +} + +static void didClickRefresh(Button *button) { + + DemosViewController *this = button->delegate.self; + + loadDemos(this); + $(this->demos, reloadData); +} + +#pragma mark - ViewController + +/** + * @see ViewController::loadView(ViewController *) + */ +static void loadView(ViewController *self) { + + super(ViewController, self, loadView); + + DemosViewController *this = (DemosViewController *) self; + + Button *play, *refresh; + Outlet outlets[] = MakeOutlets( + MakeOutlet("demos", &this->demos), + MakeOutlet("play", &play), + MakeOutlet("refresh", &refresh) + ); + + $(self->view, awakeWithResourceName, "ui/play/DemosViewController.json"); + $(self->view, resolve, outlets); + + self->view->stylesheet = $$(Stylesheet, stylesheetWithResourceName, "ui/play/DemosViewController.css"); + assert(self->view->stylesheet); + + $(this->demos, addColumnWithIdentifier, _demo); + + this->demos->dataSource.numberOfRows = numberOfRows; + this->demos->dataSource.self = this; + + this->demos->delegate.cellForColumnAndRow = cellForColumnAndRow; + this->demos->delegate.didSelectRowsAtIndexes = didSelectRowsAtIndexes; + this->demos->delegate.self = this; + + play->delegate.didClick = didClickPlay; + play->delegate.self = this; + + refresh->delegate.didClick = didClickRefresh; + refresh->delegate.self = this; +} + +/** + * @see ViewController::viewWillAppear(ViewController *) + */ +static void viewWillAppear(ViewController *self) { + + super(ViewController, self, viewWillAppear); + + DemosViewController *this = (DemosViewController *) self; + + loadDemos(this); + $(this->demos, reloadData); +} + +#pragma mark - Object + +static void dealloc(Object *self) { + + DemosViewController *this = (DemosViewController *) self; + + g_list_free_full(this->names, g_free); + + super(Object, self, dealloc); +} + +#pragma mark - Class lifecycle + +/** + * @see Class::initialize(Class *) + */ +static void initialize(Class *clazz) { + + ((ObjectInterface *) clazz->interface)->dealloc = dealloc; + + ((ViewControllerInterface *) clazz->interface)->loadView = loadView; + ((ViewControllerInterface *) clazz->interface)->viewWillAppear = viewWillAppear; +} + +/** + * @fn Class *DemosViewController::_DemosViewController(void) + * @memberof DemosViewController + */ +Class *_DemosViewController(void) { + static Class *clazz; + static Once once; + + do_once(&once, { + clazz = _initialize(&(const ClassDef) { + .name = "DemosViewController", + .superclass = _ViewController(), + .instanceSize = sizeof(DemosViewController), + .interfaceOffset = offsetof(DemosViewController, interface), + .interfaceSize = sizeof(DemosViewControllerInterface), + .initialize = initialize, + }); + }); + + return clazz; +} + +#undef _Class diff --git a/src/cgame/default/ui/play/DemosViewController.css b/src/cgame/default/ui/play/DemosViewController.css new file mode 100644 index 000000000..2f3ca7c87 --- /dev/null +++ b/src/cgame/default/ui/play/DemosViewController.css @@ -0,0 +1,7 @@ +.Demos { + autoresizing-mask: fill; +} + +.Demos TableView { + autoresizing-mask: fill; +} diff --git a/src/cgame/default/ui/play/DemosViewController.h b/src/cgame/default/ui/play/DemosViewController.h new file mode 100644 index 000000000..0143b03b1 --- /dev/null +++ b/src/cgame/default/ui/play/DemosViewController.h @@ -0,0 +1,87 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include "cg_types.h" + +#include +#include + +/** + * @file + * @brief Demo browser ViewController: lists recorded demos and plays them. + */ + +typedef struct DemosViewController DemosViewController; +typedef struct DemosViewControllerInterface DemosViewControllerInterface; + +/** + * @brief The DemosViewController type. + * @extends ViewController + */ +struct DemosViewController { + + /** + * @brief The superclass. + */ + ViewController viewController; + + /** + * @brief The interface. + * @protected + */ + DemosViewControllerInterface *interface; + + /** + * @brief The demos table. + */ + TableView *demos; + + /** + * @brief The recorded demo names (strdup'd), one per row. + */ + GList *names; + + /** + * @brief The selected row, or -1 if none. + */ + int32_t selectedRow; +}; + +/** + * @brief The DemosViewController interface. + */ +struct DemosViewControllerInterface { + + /** + * @brief The superclass interface. + */ + ViewControllerInterface viewControllerInterface; +}; + +/** + * @fn Class *DemosViewController::_DemosViewController(void) + * @brief The DemosViewController archetype. + * @return The DemosViewController Class. + * @memberof DemosViewController + */ +CGAME_EXPORT Class *_DemosViewController(void); diff --git a/src/cgame/default/ui/play/DemosViewController.json b/src/cgame/default/ui/play/DemosViewController.json new file mode 100644 index 000000000..3d6892375 --- /dev/null +++ b/src/cgame/default/ui/play/DemosViewController.json @@ -0,0 +1,40 @@ +{ + "identifier": "Demos", + "subviews": [ + { + "class": "StackView", + "classNames": [ + "container" + ], + "subviews": [ + { + "class": "TableView", + "identifier": "demos" + }, + { + "class": "StackView", + "classNames": [ + "accessoryView", + "container" + ], + "subviews": [ + { + "class": "Button", + "identifier": "refresh", + "title": { + "text": "Refresh" + } + }, + { + "class": "Button", + "identifier": "play", + "title": { + "text": "Play" + } + } + ] + } + ] + } + ] +} diff --git a/src/cgame/default/ui/play/Makefile.am b/src/cgame/default/ui/play/Makefile.am index 452146e4d..53edab255 100644 --- a/src/cgame/default/ui/play/Makefile.am +++ b/src/cgame/default/ui/play/Makefile.am @@ -2,6 +2,8 @@ cguiplaydir = @PKGLIBDIR@/default/ui/play cguiplay_DATA = \ CreateServerViewController.css \ CreateServerViewController.json \ + DemosViewController.css \ + DemosViewController.json \ JoinServerViewController.css \ JoinServerViewController.json \ PlayerSetupViewController.css \ @@ -10,6 +12,7 @@ cguiplay_DATA = \ noinst_HEADERS = \ CreateServerViewController.h \ + DemosViewController.h \ JoinServerViewController.h \ MapListCollectionItemView.h \ MapListCollectionView.h \ @@ -22,6 +25,7 @@ noinst_LTLIBRARIES = \ libcguiplay_la_SOURCES = \ CreateServerViewController.c \ + DemosViewController.c \ JoinServerViewController.c \ MapListCollectionItemView.c \ MapListCollectionView.c \ From 5e80bb2519126ce747cb50127d9fee5a4c5371ec Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:20:44 -0500 Subject: [PATCH 09/18] build(demo): add demo.{c,h} to libcommon MSBuild project (#377) 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. --- Quetoo.vs15/libs/libcommon.vcxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Quetoo.vs15/libs/libcommon.vcxproj b/Quetoo.vs15/libs/libcommon.vcxproj index 64d7636c4..424865439 100644 --- a/Quetoo.vs15/libs/libcommon.vcxproj +++ b/Quetoo.vs15/libs/libcommon.vcxproj @@ -24,6 +24,7 @@ + @@ -39,6 +40,7 @@ + From e48c0645192a8988e2f9443f2690fdc2b48c1a2f Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:35:45 -0500 Subject: [PATCH 10/18] feat(demo): serverrecord - omniscient server-side demo recording (#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). --- Quetoo.vs15/libs/libserver.vcxproj | 2 + src/server/Makefile.am | 2 + src/server/server.h | 1 + src/server/sv_admin.c | 3 + src/server/sv_demo.c | 285 +++++++++++++++++++++++++++++ src/server/sv_demo.h | 31 ++++ src/server/sv_entity.c | 4 +- src/server/sv_entity.h | 2 + src/server/sv_init.c | 2 + src/server/sv_main.c | 3 + src/server/sv_types.h | 20 ++ 11 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/server/sv_demo.c create mode 100644 src/server/sv_demo.h diff --git a/Quetoo.vs15/libs/libserver.vcxproj b/Quetoo.vs15/libs/libserver.vcxproj index d8ad7455f..0bff9e325 100644 --- a/Quetoo.vs15/libs/libserver.vcxproj +++ b/Quetoo.vs15/libs/libserver.vcxproj @@ -23,6 +23,7 @@ + @@ -40,6 +41,7 @@ + diff --git a/src/server/Makefile.am b/src/server/Makefile.am index 00e3fd2a0..d6eaa7d46 100644 --- a/src/server/Makefile.am +++ b/src/server/Makefile.am @@ -3,6 +3,7 @@ noinst_HEADERS = \ sv_admin.h \ sv_client.h \ sv_console.h \ + sv_demo.h \ sv_editor.h \ sv_entity.h \ sv_game.h \ @@ -23,6 +24,7 @@ libserver_la_SOURCES = \ sv_admin.c \ sv_client.c \ sv_console.c \ + sv_demo.c \ sv_editor.c \ sv_entity.c \ sv_game.c \ diff --git a/src/server/server.h b/src/server/server.h index 096e6c9db..54bdf5e74 100644 --- a/src/server/server.h +++ b/src/server/server.h @@ -29,6 +29,7 @@ #include "sv_admin.h" #include "sv_console.h" #include "sv_client.h" +#include "sv_demo.h" #include "sv_editor.h" #include "sv_entity.h" #include "sv_game.h" diff --git a/src/server/sv_admin.c b/src/server/sv_admin.c index bf2cb7986..8a154bf45 100644 --- a/src/server/sv_admin.c +++ b/src/server/sv_admin.c @@ -580,6 +580,9 @@ void Sv_InitAdmin(void) { Cmd_Add("demo_speed_down", Sv_DemoSpeedDown_f, CMD_SERVER, "Decrease demo playback speed"); Cmd_Add("demo_step", Sv_DemoStep_f, CMD_SERVER, "Step demo playback by N frames (pauses)"); + Cmd_Add("sv_record", Sv_Record_f, CMD_SERVER, "Record an omniscient server demo: sv_record [name]"); + Cmd_Add("sv_stoprecord", Sv_StopRecord_f, CMD_SERVER, "Stop recording a server demo"); + cmd_t *map_cmd = Cmd_Add("map", Sv_Map_f, CMD_SERVER, "Start a server for the specified map."); Cmd_SetAutocomplete(map_cmd, Sv_Map_Autocomplete_f); diff --git a/src/server/sv_demo.c b/src/server/sv_demo.c new file mode 100644 index 000000000..576d42d4d --- /dev/null +++ b/src/server/sv_demo.c @@ -0,0 +1,285 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#include "sv_local.h" +#include "common/demo.h" + +/** + * @brief Server-side demo recording (serverrecord). The server captures an + * omniscient snapshot each frame -- every visible entity, with no per-client + * PVS limiting -- and writes it as a v2 demo that plays back through the same + * server-hosted path as a client demo. The default POV follows the first + * in-game client; the viewer can detach the free camera at any time. + */ + +/** + * @brief Ping-pong omniscient frame buffers for delta encoding the recording. + * Held here rather than in sv_server_t because sv_client_frame_t is defined + * after sv_server_t in sv_types.h. + */ +static sv_client_frame_t sv_record_frames[2]; + +/** + * @brief Index of the current record frame buffer (0 or 1). + */ +static int32_t sv_record_frame; + +/** + * @brief Flushes an accumulated startup sub-message as a RELIABLE record at + * timecode 0, mirroring the client recorder's epoch. + */ +static void Sv_FlushRecordHeader(mem_buf_t *msg) { + + if (msg->size == 0) { + return; + } + + Demo_WriteRecord(sv.record_file, DEMO_RECORD_RELIABLE, 0, msg->data, msg->size); + msg->size = 0; +} + +/** + * @brief Writes the v2 header and epoch (server data, config strings, entity + * baselines) so the demo is self-contained. + */ +static void Sv_WriteRecordEpoch(void) { + static entity_state_t null_state; + mem_buf_t msg; + byte buffer[MAX_MSG_SIZE]; + + const demo_header_t header = { + .version = DEMO_FORMAT_VERSION, + .flags = DEMO_FLAG_SERVER, + .protocol_major = PROTOCOL_MAJOR, + .protocol_minor = (uint16_t) svs.game->protocol, + .tick_rate = QUETOO_TICK_RATE, + .epoch_len = 0, + }; + Demo_WriteHeader(sv.record_file, &header, NULL, 0); + + Mem_InitBuffer(&msg, buffer, sizeof(buffer)); + + Net_WriteByte(&msg, SV_CMD_SERVER_DATA); + Net_WriteLong(&msg, PROTOCOL_MAJOR); + Net_WriteLong(&msg, svs.game->protocol); + Net_WriteByte(&msg, 1); // demo_server byte + Net_WriteString(&msg, Cvar_GetString("game")); + Net_WriteString(&msg, sv.config_strings[CS_MESSAGE]); + + for (int32_t i = 0; i < MAX_CONFIG_STRINGS; i++) { + if (sv.config_strings[i][0] != '\0') { + if (msg.size + strlen(sv.config_strings[i]) + 32 > msg.max_size) { + Sv_FlushRecordHeader(&msg); + } + Net_WriteByte(&msg, SV_CMD_CONFIG_STRING); + Net_WriteShort(&msg, i); + Net_WriteString(&msg, sv.config_strings[i]); + } + } + + for (int32_t i = 0; i < sv_max_entities->integer; i++) { + const entity_state_t *baseline = &sv.entities[i].baseline; + if (i != 0 && !baseline->number) { // entity 0 is worldspawn; never skip it + continue; + } + if (msg.size + 64 > msg.max_size) { + Sv_FlushRecordHeader(&msg); + } + Net_WriteByte(&msg, SV_CMD_BASELINE); + Net_WriteDeltaEntity(&msg, &null_state, baseline, true); + } + + Net_WriteByte(&msg, SV_CMD_CBUF_TEXT); + Net_WriteString(&msg, "precache 0\n"); + + Sv_FlushRecordHeader(&msg); +} + +/** + * @brief Builds an omniscient snapshot of all visible entities and a default + * player state (the first in-game client, else a frozen spectator). + */ +static void Sv_BuildRecordFrame(sv_client_frame_t *frame) { + + frame->entity_state = svs.next_entity_state; + frame->num_entities = 0; + + // default POV: the first active, non-bot client; otherwise a frozen spectator + const sv_client_t *cl = svs.clients; + const g_client_t *pov = NULL; + for (int32_t i = 0; i < sv_max_clients->integer; i++, cl++) { + if (cl->state == SV_CLIENT_ACTIVE && cl->gclient->in_use && !cl->gclient->ai) { + pov = cl->gclient; + break; + } + } + + if (pov) { + frame->ps = pov->ps; + } else { + memset(&frame->ps, 0, sizeof(frame->ps)); + frame->ps.pm_state.type = PM_FREEZE; + } + + // every visible entity, with no per-client PVS limiting + for (int32_t i = 0; i < sv_max_entities->integer; i++) { + + const g_entity_t *ent = sv.entities[i].gent; + + if (!ent->in_use) { + continue; + } + + if (!ent->s.event && !ent->s.effects && !ent->s.trail && !ent->s.model1 && !ent->s.sound) { + continue; + } + + entity_state_t *s = &svs.entity_states[svs.next_entity_state % svs.num_entity_states]; + *s = ent->s; + + svs.next_entity_state++; + frame->num_entities++; + } +} + +/** + * @brief Builds and writes one recorded frame as a v2 FRAME record. + */ +static void Sv_WriteRecordFrame(uint32_t timecode, bool keyframe) { + + sv_client_frame_t *frame = &sv_record_frames[sv_record_frame]; + sv_client_frame_t *delta = keyframe ? NULL : &sv_record_frames[1 - sv_record_frame]; + + Sv_BuildRecordFrame(frame); + + mem_buf_t msg; + byte buffer[MAX_MSG_SIZE]; + Mem_InitBuffer(&msg, buffer, sizeof(buffer)); + msg.allow_overflow = true; + + Net_WriteByte(&msg, SV_CMD_FRAME); + Net_WriteLong(&msg, sv.frame_num); + Net_WriteLong(&msg, keyframe ? -1 : sv.record_last_frame); + + Sv_WritePlayerState(delta, frame, &msg); + Sv_WriteEntities(delta, frame, &msg); + + if (!msg.overflowed) { + Demo_WriteRecord(sv.record_file, keyframe ? DEMO_RECORD_FRAME_KEY : DEMO_RECORD_FRAME_DELTA, + timecode, msg.data, msg.size); + } + + sv.record_last_frame = sv.frame_num; + sv_record_frame = 1 - sv_record_frame; +} + +/** + * @brief Records one frame, if a server demo is being recorded. Called each + * server tick after the game frame has run. + */ +void Sv_DemoRecordFrame(void) { + + if (!sv.recording || svs.state != SV_ACTIVE_GAME) { + return; + } + + if (sv.record_first_frame < 0) { + sv.record_first_frame = sv.frame_num; + } + + const int32_t rel_frame = sv.frame_num - sv.record_first_frame; + const uint32_t timecode = (uint32_t) rel_frame * QUETOO_TICK_MILLIS; + const bool keyframe = (rel_frame == 0) || (rel_frame % DEMO_KEYFRAME_INTERVAL) == 0; + + Sv_WriteRecordFrame(timecode, keyframe); +} + +/** + * @brief Stops any in-progress server recording and closes the file. + */ +void Sv_StopServerRecord(void) { + + if (!sv.recording) { + return; + } + + if (sv.record_file) { + Fs_Close(sv.record_file); + sv.record_file = NULL; + } + + sv.recording = false; +} + +/** + * @brief sv_record [name] — begins recording an omniscient server demo. + */ +void Sv_Record_f(void) { + + if (svs.state != SV_ACTIVE_GAME) { + Com_Print("You must be running a game to record\n"); + return; + } + + if (sv.recording) { + Com_Print("Already recording\n"); + return; + } + + char name[MAX_QPATH]; + if (Cmd_Argc() == 2) { + g_snprintf(name, sizeof(name), "demos/%s.demo", Cmd_Argv(1)); + } else { + const time_t t = time(NULL); + const struct tm *tm = localtime(&t); + char datestamp[32]; + strftime(datestamp, sizeof(datestamp), "%Y-%m-%d-%H-%M-%S", tm); + g_snprintf(name, sizeof(name), "demos/server-%s.demo", datestamp); + } + + if (!(sv.record_file = Fs_OpenWrite(name))) { + Com_Warn("Couldn't open %s\n", name); + return; + } + + sv.recording = true; + sv.record_first_frame = -1; + sv.record_last_frame = -1; + sv_record_frame = 0; + + Sv_WriteRecordEpoch(); + + Com_Print("Recording server demo to %s\n", name); +} + +/** + * @brief sv_stoprecord — stops recording a server demo. + */ +void Sv_StopRecord_f(void) { + + if (!sv.recording) { + Com_Print("Not recording a server demo\n"); + return; + } + + Sv_StopServerRecord(); + Com_Print("Stopped server demo\n"); +} diff --git a/src/server/sv_demo.h b/src/server/sv_demo.h new file mode 100644 index 000000000..77c7c561a --- /dev/null +++ b/src/server/sv_demo.h @@ -0,0 +1,31 @@ +/* + * Copyright(c) 1997-2001 id Software, Inc. + * Copyright(c) 2002 The Quakeforge Project. + * Copyright(c) 2006 Quetoo. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include "sv_types.h" + +#if defined(__SV_LOCAL_H__) +void Sv_Record_f(void); +void Sv_StopRecord_f(void); +void Sv_DemoRecordFrame(void); +void Sv_StopServerRecord(void); +#endif /* __SV_LOCAL_H__ */ diff --git a/src/server/sv_entity.c b/src/server/sv_entity.c index a489d5fbc..6c94d4e00 100644 --- a/src/server/sv_entity.c +++ b/src/server/sv_entity.c @@ -24,7 +24,7 @@ /** * @brief Writes a delta update of an `entity_state_t` list to the message. */ -static void Sv_WriteEntities(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg) { +void Sv_WriteEntities(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg) { entity_state_t *old_state = NULL, *new_state = NULL; int32_t old_index, new_index; int16_t old_num, new_num; @@ -92,7 +92,7 @@ static void Sv_WriteEntities(sv_client_frame_t *from, sv_client_frame_t *to, mem /** * @brief Writes a delta-compressed player state to the message buffer. */ -static void Sv_WritePlayerState(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg) { +void Sv_WritePlayerState(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg) { static player_state_t null_state; if (from) { diff --git a/src/server/sv_entity.h b/src/server/sv_entity.h index 5b9b7dec4..e99d9523e 100644 --- a/src/server/sv_entity.h +++ b/src/server/sv_entity.h @@ -26,4 +26,6 @@ #if defined(__SV_LOCAL_H__) void Sv_WriteClientFrame(sv_client_t *client, mem_buf_t *msg); void Sv_BuildClientFrame(sv_client_t *client); +void Sv_WriteEntities(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg); +void Sv_WritePlayerState(sv_client_frame_t *from, sv_client_frame_t *to, mem_buf_t *msg); #endif /* __SV_LOCAL_H__ */ diff --git a/src/server/sv_init.c b/src/server/sv_init.c index 0c4188e75..39754730e 100644 --- a/src/server/sv_init.c +++ b/src/server/sv_init.c @@ -146,6 +146,8 @@ static void Sv_ClearState(void) { return; } + Sv_StopServerRecord(); + if (sv.demo_file) { Fs_Close(sv.demo_file); } diff --git a/src/server/sv_main.c b/src/server/sv_main.c index d8e0cfabf..425f775a0 100644 --- a/src/server/sv_main.c +++ b/src/server/sv_main.c @@ -872,6 +872,9 @@ void Sv_Frame(const uint32_t msec) { // send the resulting frame to connected clients Sv_SendClientPackets(); + // capture an omniscient snapshot for serverrecord, if active + Sv_DemoRecordFrame(); + // decrement the simulation time frame_delta -= QUETOO_TICK_MILLIS; ticks_run++; diff --git a/src/server/sv_types.h b/src/server/sv_types.h index d63bdbd68..1c32f8832 100644 --- a/src/server/sv_types.h +++ b/src/server/sv_types.h @@ -146,6 +146,26 @@ typedef struct { * time_scale, so the (real-time) free camera stays smooth at any speed. */ float demo_speed; + + /** + * @brief Open file for server-side demo recording (serverrecord), or NULL. + */ + file_t *record_file; + + /** + * @brief True while recording a server-side demo. + */ + bool recording; + + /** + * @brief Frame number of the first recorded frame, or -1. + */ + int32_t record_first_frame; + + /** + * @brief Frame number of the most recently recorded frame. + */ + int32_t record_last_frame; } sv_server_t; /** From 3ec1763521cbeba67d680b2ef5f83e2b40261852 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:41:31 -0500 Subject: [PATCH 11/18] feat(demo): follow/cycle any player's POV during playback (#377) 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). --- src/cgame/default/cg_demo.c | 148 ++++++++++++++++-- src/cgame/default/cg_demo.h | 3 +- src/cgame/default/cg_entity.c | 2 +- src/cgame/default/cg_view.c | 6 +- .../ui/controls/DemoBindsViewController.json | 24 +++ 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/cgame/default/cg_demo.c b/src/cgame/default/cg_demo.c index 01b0547c0..aa918aa05 100644 --- a/src/cgame/default/cg_demo.c +++ b/src/cgame/default/cg_demo.c @@ -25,20 +25,37 @@ cvar_t *cg_demo_freecam; /** - * @brief Free demo camera state. While active, the camera detaches from the - * recorded player and flies under local input as a PM_SPECTATOR. The recorded - * player remains frozen (see Cl_ParsePlayerState); only the view is overridden. + * @brief Demo camera modes. + */ +typedef enum { + CG_DEMO_CAM_LOCKED, // the recorded player's eye (default) + CG_DEMO_CAM_FREE, // free-fly spectator under local input + CG_DEMO_CAM_FOLLOW, // chase camera following a player entity +} cg_demo_cam_t; + +/** + * @brief Demo camera state. While not LOCKED, the camera detaches from the + * recorded player and only the view is overridden; the recorded player stays + * frozen (see Cl_ParsePlayerState). */ static struct { - bool active; - pm_state_t s; + cg_demo_cam_t mode; + pm_state_t s; // free-fly spectator state (CG_DEMO_CAM_FREE) + int32_t follow; // followed entity number (CG_DEMO_CAM_FOLLOW) } cg_demo; /** * @return True if a demo is playing and the free camera is active. */ bool Cg_DemoInFreeCamera(void) { - return cgi.client->demo_server && cg_demo_freecam->value && cg_demo.active; + return cgi.client->demo_server && cg_demo.mode == CG_DEMO_CAM_FREE; +} + +/** + * @return True if the demo camera is overriding the view (free or follow). + */ +bool Cg_DemoOverridingView(void) { + return cgi.client->demo_server && cg_demo.mode != CG_DEMO_CAM_LOCKED; } /** @@ -52,18 +69,77 @@ static void Cg_DemoFreecam_f(void) { return; } - cg_demo.active = !cg_demo.active; - - if (cg_demo.active) { + if (cg_demo.mode == CG_DEMO_CAM_FREE) { + cg_demo.mode = CG_DEMO_CAM_LOCKED; + cgi.Print("Demo free camera disabled\n"); + } else { + cg_demo.mode = CG_DEMO_CAM_FREE; cg_demo.s = (pm_state_t) { .type = PM_SPECTATOR, .origin = cgi.view->origin, }; cgi.client->angles = cgi.view->angles; cgi.Print("Demo free camera enabled\n"); + } +} + +/** + * @brief Cycles the follow camera through the player entities present in the + * current frame. Works on any demo (the players' entities are always present). + */ +static void Cg_DemoFollow(int32_t dir) { + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + const cl_frame_t *frame = &cgi.client->frame; + + int32_t players[MAX_CLIENTS]; + int32_t count = 0; + + for (int32_t i = 0; i < frame->num_entities && count < MAX_CLIENTS; i++) { + const uint32_t snum = (frame->entity_state + i) & ENTITY_STATE_MASK; + const entity_state_t *s = &cgi.client->entity_states[snum]; + if (s->effects & EF_CLIENT) { + players[count++] = s->number; + } + } + + if (count == 0) { + cgi.Print("No players to follow\n"); + return; + } + + int32_t index = -1; + if (cg_demo.mode == CG_DEMO_CAM_FOLLOW) { + for (int32_t i = 0; i < count; i++) { + if (players[i] == cg_demo.follow) { + index = i; + break; + } + } + } + + if (index < 0) { + index = (dir > 0) ? 0 : count - 1; } else { - cgi.Print("Demo free camera disabled\n"); + index = (index + dir + count) % count; } + + cg_demo.follow = players[index]; + cg_demo.mode = CG_DEMO_CAM_FOLLOW; + + cgi.Print("Following player %d\n", cg_demo.follow); +} + +static void Cg_DemoFollowNext_f(void) { + Cg_DemoFollow(1); +} + +static void Cg_DemoFollowPrev_f(void) { + Cg_DemoFollow(-1); } /** @@ -75,8 +151,8 @@ static cm_trace_t Cg_DemoCamera_Trace(const vec3_t start, const vec3_t end, cons /** * @brief Integrates the free camera from the pending user commands as a no-clip - * spectator. Because the command msec reflects real frame time, the camera flies - * at a constant real-time speed regardless of the demo playback speed. + * spectator. The command msec is real frame time, so the camera flies at a + * constant real-time speed regardless of the demo playback speed. */ void Cg_PredictDemoCamera(const GPtrArray *cmds) { @@ -103,10 +179,9 @@ void Cg_PredictDemoCamera(const GPtrArray *cmds) { } /** - * @brief Writes the free camera origin and angles into the view, replacing the - * recorded player's eye for this frame. + * @brief Writes the free camera origin and angles into the view. */ -void Cg_UpdateDemoCamera(void) { +static void Cg_UpdateDemoFreeCamera(void) { cgi.view->origin = Vec3_Add(cg_demo.s.origin, cg_demo.s.view_offset); cgi.view->angles = cgi.client->angles; @@ -115,7 +190,42 @@ void Cg_UpdateDemoCamera(void) { } /** - * @brief Registers the demo camera cvar and command. + * @brief Positions the camera as a third-person chase of the followed player. + */ +static void Cg_UpdateDemoFollowCamera(void) { + + const cl_entity_t *ent = &cgi.client->entities[cg_demo.follow]; + const vec3_t target = ent->origin; + + vec3_t forward, right, up; + Vec3_Vectors(Vec3(0.f, ent->angles.y, 0.f), &forward, &right, &up); + + vec3_t desired = Vec3_Fmaf(target, -120.f, forward); + desired.z += 50.f; + + const cm_trace_t tr = cgi.Trace(target, desired, Box3f(8.f, 8.f, 8.f), NULL, CONTENTS_MASK_CLIP_PLAYER); + cgi.view->origin = tr.end; + + const vec3_t dir = Vec3_Subtract(target, cgi.view->origin); + cgi.view->angles = Vec3_Euler(Vec3_Normalize(dir)); + + Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); +} + +/** + * @brief Writes the demo camera into the view, dispatching by mode. + */ +void Cg_UpdateDemoView(void) { + + if (cg_demo.mode == CG_DEMO_CAM_FREE) { + Cg_UpdateDemoFreeCamera(); + } else if (cg_demo.mode == CG_DEMO_CAM_FOLLOW) { + Cg_UpdateDemoFollowCamera(); + } +} + +/** + * @brief Registers the demo camera cvar and commands. */ void Cg_InitDemo(void) { @@ -124,4 +234,10 @@ void Cg_InitDemo(void) { cgi.AddCmd("demo_freecam", Cg_DemoFreecam_f, CMD_CGAME, "Toggle the free-fly camera while watching a demo."); + + cgi.AddCmd("demo_follow_next", Cg_DemoFollowNext_f, CMD_CGAME, + "Follow the next player while watching a demo."); + + cgi.AddCmd("demo_follow_prev", Cg_DemoFollowPrev_f, CMD_CGAME, + "Follow the previous player while watching a demo."); } diff --git a/src/cgame/default/cg_demo.h b/src/cgame/default/cg_demo.h index 7137e028b..14c2c6f8a 100644 --- a/src/cgame/default/cg_demo.h +++ b/src/cgame/default/cg_demo.h @@ -26,8 +26,9 @@ extern cvar_t *cg_demo_freecam; bool Cg_DemoInFreeCamera(void); +bool Cg_DemoOverridingView(void); void Cg_InitDemo(void); void Cg_PredictDemoCamera(const GPtrArray *cmds); -void Cg_UpdateDemoCamera(void); +void Cg_UpdateDemoView(void); #endif /* __CG_LOCAL_H__ */ diff --git a/src/cgame/default/cg_entity.c b/src/cgame/default/cg_entity.c index bffc79041..393524486 100644 --- a/src/cgame/default/cg_entity.c +++ b/src/cgame/default/cg_entity.c @@ -275,7 +275,7 @@ static void Cg_AddEntity(cl_entity_t *ent) { Cg_AddClientEntity(ent, &e); // add our view weapon, if it's our view entity and we're in first-person - if (ent == Cg_Self() && !cgi.client->third_person && !Cg_DemoInFreeCamera()) { + if (ent == Cg_Self() && !cgi.client->third_person && !Cg_DemoOverridingView()) { Cg_AddWeapon(ent, &e); } diff --git a/src/cgame/default/cg_view.c b/src/cgame/default/cg_view.c index c4f2af661..876a3a53c 100644 --- a/src/cgame/default/cg_view.c +++ b/src/cgame/default/cg_view.c @@ -337,8 +337,8 @@ void Cg_PrepareView(const cl_frame_t *frame) { const player_state_t *ps1 = &frame->ps; - if (Cg_DemoInFreeCamera()) { - Cg_UpdateDemoCamera(); + if (Cg_DemoOverridingView()) { + Cg_UpdateDemoView(); } else { Cg_UpdateOrigin(ps0, ps1); @@ -349,7 +349,7 @@ void Cg_PrepareView(const cl_frame_t *frame) { Cg_UpdateFov(); - if (!Cg_DemoInFreeCamera()) { + if (!Cg_DemoOverridingView()) { Cg_UpdateBob(ps1); } diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.json b/src/cgame/default/ui/controls/DemoBindsViewController.json index 363e6da73..585da36c6 100644 --- a/src/cgame/default/ui/controls/DemoBindsViewController.json +++ b/src/cgame/default/ui/controls/DemoBindsViewController.json @@ -44,6 +44,30 @@ "class": "BindTextView", "bind": "demo_freecam" } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Follow next player" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_follow_next" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Follow previous player" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_follow_prev" + } } ] } From 0607e8f131d0009978d8d22b7d135b84c73b2b23 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 18:08:51 -0500 Subject: [PATCH 12/18] feat(demo): cinematic keyframed camera paths (#377) 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. --- src/cgame/default/cg_demo.c | 222 ++++++++++++++++++ .../ui/controls/DemoBindsViewController.json | 24 ++ 2 files changed, 246 insertions(+) diff --git a/src/cgame/default/cg_demo.c b/src/cgame/default/cg_demo.c index aa918aa05..561a14c17 100644 --- a/src/cgame/default/cg_demo.c +++ b/src/cgame/default/cg_demo.c @@ -31,8 +31,23 @@ typedef enum { CG_DEMO_CAM_LOCKED, // the recorded player's eye (default) CG_DEMO_CAM_FREE, // free-fly spectator under local input CG_DEMO_CAM_FOLLOW, // chase camera following a player entity + CG_DEMO_CAM_PATH, // cinematic keyframed dolly path } cg_demo_cam_t; +/** + * @brief A cinematic camera path keyframe. + */ +typedef struct { + uint32_t time; // demo frame time (frame_num * QUETOO_TICK_MILLIS) + vec3_t origin; + vec3_t angles; +} cg_demo_key_t; + +#define CG_DEMO_MAX_CAM_KEYS 256 + +static cg_demo_key_t cg_demo_cam_keys[CG_DEMO_MAX_CAM_KEYS]; +static int32_t cg_demo_cam_count; + /** * @brief Demo camera state. While not LOCKED, the camera detaches from the * recorded player and only the view is overridden; the recorded player stays @@ -212,6 +227,200 @@ static void Cg_UpdateDemoFollowCamera(void) { Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); } +#pragma mark - Camera paths + +#define CG_DEMO_CAM_PATH_FILE "demos/cinematic.cam" + +/** + * @brief Catmull-Rom interpolation of four control points at fraction t [0,1]. + */ +static vec3_t Cg_DemoCatmullRom(const vec3_t p0, const vec3_t p1, const vec3_t p2, const vec3_t p3, float t) { + + const float t2 = t * t; + const float t3 = t2 * t; + + // c = 2*p0 - 5*p1 + 4*p2 - p3 + vec3_t c = Vec3_Scale(p0, 2.f); + c = Vec3_Fmaf(c, -5.f, p1); + c = Vec3_Fmaf(c, 4.f, p2); + c = Vec3_Subtract(c, p3); + + // d = -p0 + 3*p1 - 3*p2 + p3 + vec3_t d = Vec3_Scale(p1, 3.f); + d = Vec3_Subtract(d, p0); + d = Vec3_Fmaf(d, -3.f, p2); + d = Vec3_Add(d, p3); + + // r = (2*p1 + t*(p2 - p0) + t2*c + t3*d) / 2 + vec3_t r = Vec3_Scale(p1, 2.f); + r = Vec3_Fmaf(r, t, Vec3_Subtract(p2, p0)); + r = Vec3_Fmaf(r, t2, c); + r = Vec3_Fmaf(r, t3, d); + + return Vec3_Scale(r, 0.5f); +} + +/** + * @brief demo_cam_add — captures the current view as a camera keyframe. + */ +static void Cg_DemoCamAdd_f(void) { + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + if (cg_demo_cam_count >= CG_DEMO_MAX_CAM_KEYS) { + cgi.Print("Camera path is full\n"); + return; + } + + const uint32_t t = cgi.client->frame.time; + + // keep the keyframes sorted by time + int32_t i = cg_demo_cam_count; + while (i > 0 && cg_demo_cam_keys[i - 1].time > t) { + cg_demo_cam_keys[i] = cg_demo_cam_keys[i - 1]; + i--; + } + + cg_demo_cam_keys[i].time = t; + cg_demo_cam_keys[i].origin = cgi.view->origin; + cg_demo_cam_keys[i].angles = cgi.view->angles; + cg_demo_cam_count++; + + cgi.Print("Camera keyframe %d at %.1fs\n", cg_demo_cam_count, t / 1000.0); +} + +/** + * @brief demo_cam_clear — removes all camera keyframes. + */ +static void Cg_DemoCamClear_f(void) { + + cg_demo_cam_count = 0; + + if (cg_demo.mode == CG_DEMO_CAM_PATH) { + cg_demo.mode = CG_DEMO_CAM_LOCKED; + } + + cgi.Print("Camera path cleared\n"); +} + +/** + * @brief demo_cam_play — toggles flying the cinematic camera path. + */ +static void Cg_DemoCamPlay_f(void) { + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + if (cg_demo.mode == CG_DEMO_CAM_PATH) { + cg_demo.mode = CG_DEMO_CAM_LOCKED; + cgi.Print("Camera path stopped\n"); + return; + } + + if (cg_demo_cam_count < 2) { + cgi.Print("Add at least 2 camera keyframes first (demo_cam_add)\n"); + return; + } + + cg_demo.mode = CG_DEMO_CAM_PATH; + cgi.Print("Flying camera path (%d keyframes)\n", cg_demo_cam_count); +} + +/** + * @brief demo_cam_save — writes the camera path to a sidecar file. + */ +static void Cg_DemoCamSave_f(void) { + + if (cg_demo_cam_count == 0) { + cgi.Print("No camera path to save\n"); + return; + } + + file_t *file = cgi.OpenFileWrite(CG_DEMO_CAM_PATH_FILE); + if (!file) { + cgi.Print("Couldn't write %s\n", CG_DEMO_CAM_PATH_FILE); + return; + } + + for (int32_t i = 0; i < cg_demo_cam_count; i++) { + const cg_demo_key_t *k = &cg_demo_cam_keys[i]; + const char *line = va("%u %f %f %f %f %f %f\n", k->time, + k->origin.x, k->origin.y, k->origin.z, + k->angles.x, k->angles.y, k->angles.z); + cgi.WriteFile(file, line, 1, strlen(line)); + } + + cgi.CloseFile(file); + cgi.Print("Saved %d camera keyframes to %s\n", cg_demo_cam_count, CG_DEMO_CAM_PATH_FILE); +} + +/** + * @brief demo_cam_load — reads the camera path from a sidecar file. + */ +static void Cg_DemoCamLoad_f(void) { + + file_t *file = cgi.OpenFile(CG_DEMO_CAM_PATH_FILE); + if (!file) { + cgi.Print("No saved camera path (%s)\n", CG_DEMO_CAM_PATH_FILE); + return; + } + + static char buffer[CG_DEMO_MAX_CAM_KEYS * 80]; + const int64_t len = cgi.ReadFile(file, buffer, 1, sizeof(buffer) - 1); + cgi.CloseFile(file); + + if (len <= 0) { + return; + } + buffer[len] = '\0'; + + cg_demo_cam_count = 0; + char *line = strtok(buffer, "\n"); + while (line && cg_demo_cam_count < CG_DEMO_MAX_CAM_KEYS) { + cg_demo_key_t *k = &cg_demo_cam_keys[cg_demo_cam_count]; + if (sscanf(line, "%u %f %f %f %f %f %f", &k->time, + &k->origin.x, &k->origin.y, &k->origin.z, + &k->angles.x, &k->angles.y, &k->angles.z) == 7) { + cg_demo_cam_count++; + } + line = strtok(NULL, "\n"); + } + + cgi.Print("Loaded %d camera keyframes\n", cg_demo_cam_count); +} + +/** + * @brief Drives the view along the cinematic camera path by the demo time. + */ +static void Cg_UpdateDemoPathCamera(void) { + + const uint32_t now = cgi.client->frame.time; + + // find the segment [i, i+1] containing the current time + int32_t i = 0; + while (i < cg_demo_cam_count - 2 && cg_demo_cam_keys[i + 1].time <= now) { + i++; + } + + const cg_demo_key_t *k1 = &cg_demo_cam_keys[i]; + const cg_demo_key_t *k2 = &cg_demo_cam_keys[i + 1]; + const cg_demo_key_t *k0 = &cg_demo_cam_keys[i > 0 ? i - 1 : i]; + const cg_demo_key_t *k3 = &cg_demo_cam_keys[i + 2 < cg_demo_cam_count ? i + 2 : i + 1]; + + float t = (k2->time > k1->time) ? (float) (now - k1->time) / (float) (k2->time - k1->time) : 0.f; + t = Clampf(t, 0.f, 1.f); + + cgi.view->origin = Cg_DemoCatmullRom(k0->origin, k1->origin, k2->origin, k3->origin, t); + cgi.view->angles = Vec3_MixEuler(k1->angles, k2->angles, t); + + Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); +} + /** * @brief Writes the demo camera into the view, dispatching by mode. */ @@ -221,6 +430,8 @@ void Cg_UpdateDemoView(void) { Cg_UpdateDemoFreeCamera(); } else if (cg_demo.mode == CG_DEMO_CAM_FOLLOW) { Cg_UpdateDemoFollowCamera(); + } else if (cg_demo.mode == CG_DEMO_CAM_PATH) { + Cg_UpdateDemoPathCamera(); } } @@ -240,4 +451,15 @@ void Cg_InitDemo(void) { cgi.AddCmd("demo_follow_prev", Cg_DemoFollowPrev_f, CMD_CGAME, "Follow the previous player while watching a demo."); + + cgi.AddCmd("demo_cam_add", Cg_DemoCamAdd_f, CMD_CGAME, + "Add a cinematic camera keyframe at the current view."); + cgi.AddCmd("demo_cam_clear", Cg_DemoCamClear_f, CMD_CGAME, + "Clear all cinematic camera keyframes."); + cgi.AddCmd("demo_cam_play", Cg_DemoCamPlay_f, CMD_CGAME, + "Toggle flying the cinematic camera path."); + cgi.AddCmd("demo_cam_save", Cg_DemoCamSave_f, CMD_CGAME, + "Save the cinematic camera path to " CG_DEMO_CAM_PATH_FILE "."); + cgi.AddCmd("demo_cam_load", Cg_DemoCamLoad_f, CMD_CGAME, + "Load the cinematic camera path from " CG_DEMO_CAM_PATH_FILE "."); } diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.json b/src/cgame/default/ui/controls/DemoBindsViewController.json index 585da36c6..020a574b0 100644 --- a/src/cgame/default/ui/controls/DemoBindsViewController.json +++ b/src/cgame/default/ui/controls/DemoBindsViewController.json @@ -68,6 +68,30 @@ "class": "BindTextView", "bind": "demo_follow_prev" } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Add camera keyframe" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_add" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Play camera path" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_play" + } } ] } From 85de2a21c4c62892c01eeb024b5698409ff93190 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 18:15:37 -0500 Subject: [PATCH 13/18] feat(demo): in-game timeline scrubber bar (#377) 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. --- src/cgame/default/cg_demo.c | 48 +++++++++++++++++++++++++++++++++++++ src/cgame/default/cg_demo.h | 1 + src/cgame/default/cg_main.c | 2 ++ src/game/default/g_types.h | 1 + src/server/sv_send.c | 20 ++++++++++++++++ src/shared/shared.h | 2 ++ 6 files changed, 74 insertions(+) diff --git a/src/cgame/default/cg_demo.c b/src/cgame/default/cg_demo.c index 561a14c17..a7725cea6 100644 --- a/src/cgame/default/cg_demo.c +++ b/src/cgame/default/cg_demo.c @@ -435,6 +435,54 @@ void Cg_UpdateDemoView(void) { } } +/** + * @brief Draws the demo timeline scrubber bar during playback. The demo time + * and duration come from the server via the CS_DEMO_STATUS config string. + */ +void Cg_DrawDemoBar(void) { + + if (!cgi.client->demo_server) { + return; + } + + const char *status = cgi.ConfigString(CS_DEMO_STATUS); + if (!status || !*status) { + return; // v1 demo, or status not yet received + } + + uint32_t time = 0, duration = 0; + int32_t paused = 0; + float speed = 1.f; + sscanf(status, "%u %u %d %f", &time, &duration, &paused, &speed); + + if (duration == 0) { + return; + } + + const GLint w = cgi.context->w; + const GLint h = cgi.context->h; + + const GLint barWidth = (GLint) (w * 0.6f); + const GLint barHeight = 6; + const GLint barX = (w - barWidth) / 2; + const GLint barY = h - 48; + + const float frac = Clampf((float) time / (float) duration, 0.f, 1.f); + const GLint progress = (GLint) (barWidth * frac); + + cgi.Draw2DFill(barX, barY, barWidth, barHeight, ColorHSVA(0.f, 0.f, 0.f, 0.5f)); + cgi.Draw2DFill(barX, barY, progress, barHeight, color_orange); + cgi.Draw2DFill(barX + progress - 1, barY - 4, 3, barHeight + 8, color_white); + + char text[MAX_STRING_CHARS]; + g_snprintf(text, sizeof(text), "%u:%02u / %u:%02u %.2gx%s", + time / 60000, (time / 1000) % 60, + duration / 60000, (duration / 1000) % 60, + speed, paused ? " PAUSED" : ""); + + cgi.Draw2DString(barX, barY - 22, text, color_white); +} + /** * @brief Registers the demo camera cvar and commands. */ diff --git a/src/cgame/default/cg_demo.h b/src/cgame/default/cg_demo.h index 14c2c6f8a..58393b7fd 100644 --- a/src/cgame/default/cg_demo.h +++ b/src/cgame/default/cg_demo.h @@ -27,6 +27,7 @@ extern cvar_t *cg_demo_freecam; bool Cg_DemoInFreeCamera(void); bool Cg_DemoOverridingView(void); +void Cg_DrawDemoBar(void); void Cg_InitDemo(void); void Cg_PredictDemoCamera(const GPtrArray *cmds); void Cg_UpdateDemoView(void); diff --git a/src/cgame/default/cg_main.c b/src/cgame/default/cg_main.c index 27aeafb57..bb9938eb8 100644 --- a/src/cgame/default/cg_main.c +++ b/src/cgame/default/cg_main.c @@ -454,6 +454,8 @@ static void Cg_UpdateScreen(const cl_frame_t *frame) { Cg_DrawHud(&frame->ps); Cg_DrawScores(&frame->ps); + + Cg_DrawDemoBar(); } Cg_CheckEditor(); diff --git a/src/game/default/g_types.h b/src/game/default/g_types.h index 5f2bb44f4..fb7919fc2 100644 --- a/src/game/default/g_types.h +++ b/src/game/default/g_types.h @@ -69,6 +69,7 @@ typedef enum { #define CS_MAX_CLIENTS (CS_GAME + 5) // max clients of server #define CS_NUM_CLIENTS (CS_GAME + 6) // number of players in server #define CS_NUM_TEAMS (CS_GAME + 7) // number of teams (0 - MAX_TEAMS) +// CS_GAME + 8 is reserved engine-side for CS_DEMO_STATUS (see shared.h) #define CS_NAV_EDIT (CS_GAME + 9) // nav edit mode #define CS_ITEM_SET (CS_GAME + 10) // active item set (g_items_t) diff --git a/src/server/sv_send.c b/src/server/sv_send.c index b3ba13746..180dc64b7 100644 --- a/src/server/sv_send.c +++ b/src/server/sv_send.c @@ -405,6 +405,25 @@ static bool Sv_SendDemoV2Records(sv_client_t *cl) { } } +/** + * @brief Transmits the demo playback status (time, duration, paused, speed) to + * the client as a config string, so the cgame can draw the timeline scrubber. + * Sent explicitly here because the demo path does not flush the multicast. + */ +static void Sv_SendDemoStatus(sv_client_t *cl) { + byte buffer[256]; + mem_buf_t msg; + + Mem_InitBuffer(&msg, buffer, sizeof(buffer)); + + Net_WriteByte(&msg, SV_CMD_CONFIG_STRING); + Net_WriteShort(&msg, CS_DEMO_STATUS); + Net_WriteString(&msg, va("%u %u %d %.3f", sv.demo_time, sv.demo_index.duration, + sv.demo_paused ? 1 : 0, sv.demo_speed)); + + Netchan_Transmit(&cl->net_chan, msg.data, msg.size); +} + /** * @brief Send the frame and all pending datagram messages since the last frame. */ @@ -439,6 +458,7 @@ void Sv_SendClientPackets(void) { if (!Sv_SendDemoV2Records(cl)) { break; // demo complete } + Sv_SendDemoStatus(cl); } else { byte buffer[MAX_MSG_SIZE]; size_t size; diff --git a/src/shared/shared.h b/src/shared/shared.h index 5eeb998d5..407d43ee6 100644 --- a/src/shared/shared.h +++ b/src/shared/shared.h @@ -46,6 +46,8 @@ #define CS_ENTITIES (CS_CLIENTS + MAX_CLIENTS) // for the in-game editor #define CS_GAME (CS_ENTITIES + MAX_ENTITIES) // game-module specific config strings +#define CS_DEMO_STATUS (CS_GAME + 8) // engine demo playback status: "time duration paused speed" + #define MAX_GAME_CONFIG_STRINGS 256 #define MAX_CONFIG_STRINGS (CS_GAME + MAX_GAME_CONFIG_STRINGS) From 15ef397c8ee0f71d5b0a2e5f00564447085d102d Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 18:35:13 -0500 Subject: [PATCH 14/18] fix(demo): rebase delta frames that reach past the last keyframe (#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. --- src/client/cl_demo.c | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/client/cl_demo.c b/src/client/cl_demo.c index 51f47bd9f..e585a6a8d 100644 --- a/src/client/cl_demo.c +++ b/src/client/cl_demo.c @@ -28,6 +28,7 @@ cvar_t *cl_demo_v2; static bool cl_demo_recording_v2; // true if the active recording is the v2 container static int32_t cl_demo_first_frame; // frame_num of the first recorded frame, or -1 static int32_t cl_demo_last_frame; // frame_num of the most recently recorded frame +static int32_t cl_demo_last_keyframe; // frame_num of the most recently recorded keyframe /** * @brief Flushes one accumulated startup sub-message to the demo file, as a v1 @@ -151,11 +152,78 @@ static void Cl_WriteDemoKeyframe(uint32_t timecode) { Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, kf.data, kf.size); } +/** + * @brief Delta-encodes the entities of `to` against `from`, mirroring the + * server's Sv_WriteEntities. Both entity lists are sorted by number. + */ +static void Cl_WriteDemoEntitiesDelta(const cl_frame_t *from, const cl_frame_t *to, mem_buf_t *msg) { + + int32_t old_index = 0, new_index = 0; + + while (new_index < to->num_entities || old_index < from->num_entities) { + + const entity_state_t *new_state = (new_index < to->num_entities) + ? &cl.entity_states[(to->entity_state + new_index) & ENTITY_STATE_MASK] : NULL; + const entity_state_t *old_state = (old_index < from->num_entities) + ? &cl.entity_states[(from->entity_state + old_index) & ENTITY_STATE_MASK] : NULL; + + const int16_t new_num = new_state ? new_state->number : INT16_MAX; + const int16_t old_num = old_state ? old_state->number : INT16_MAX; + + if (new_num == old_num) { // delta update + Net_WriteDeltaEntity(msg, old_state, new_state, false); + old_index++; + new_index++; + } else if (new_num < old_num) { // new entity from baseline + Net_WriteDeltaEntity(msg, &cl.entities[new_num].baseline, new_state, true); + new_index++; + } else { // removed entity + Net_WriteShort(msg, old_num); + Net_WriteShort(msg, U_REMOVE); + old_index++; + } + } + + Net_WriteShort(msg, -1); // end of entities +} + +/** + * @brief Re-encodes the current frame as a delta from the immediately preceding + * recorded frame. Used when the original packet's delta references a frame + * older than the last keyframe, which would break playback after a seek. Falls + * back to a keyframe if the previous frame isn't available. + */ +static void Cl_WriteDemoFrameDelta(uint32_t timecode) { + + const cl_frame_t *prev = &cl.frames[(cl.frame.frame_num - 1) & PACKET_MASK]; + + if (!prev->valid || prev->frame_num != cl.frame.frame_num - 1) { + Cl_WriteDemoKeyframe(timecode); + cl_demo_last_keyframe = cl.frame.frame_num; + return; + } + + mem_buf_t msg; + byte buffer[MAX_MSG_SIZE]; + Mem_InitBuffer(&msg, buffer, sizeof(buffer)); + + Net_WriteByte(&msg, SV_CMD_FRAME); + Net_WriteLong(&msg, cl.frame.frame_num); + Net_WriteLong(&msg, cl.frame.frame_num - 1); // delta from the previous frame + + Net_WriteDeltaPlayerState(&msg, &prev->ps, &cl.frame.ps); + Cl_WriteDemoEntitiesDelta(prev, &cl.frame, &msg); + + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, msg.data, msg.size); +} + /** * @brief Writes the current net message as a v2 record, stamping it with the * current frame's timecode and classifying it as a keyframe, delta, or reliable. * Every DEMO_KEYFRAME_INTERVAL frames the natural delta is replaced with a * re-encoded keyframe so that seeking has a nearby self-contained entry point. + * Any delta that references a frame older than the last keyframe is rebased so + * that playback after a seek can always be reconstructed from the keyframe. */ static void Cl_WriteDemoRecord(void) { @@ -175,8 +243,12 @@ static void Cl_WriteDemoRecord(void) { if (cl.frame.delta_frame_num < 0) { // a naturally uncompressed frame is already a keyframe Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, net_message.data + 8, net_message.size - 8); + cl_demo_last_keyframe = cl.frame.frame_num; } else if (rel_frame > 0 && (rel_frame % DEMO_KEYFRAME_INTERVAL) == 0) { // periodic keyframe Cl_WriteDemoKeyframe(timecode); + cl_demo_last_keyframe = cl.frame.frame_num; + } else if (cl.frame.delta_frame_num < cl_demo_last_keyframe) { // delta reaches before the keyframe + Cl_WriteDemoFrameDelta(timecode); } else { Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, net_message.data + 8, net_message.size - 8); } @@ -276,6 +348,7 @@ void Cl_Record_f(void) { cl_demo_recording_v2 = cl_demo_v2->value; cl_demo_first_frame = -1; cl_demo_last_frame = -1; + cl_demo_last_keyframe = -1; Com_Print("Recording to %s (%s)\n", cls.demo_filename, cl_demo_recording_v2 ? "v2" : "v1"); } From 452b946ba99b901d85992e35e10248ea8868bdd6 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 19:28:41 -0500 Subject: [PATCH 15/18] Revert "fix(demo): rebase delta frames that reach past the last keyframe (#377)" This reverts commit 5eac5749c2d6764cb0e34025adc21c46ec754b8a. --- src/client/cl_demo.c | 73 -------------------------------------------- 1 file changed, 73 deletions(-) diff --git a/src/client/cl_demo.c b/src/client/cl_demo.c index e585a6a8d..51f47bd9f 100644 --- a/src/client/cl_demo.c +++ b/src/client/cl_demo.c @@ -28,7 +28,6 @@ cvar_t *cl_demo_v2; static bool cl_demo_recording_v2; // true if the active recording is the v2 container static int32_t cl_demo_first_frame; // frame_num of the first recorded frame, or -1 static int32_t cl_demo_last_frame; // frame_num of the most recently recorded frame -static int32_t cl_demo_last_keyframe; // frame_num of the most recently recorded keyframe /** * @brief Flushes one accumulated startup sub-message to the demo file, as a v1 @@ -152,78 +151,11 @@ static void Cl_WriteDemoKeyframe(uint32_t timecode) { Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, kf.data, kf.size); } -/** - * @brief Delta-encodes the entities of `to` against `from`, mirroring the - * server's Sv_WriteEntities. Both entity lists are sorted by number. - */ -static void Cl_WriteDemoEntitiesDelta(const cl_frame_t *from, const cl_frame_t *to, mem_buf_t *msg) { - - int32_t old_index = 0, new_index = 0; - - while (new_index < to->num_entities || old_index < from->num_entities) { - - const entity_state_t *new_state = (new_index < to->num_entities) - ? &cl.entity_states[(to->entity_state + new_index) & ENTITY_STATE_MASK] : NULL; - const entity_state_t *old_state = (old_index < from->num_entities) - ? &cl.entity_states[(from->entity_state + old_index) & ENTITY_STATE_MASK] : NULL; - - const int16_t new_num = new_state ? new_state->number : INT16_MAX; - const int16_t old_num = old_state ? old_state->number : INT16_MAX; - - if (new_num == old_num) { // delta update - Net_WriteDeltaEntity(msg, old_state, new_state, false); - old_index++; - new_index++; - } else if (new_num < old_num) { // new entity from baseline - Net_WriteDeltaEntity(msg, &cl.entities[new_num].baseline, new_state, true); - new_index++; - } else { // removed entity - Net_WriteShort(msg, old_num); - Net_WriteShort(msg, U_REMOVE); - old_index++; - } - } - - Net_WriteShort(msg, -1); // end of entities -} - -/** - * @brief Re-encodes the current frame as a delta from the immediately preceding - * recorded frame. Used when the original packet's delta references a frame - * older than the last keyframe, which would break playback after a seek. Falls - * back to a keyframe if the previous frame isn't available. - */ -static void Cl_WriteDemoFrameDelta(uint32_t timecode) { - - const cl_frame_t *prev = &cl.frames[(cl.frame.frame_num - 1) & PACKET_MASK]; - - if (!prev->valid || prev->frame_num != cl.frame.frame_num - 1) { - Cl_WriteDemoKeyframe(timecode); - cl_demo_last_keyframe = cl.frame.frame_num; - return; - } - - mem_buf_t msg; - byte buffer[MAX_MSG_SIZE]; - Mem_InitBuffer(&msg, buffer, sizeof(buffer)); - - Net_WriteByte(&msg, SV_CMD_FRAME); - Net_WriteLong(&msg, cl.frame.frame_num); - Net_WriteLong(&msg, cl.frame.frame_num - 1); // delta from the previous frame - - Net_WriteDeltaPlayerState(&msg, &prev->ps, &cl.frame.ps); - Cl_WriteDemoEntitiesDelta(prev, &cl.frame, &msg); - - Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, msg.data, msg.size); -} - /** * @brief Writes the current net message as a v2 record, stamping it with the * current frame's timecode and classifying it as a keyframe, delta, or reliable. * Every DEMO_KEYFRAME_INTERVAL frames the natural delta is replaced with a * re-encoded keyframe so that seeking has a nearby self-contained entry point. - * Any delta that references a frame older than the last keyframe is rebased so - * that playback after a seek can always be reconstructed from the keyframe. */ static void Cl_WriteDemoRecord(void) { @@ -243,12 +175,8 @@ static void Cl_WriteDemoRecord(void) { if (cl.frame.delta_frame_num < 0) { // a naturally uncompressed frame is already a keyframe Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, net_message.data + 8, net_message.size - 8); - cl_demo_last_keyframe = cl.frame.frame_num; } else if (rel_frame > 0 && (rel_frame % DEMO_KEYFRAME_INTERVAL) == 0) { // periodic keyframe Cl_WriteDemoKeyframe(timecode); - cl_demo_last_keyframe = cl.frame.frame_num; - } else if (cl.frame.delta_frame_num < cl_demo_last_keyframe) { // delta reaches before the keyframe - Cl_WriteDemoFrameDelta(timecode); } else { Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, net_message.data + 8, net_message.size - 8); } @@ -348,7 +276,6 @@ void Cl_Record_f(void) { cl_demo_recording_v2 = cl_demo_v2->value; cl_demo_first_frame = -1; cl_demo_last_frame = -1; - cl_demo_last_keyframe = -1; Com_Print("Recording to %s (%s)\n", cls.demo_filename, cl_demo_recording_v2 ? "v2" : "v1"); } From d95bea2cb762d519816d8afc57dfb94982e45b3d Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 11:06:57 -0500 Subject: [PATCH 16/18] fix(demo): make demo seeking robust on any demo (#377) 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"). --- src/client/cl_demo.c | 89 ++++---- src/client/cl_demo.h | 2 +- src/client/cl_parse.c | 8 +- src/server/sv_demo.c | 457 ++++++++++++++++++++++++++++++++++++++++++ src/server/sv_demo.h | 2 + src/server/sv_init.c | 8 + src/server/sv_send.c | 29 ++- 7 files changed, 542 insertions(+), 53 deletions(-) diff --git a/src/client/cl_demo.c b/src/client/cl_demo.c index 51f47bd9f..b9055aaf4 100644 --- a/src/client/cl_demo.c +++ b/src/client/cl_demo.c @@ -27,7 +27,6 @@ cvar_t *cl_demo_v2; // state for the in-progress recording static bool cl_demo_recording_v2; // true if the active recording is the v2 container static int32_t cl_demo_first_frame; // frame_num of the first recorded frame, or -1 -static int32_t cl_demo_last_frame; // frame_num of the most recently recorded frame /** * @brief Flushes one accumulated startup sub-message to the demo file, as a v1 @@ -121,43 +120,26 @@ static void Cl_WriteDemoHeader(void) { } /** - * @brief Re-encodes the current decoded frame as a self-contained keyframe (a - * full, non-delta snapshot) and writes it as a FRAME_KEY record. This is the - * exact wire format the client parser expects for an uncompressed frame: the - * player state delta'd from null, and every live entity delta'd from its - * baseline. Seeking jumps to these keyframes (see Demo_ScanIndex / Sv_DemoSeek). + * @brief Writes the current packet as v2 records, stamped with the current + * frame's timecode. A server packet is the concatenation of any reliable + * commands the netchan prepended, the frame command, and the per-frame datagram. + * The frame command is written as its own FRAME record with the surrounding + * bytes written verbatim as RELIABLE records, so the frame is isolated in pure + * engine wire format. This lets playback decode and re-encode it into a fully + * seekable stream (see Sv_DemoMakeSeekable) without having to parse the game's + * datagram commands, whose lengths only the client game module knows. + * + * Datagram-only packets (a fragmented frame's continuation) carry no frame + * command (`frame_start < 0`) and are written verbatim as a single RELIABLE + * record. + * + * @param frame_start Byte offset of the frame command in net_message, or -1. + * @param frame_end Byte offset just past the frame command in net_message. */ -static void Cl_WriteDemoKeyframe(uint32_t timecode) { - static player_state_t null_state; - mem_buf_t kf; - byte buffer[MAX_MSG_SIZE]; - - Mem_InitBuffer(&kf, buffer, sizeof(buffer)); - - Net_WriteByte(&kf, SV_CMD_FRAME); - Net_WriteLong(&kf, cl.frame.frame_num); - Net_WriteLong(&kf, -1); // uncompressed - - Net_WriteDeltaPlayerState(&kf, &null_state, &cl.frame.ps); - - for (int32_t i = 0; i < cl.frame.num_entities; i++) { - const uint32_t snum = (cl.frame.entity_state + i) & ENTITY_STATE_MASK; - const entity_state_t *s = &cl.entity_states[snum]; - Net_WriteDeltaEntity(&kf, &cl.entities[s->number].baseline, s, true); - } - - Net_WriteShort(&kf, -1); // end of entities +static void Cl_WriteDemoRecord(int32_t frame_start, int32_t frame_end) { - Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, kf.data, kf.size); -} - -/** - * @brief Writes the current net message as a v2 record, stamping it with the - * current frame's timecode and classifying it as a keyframe, delta, or reliable. - * Every DEMO_KEYFRAME_INTERVAL frames the natural delta is replaced with a - * re-encoded keyframe so that seeking has a nearby self-contained entry point. - */ -static void Cl_WriteDemoRecord(void) { + byte *payload = net_message.data + 8; + const int32_t payload_size = (int32_t) net_message.size - 8; if (cl_demo_first_frame < 0) { cl_demo_first_frame = cl.frame.frame_num; @@ -166,27 +148,37 @@ static void Cl_WriteDemoRecord(void) { const int32_t rel_frame = cl.frame.frame_num - cl_demo_first_frame; const uint32_t timecode = (uint32_t) rel_frame * QUETOO_TICK_MILLIS; - if (cl.frame.frame_num == cl_demo_last_frame) { // a trailing datagram-only packet - Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, timecode, net_message.data + 8, net_message.size - 8); + // the frame command's range within the recorded payload (skip the 8-byte seq) + const int32_t fstart = frame_start - 8; + const int32_t fend = frame_end - 8; + + if (frame_start < 0 || fstart < 0 || fend > payload_size || fstart >= fend) { + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, timecode, payload, payload_size); return; } - cl_demo_last_frame = cl.frame.frame_num; + const uint8_t frame_type = (cl.frame.delta_frame_num < 0) + ? DEMO_RECORD_FRAME_KEY : DEMO_RECORD_FRAME_DELTA; - if (cl.frame.delta_frame_num < 0) { // a naturally uncompressed frame is already a keyframe - Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_KEY, timecode, net_message.data + 8, net_message.size - 8); - } else if (rel_frame > 0 && (rel_frame % DEMO_KEYFRAME_INTERVAL) == 0) { // periodic keyframe - Cl_WriteDemoKeyframe(timecode); - } else { - Demo_WriteRecord(cls.demo_file, DEMO_RECORD_FRAME_DELTA, timecode, net_message.data + 8, net_message.size - 8); + // reliable commands the netchan prepended ahead of the frame + if (fstart > 0) { + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, timecode, payload, fstart); + } + + // the isolated, engine-format frame command (decodable without the cgame) + Demo_WriteRecord(cls.demo_file, frame_type, timecode, payload + fstart, fend - fstart); + + // trailing datagram commands (sounds, temp entities, ...) + if (fend < payload_size) { + Demo_WriteRecord(cls.demo_file, DEMO_RECORD_RELIABLE, timecode, payload + fend, payload_size - fend); } } /** * @brief Dumps the current net message to the demo, prefixed by the length (v1) - * or wrapped in a timecoded record (v2). + * or wrapped in timecoded records (v2). */ -void Cl_WriteDemoMessage(void) { +void Cl_WriteDemoMessage(int32_t frame_start, int32_t frame_end) { if (!cls.demo_file) { return; @@ -202,7 +194,7 @@ void Cl_WriteDemoMessage(void) { } if (cl_demo_recording_v2) { - Cl_WriteDemoRecord(); + Cl_WriteDemoRecord(frame_start, frame_end); } else { // the first eight bytes are just packet sequencing stuff const int32_t len = LittleLong((int32_t) (net_message.size - 8)); @@ -275,7 +267,6 @@ void Cl_Record_f(void) { cl_demo_recording_v2 = cl_demo_v2->value; cl_demo_first_frame = -1; - cl_demo_last_frame = -1; Com_Print("Recording to %s (%s)\n", cls.demo_filename, cl_demo_recording_v2 ? "v2" : "v1"); } diff --git a/src/client/cl_demo.h b/src/client/cl_demo.h index ba313bcfc..bcc2e234c 100644 --- a/src/client/cl_demo.h +++ b/src/client/cl_demo.h @@ -27,7 +27,7 @@ extern cvar_t *cl_demo_v2; #if defined(__CL_LOCAL_H__) void Cl_InitDemo(void); -void Cl_WriteDemoMessage(void); +void Cl_WriteDemoMessage(int32_t frame_start, int32_t frame_end); void Cl_Record_f(void); void Cl_Stop_f(void); void Cl_FastForward_f(void); diff --git a/src/client/cl_parse.c b/src/client/cl_parse.c index 87cfe40a1..379ff41ca 100644 --- a/src/client/cl_parse.c +++ b/src/client/cl_parse.c @@ -375,6 +375,10 @@ void Cl_ParseServerMessage(void) { Com_Print("------------------\n"); } + // byte range of the frame command within this packet, so the demo recorder can + // isolate it from the reliable/datagram commands around it (-1 if no frame) + int32_t frame_start = -1, frame_end = -1; + cmd = SV_CMD_BAD; // parse the message @@ -419,7 +423,9 @@ void Cl_ParseServerMessage(void) { Com_Error(ERROR_DROP, "Server dropped connection\n"); case SV_CMD_FRAME: + frame_start = (int32_t) (net_message.read - 1); // the SV_CMD_FRAME byte Cl_ParseFrame(); + frame_end = (int32_t) net_message.read; break; case SV_CMD_PRINT: @@ -456,5 +462,5 @@ void Cl_ParseServerMessage(void) { Cl_AddNetGraph(); - Cl_WriteDemoMessage(); + Cl_WriteDemoMessage(frame_start, frame_end); } diff --git a/src/server/sv_demo.c b/src/server/sv_demo.c index 576d42d4d..95123a17f 100644 --- a/src/server/sv_demo.c +++ b/src/server/sv_demo.c @@ -283,3 +283,460 @@ void Sv_StopRecord_f(void) { Sv_StopServerRecord(); Com_Print("Stopped server demo\n"); } + +/** + * @brief Demo seek re-encoding (playback side). + * + * Client demos are captured verbatim: every recorded frame is delta-compressed + * against whatever frame the client had acknowledged at record time. That chain + * is intact for forward playback but breaks the instant a seek skips frames -- + * the client rejects a delta whose base it never received ("Delta frame too + * old"). To make scrubbing safe on *any* demo, the whole demo is decoded once at + * load time and re-emitted as a clean, self-consistent stream: a real (full) + * keyframe every DEMO_KEYFRAME_INTERVAL frames, and every other frame delta'd + * against the immediately preceding frame. Seeking then jumps to a keyframe and + * the existing playback path streams the intervening deltas in order, so the + * delta chain is never crossed and interpolation (which only happens on delta + * frames) stays smooth. + */ + +/** + * @brief A fully-decoded snapshot: the player state plus every live entity, + * indexed by entity number. + */ +typedef struct { + player_state_t ps; + entity_state_t entities[MAX_ENTITIES]; + bool active[MAX_ENTITIES]; + int32_t frame_num; // the original frame this snapshot holds, or -1 if empty +} sv_demo_snapshot_t; + +/** + * @brief Running decoder state for a re-encode pass. + * + * Recorded frames do not always delta against the immediately preceding frame -- + * the live server deltas against whatever frame the client last acknowledged, + * which lags. So, exactly like the client (cl.frames[]), decoded frames are kept + * in a ring indexed by frame_num & PACKET_MASK and each frame is decoded against + * its true delta base, however many frames back that is. + */ +typedef struct { + sv_demo_snapshot_t ring[PACKET_BACKUP]; + entity_state_t baselines[MAX_ENTITIES]; +} sv_demo_decoder_t; + +/** + * @brief Scratch file the re-encoded, seekable stream is written to. A single + * demo plays at a time, so one fixed path is reused and deleted on unload. The + * leading dot and ".tmp" suffix keep it out of the demo browser listing. + */ +#define SV_DEMO_SEEK_TEMP "demos/.seek.tmp" + +/** + * @brief Walks an epoch (startup) RELIABLE record, capturing entity baselines so + * later frames can be decoded and re-encoded against them. The record holds only + * server-data, config-string, baseline, and cbuf-text commands; anything else + * means the stream is not one we know how to decode. + */ +static bool Sv_DemoParseEpoch(sv_demo_decoder_t *dec, byte *data, uint32_t len) { + static entity_state_t null_state; + mem_buf_t in; + + Mem_InitBuffer(&in, data, len); + in.size = len; + + while (in.read < in.size) { + const byte cmd = Net_ReadByte(&in); + + switch (cmd) { + case SV_CMD_SERVER_DATA: + Net_ReadLong(&in); // protocol major + Net_ReadLong(&in); // protocol minor + Net_ReadByte(&in); // demo_server byte + Net_ReadString(&in); // game + Net_ReadString(&in); // level message + break; + case SV_CMD_CONFIG_STRING: + Net_ReadShort(&in); // index + Net_ReadString(&in); // value + break; + case SV_CMD_BASELINE: { + const int16_t number = Net_ReadShort(&in); + const uint16_t bits = Net_ReadShort(&in); + if (number < 0 || number >= MAX_ENTITIES) { + Com_Warn("Demo re-encode: bad baseline number %d\n", number); + return false; + } + Net_ReadDeltaEntity(&in, &null_state, &dec->baselines[number], number, bits); + break; + } + case SV_CMD_CBUF_TEXT: + Net_ReadString(&in); + break; + default: + // a non-startup command (e.g. a cgame command in a frame's reliable + // prefix). Baselines only ever appear in the pure-engine startup epoch, + // ahead of any such command, so stop here with what we have -- we cannot + // know this command's length, but we do not need to: the record is + // copied through verbatim regardless. + return true; + } + + if (in.read > in.size) { + Com_Warn("Demo re-encode: epoch record overran\n"); + return false; + } + } + + return true; +} + +/** + * @brief Locates and decodes the SV_CMD_FRAME within a recorded FRAME record, + * advancing the running state from the previous frame to this one. + * + * Demos recorded once frames were isolated (see Cl_WriteDemoRecord) have the + * frame command at offset 0. Older demos may carry the reliable commands the + * netchan prepended ahead of it; those engine commands are skipped here (and any + * baselines among them captured). A cgame command cannot be skipped -- only the + * client game module knows its length -- so the re-encode bails if one precedes + * the frame, and that demo falls back to plain forward playback. + * + * The frame decode mirrors the client's parser exactly (Cl_ParseFrame / + * Cl_ParseEntities): existing entities delta from their delta-base state, new + * entities from their baseline, U_REMOVE drops them. The decoded frame is stored + * in the ring so it can serve as a delta base for later frames. + * + * @param out_start Set to the byte offset of the SV_CMD_FRAME command. + * @param out_end Set to the byte offset just past the frame command (anything + * beyond is trailing datagram, e.g. sounds and temp entities). + * @param out_frame_num Set to the decoded frame's original frame number. + * @return True on success. + */ +static bool Sv_DemoDecodeFrame(sv_demo_decoder_t *dec, byte *data, uint32_t len, + uint32_t *out_start, uint32_t *out_end, int32_t *out_frame_num) { + static entity_state_t null_es; + static player_state_t null_ps; + mem_buf_t in; + + Mem_InitBuffer(&in, data, len); + in.size = len; + + // walk to the frame command, skipping any engine commands ahead of it + for (;;) { + if (in.read >= in.size) { + Com_Warn("Demo re-encode: no frame command in record\n"); + return false; + } + + const uint32_t cmd_pos = (uint32_t) in.read; + const byte cmd = Net_ReadByte(&in); + + if (cmd == SV_CMD_FRAME) { + *out_start = cmd_pos; + break; + } + + switch (cmd) { + case SV_CMD_CONFIG_STRING: + Net_ReadShort(&in); + Net_ReadString(&in); + break; + case SV_CMD_PRINT: + Net_ReadByte(&in); + Net_ReadString(&in); + break; + case SV_CMD_CBUF_TEXT: + Net_ReadString(&in); + break; + case SV_CMD_BASELINE: { + const int16_t number = Net_ReadShort(&in); + const uint16_t bits = Net_ReadShort(&in); + if (number >= 0 && number < MAX_ENTITIES) { + Net_ReadDeltaEntity(&in, &null_es, &dec->baselines[number], number, bits); + } + break; + } + case SV_CMD_SERVER_DATA: + Net_ReadLong(&in); + Net_ReadLong(&in); + Net_ReadByte(&in); + Net_ReadString(&in); + Net_ReadString(&in); + break; + case SV_CMD_DISCONNECT: + case SV_CMD_RECONNECT: + case SV_CMD_DROP: + break; // no payload + default: + Com_Warn("Demo re-encode: cannot skip command %u before frame\n", cmd); + return false; + } + + if (in.read > in.size) { + Com_Warn("Demo re-encode: command overran record\n"); + return false; + } + } + + const int32_t frame_num = Net_ReadLong(&in); // original frame number + const int32_t delta = Net_ReadLong(&in); // original delta frame number (< 0 if uncompressed) + + sv_demo_snapshot_t *cur = &dec->ring[frame_num & PACKET_MASK]; + + // seed this frame from its true delta base, then apply the recorded delta + const player_state_t *ps_from; + if (delta < 0) { + memset(cur, 0, sizeof(*cur)); // uncompressed: from null / baselines + ps_from = &null_ps; + } else { + const sv_demo_snapshot_t *base = &dec->ring[delta & PACKET_MASK]; + if (base->frame_num != delta) { + Com_Warn("Demo re-encode: delta base frame %d unavailable\n", delta); + return false; + } + ps_from = &base->ps; + *cur = *base; // unchanged entities persist from the base frame + } + + Net_ReadDeltaPlayerState(&in, ps_from, &cur->ps); + + for (;;) { + const int16_t number = Net_ReadShort(&in); + if (number == -1) { + break; + } + + if (number < 0 || number >= MAX_ENTITIES) { + Com_Warn("Demo re-encode: bad entity number %d\n", number); + return false; + } + + const uint16_t bits = Net_ReadShort(&in); + + if (bits & U_REMOVE) { + cur->active[number] = false; + } else { + const entity_state_t *from = cur->active[number] + ? &cur->entities[number] + : &dec->baselines[number]; + Net_ReadDeltaEntity(&in, from, &cur->entities[number], number, bits); + cur->active[number] = true; + } + + if (in.read > in.size) { + Com_Warn("Demo re-encode: frame record overran\n"); + return false; + } + } + + cur->frame_num = frame_num; + *out_end = (uint32_t) in.read; + *out_frame_num = frame_num; + return true; +} + +/** + * @brief Re-emits the running state as a single SV_CMD_FRAME. A keyframe is a + * full, self-contained snapshot (player state from null, entities from their + * baselines) that a seek can land on; a delta frame is encoded against the + * previous frame, exactly like the live server (mirrors Sv_WriteClientFrame). + */ +static void Sv_DemoEncodeFrame(const sv_demo_snapshot_t *prev, const sv_demo_snapshot_t *cur, + const entity_state_t *baselines, mem_buf_t *out, + uint32_t frame_num, bool keyframe) { + static player_state_t null_ps; + + Net_WriteByte(out, SV_CMD_FRAME); + Net_WriteLong(out, (int32_t) frame_num); + Net_WriteLong(out, keyframe ? -1 : (int32_t) (frame_num - 1)); + + if (keyframe) { + Net_WriteDeltaPlayerState(out, &null_ps, &cur->ps); + } else { + Net_WriteDeltaPlayerState(out, &prev->ps, &cur->ps); + } + + for (int32_t n = 0; n < MAX_ENTITIES; n++) { + const bool in_new = cur->active[n]; + const bool in_old = !keyframe && prev->active[n]; + + if (in_new && in_old) { // delta from the previous emitted frame's state + Net_WriteDeltaEntity(out, &prev->entities[n], &cur->entities[n], false); + } else if (in_new) { // new (or keyframe): delta from the baseline + Net_WriteDeltaEntity(out, &baselines[n], &cur->entities[n], true); + } else if (in_old) { // present last frame, gone now + Net_WriteShort(out, n); + Net_WriteShort(out, U_REMOVE); + } + } + + Net_WriteShort(out, -1); // end of entities +} + +/** + * @brief Streams every record from the recorded demo to the seekable temp file, + * transforming FRAME records (decode + re-emit as a clean keyframe/delta) and + * passing reliable, camera, and trailing-datagram bytes through verbatim. + */ +static bool Sv_DemoReencodeRecords(sv_demo_decoder_t *dec, file_t *in, file_t *out) { + byte in_buf[MAX_MSG_SIZE]; + byte out_buf[MAX_MSG_SIZE * 2]; + demo_record_t rec; + uint32_t out_frame = 1; // start at 1 so the first delta's base is never 0 (the + // client reads delta_frame_num <= 0 as uncompressed) + int32_t prev_frame_num = -1; // original frame number of the previously emitted frame + bool have_frame = false; + + for (;;) { + if (!Demo_ReadRecord(in, &rec, in_buf, sizeof(in_buf))) { + break; // end of records + } + + switch (rec.type) { + case DEMO_RECORD_RELIABLE: + // the startup epoch arrives as tc-0 reliables before the first frame + if (rec.timecode == 0 && !have_frame) { + if (!Sv_DemoParseEpoch(dec, in_buf, rec.length)) { + return false; + } + } + Demo_WriteRecord(out, DEMO_RECORD_RELIABLE, rec.timecode, in_buf, rec.length); + break; + + case DEMO_RECORD_FRAME_KEY: + case DEMO_RECORD_FRAME_DELTA: { + uint32_t fstart = 0, fend = 0; + int32_t frame_num = 0; + if (!Sv_DemoDecodeFrame(dec, in_buf, rec.length, &fstart, &fend, &frame_num)) { + return false; + } + have_frame = true; + + const sv_demo_snapshot_t *cur = &dec->ring[frame_num & PACKET_MASK]; + const sv_demo_snapshot_t *prev = + (prev_frame_num >= 0) ? &dec->ring[prev_frame_num & PACKET_MASK] : NULL; + + // engine commands the netchan prepended ahead of the frame (older, + // un-isolated demos): pass them through before the re-encoded frame + if (fstart > 0) { + Demo_WriteRecord(out, DEMO_RECORD_RELIABLE, rec.timecode, in_buf, fstart); + } + + const uint32_t trailing = rec.length - fend; + mem_buf_t out_msg; + + // emit a keyframe on cadence (and always for the first frame, which has + // nothing to delta from), but fall back to a smaller delta if the full + // snapshot plus its trailing datagrams would not fit one message + bool keyframe = (out_frame % DEMO_KEYFRAME_INTERVAL) == 0 || prev == NULL; + for (;;) { + Mem_InitBuffer(&out_msg, out_buf, sizeof(out_buf)); + out_msg.allow_overflow = true; // never abort; detect via .overflowed + Sv_DemoEncodeFrame(prev, cur, dec->baselines, &out_msg, out_frame, keyframe); + + if (trailing) { + Mem_WriteBuffer(&out_msg, in_buf + fend, trailing); + } + + if (!out_msg.overflowed && out_msg.size <= MAX_MSG_SIZE - 16) { + break; // fits + } + + if (!keyframe || prev == NULL) { + // a delta overflowed (the original fit, so this should not happen), + // or a mandatory keyframe (the first frame) is too large -- give up + Com_Warn("Demo re-encode: frame %u does not fit (%u bytes)\n", + out_frame, (uint32_t) out_msg.size); + return false; + } + + keyframe = false; // retry as a delta + } + + Demo_WriteRecord(out, keyframe ? DEMO_RECORD_FRAME_KEY : DEMO_RECORD_FRAME_DELTA, + rec.timecode, out_msg.data, out_msg.size); + out_frame++; + prev_frame_num = frame_num; + break; + } + + case DEMO_RECORD_CAMERA: + default: + Demo_WriteRecord(out, rec.type, rec.timecode, in_buf, rec.length); + break; + } + } + + if (out_frame == 0) { + Com_Warn("Demo re-encode: no frames decoded\n"); + return false; + } + + return true; +} + +/** + * @brief Decodes the loaded demo (cursor positioned at its first record) into a + * clean, fully seekable temp stream and swaps `sv.demo_file` to it. On any + * failure the original file is left rewound to its first record so forward + * playback still works. + * @return True if the seekable stream was built and is now active. + */ +bool Sv_DemoMakeSeekable(const demo_header_t *header) { + const int64_t first_record = Fs_Tell(sv.demo_file); + + file_t *out = Fs_OpenWrite(SV_DEMO_SEEK_TEMP); + if (!out) { + Com_Warn("Couldn't open %s for re-encode\n", SV_DEMO_SEEK_TEMP); + Fs_Seek(sv.demo_file, first_record); + return false; + } + + demo_header_t out_header = *header; + out_header.epoch_len = 0; + + bool ok = Demo_WriteHeader(out, &out_header, NULL, 0); + if (ok) { + sv_demo_decoder_t *dec = Mem_TagMalloc(sizeof(*dec), MEM_TAG_SERVER); + for (int32_t i = 0; i < PACKET_BACKUP; i++) { + dec->ring[i].frame_num = -1; // mark every ring slot empty + } + ok = Sv_DemoReencodeRecords(dec, sv.demo_file, out); + Mem_Free(dec); + } + + Fs_Close(out); + + if (!ok) { + Fs_Delete(SV_DEMO_SEEK_TEMP); + Fs_Seek(sv.demo_file, first_record); + return false; + } + + file_t *seekable = Fs_OpenRead(SV_DEMO_SEEK_TEMP); + if (!seekable) { + Com_Warn("Couldn't reopen re-encoded demo\n"); + Fs_Seek(sv.demo_file, first_record); + return false; + } + + demo_header_t tmp; + if (!Demo_ReadHeader(seekable, &tmp, NULL)) { + Com_Warn("Re-encoded demo header invalid\n"); + Fs_Close(seekable); + Fs_Seek(sv.demo_file, first_record); + return false; + } + + Fs_Close(sv.demo_file); + sv.demo_file = seekable; + return true; +} + +/** + * @brief Removes the seekable scratch file, if present. Called on demo unload. + */ +void Sv_DemoSeekCleanup(void) { + Fs_Delete(SV_DEMO_SEEK_TEMP); +} diff --git a/src/server/sv_demo.h b/src/server/sv_demo.h index 77c7c561a..27ebfeec0 100644 --- a/src/server/sv_demo.h +++ b/src/server/sv_demo.h @@ -28,4 +28,6 @@ void Sv_Record_f(void); void Sv_StopRecord_f(void); void Sv_DemoRecordFrame(void); void Sv_StopServerRecord(void); +bool Sv_DemoMakeSeekable(const demo_header_t *header); +void Sv_DemoSeekCleanup(void); #endif /* __SV_LOCAL_H__ */ diff --git a/src/server/sv_init.c b/src/server/sv_init.c index 39754730e..27d7bb6c7 100644 --- a/src/server/sv_init.c +++ b/src/server/sv_init.c @@ -152,6 +152,8 @@ static void Sv_ClearState(void) { Fs_Close(sv.demo_file); } + Sv_DemoSeekCleanup(); + Demo_FreeIndex(&sv.demo_index); memset(&sv, 0, sizeof(sv)); @@ -292,6 +294,12 @@ static void Sv_LoadMedia(const char *name, const cm_entity_t *props, sv_state_t sv.demo_speed = 1.0; sv.demo_paused = false; + // pre-decode the whole demo into a clean, fully seekable stream (periodic + // keyframes + sequential deltas) so scrubbing can't break the delta chain + if (!Sv_DemoMakeSeekable(&header)) { + Com_Warn(" Seek re-encode failed; forward playback only.\n"); + } + // build the keyframe seek index; this leaves the cursor at the first record Demo_ScanIndex(sv.demo_file, &sv.demo_index); Com_Print(" %u keyframe(s), %.1fs.\n", sv.demo_index.count, sv.demo_index.duration / 1000.0); diff --git a/src/server/sv_send.c b/src/server/sv_send.c index 180dc64b7..e446e3868 100644 --- a/src/server/sv_send.c +++ b/src/server/sv_send.c @@ -379,14 +379,33 @@ static size_t Sv_GetDemoMessage(byte *buffer) { } /** - * @brief Transmits all v2 demo records whose timecode has been reached by the + * @brief Largest number of demo records transmitted to a client per server tick. + * A seek makes a large backlog of records due at once (the keyframe plus every + * frame up to the target); transmitting them all at once would overrun the + * loopback receive ring (MAX_NET_UDP_LOOPS == 64), silently dropping frames and + * breaking the delta chain ("Delta frame too old"). Capping the burst spreads + * the catch-up over a few ticks -- a brief, smooth fast-forward -- while keeping + * every frame in order so the chain never breaks. + * + * Sv_Frame may run up to 4 ticks before the client drains the ring once (and the + * client can skip draining entirely under its frame-rate cap), so the bound is + * 4 * (SV_DEMO_MAX_BURST + 1 status) records between drains. 12 keeps that at 52, + * safely under the 64-slot ring for any realistic frame rate. + */ +#define SV_DEMO_MAX_BURST 12 + +/** + * @brief Transmits the v2 demo records whose timecode has been reached by the * demo clock, reframing each as the client expects. The records are the exact - * v1 wire messages, so the client parses them identically to live play. + * v1 wire messages, so the client parses them identically to live play. At most + * SV_DEMO_MAX_BURST are sent per frame so a seek's backlog cannot overrun the + * loopback ring. * @return False when the demo has completed (end of records). */ static bool Sv_SendDemoV2Records(sv_client_t *cl) { byte buffer[MAX_MSG_SIZE]; demo_record_t rec; + int32_t sent = 0; for (;;) { const int64_t offset = Fs_Tell(sv.demo_file); @@ -401,7 +420,13 @@ static bool Sv_SendDemoV2Records(sv_client_t *cl) { return true; } + if (sent >= SV_DEMO_MAX_BURST) { // throttle the catch-up; resume next frame + Fs_Seek(sv.demo_file, offset); + return true; + } + Netchan_Transmit(&cl->net_chan, buffer, rec.length); + sent++; } } From b6621018d8c3f4e6a94aeaa297fb4233f8cdfdfb Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 11:07:14 -0500 Subject: [PATCH 17/18] fix(cgame): survive missing precached assets during demo playback (#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. --- src/cgame/default/cg_entity_effect.c | 12 ++++--- src/cgame/default/cg_sound.c | 47 ++++++++++++++++++---------- src/cgame/default/cg_sprite.c | 5 ++- src/cgame/default/cg_sprite.h | 3 ++ src/cgame/default/cg_temp_entity.c | 17 ++++++++-- 5 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/cgame/default/cg_entity_effect.c b/src/cgame/default/cg_entity_effect.c index 2f9aad573..8a817fd1c 100644 --- a/src/cgame/default/cg_entity_effect.c +++ b/src/cgame/default/cg_entity_effect.c @@ -41,11 +41,13 @@ vec3_t Cg_EffectColor(float *hue, const float default_hue) { */ vec3_t Cg_ClientEffectColor(const int32_t client, float *hue, const float default_hue) { - assert(client >= 0); - assert(client < MAX_CLIENTS); - - const cg_client_info_t *ci = &cg_state.clients[client]; - float client_hue = ci->team ? ci->team->hue : ci->hue; + // the client index arrives raw off the wire; fall back to the default hue if it + // is out of range rather than indexing cg_state.clients[] out of bounds + float client_hue = default_hue; + if (client >= 0 && client < MAX_CLIENTS) { + const cg_client_info_t *ci = &cg_state.clients[client]; + client_hue = ci->team ? ci->team->hue : ci->hue; + } const vec3_t color = Cg_EffectColor(&client_hue, default_hue); diff --git a/src/cgame/default/cg_sound.c b/src/cgame/default/cg_sound.c index 38cf48f4e..269c3501b 100644 --- a/src/cgame/default/cg_sound.c +++ b/src/cgame/default/cg_sound.c @@ -41,19 +41,44 @@ void Cg_PrepareStage(const cl_frame_t *frame) { void Cg_ParseSound(void) { const byte flags = cgi.ReadByte(); - const uint8_t sample_index = cgi.ReadByte(); + s_play_sample_t play = { .sample = cgi.client->sounds[sample_index] }; + // consume the whole message first, in wire order, so that a missing sample (or + // any later bail-out) cannot leave the read cursor misaligned and desync the + // rest of the packet + int32_t number = -1; + if (flags & SOUND_ENTITY) { + number = cgi.ReadShort(); + } + + vec3_t origin = Vec3_Zero(); + const bool has_origin = flags & SOUND_ORIGIN; + if (has_origin) { + origin = cgi.ReadPosition(); + } + + if (flags & SOUND_PITCH) { + play.pitch = cgi.ReadChar() * 2; + } + + if (flags & SOUND_GAIN) { + play.gain = cgi.ReadByte() / 255.f; + } + + if (flags & SOUND_RELATIVE) { + play.flags |= S_PLAY_RELATIVE; + } + if (!play.sample) { Cg_Warn("NULL sample for sound index %u\n", sample_index); return; } - if (flags & SOUND_ENTITY) { - const int16_t number = cgi.ReadShort(); + if (number >= 0) { assert(number < MAX_ENTITIES); const cl_entity_t *ent = &cgi.client->entities[number]; play.entity = ent; @@ -74,20 +99,8 @@ void Cg_ParseSound(void) { play.entity = NULL; } - if (flags & SOUND_ORIGIN) { - play.origin = cgi.ReadPosition(); - } - - if (flags & SOUND_PITCH) { - play.pitch = cgi.ReadChar() * 2; - } - - if (flags & SOUND_GAIN) { - play.gain = cgi.ReadByte() / 255.f; - } - - if (flags & SOUND_RELATIVE) { - play.flags |= S_PLAY_RELATIVE; + if (has_origin) { + play.origin = origin; } Cg_AddSample(cgi.stage, &play); diff --git a/src/cgame/default/cg_sprite.c b/src/cgame/default/cg_sprite.c index d60cc3ac1..2546b30b1 100644 --- a/src/cgame/default/cg_sprite.c +++ b/src/cgame/default/cg_sprite.c @@ -76,7 +76,10 @@ cg_sprite_t *Cg_AddSprite(const cg_sprite_t *in_s) { return NULL; } - assert(in_s->media); + if (in_s->media == NULL) { // the sprite/atlas/animation/beam image failed to precache + Cg_Debug("NULL sprite media\n"); + return NULL; + } cg_sprite_t *s = cg_free_sprites; diff --git a/src/cgame/default/cg_sprite.h b/src/cgame/default/cg_sprite.h index be156def4..8867861b5 100644 --- a/src/cgame/default/cg_sprite.h +++ b/src/cgame/default/cg_sprite.h @@ -255,6 +255,9 @@ struct cg_sprite_s { * @brief Calculate a lifetime value that causes the animation to run at a specified framerate. */ static inline uint32_t Cg_AnimationLifetime(const r_animation_t *animation, const float fps) { + if (animation == NULL) { // the animation sprite failed to precache + return 0; + } return animation->num_frames * FRAMES_TO_SECONDS(fps); } diff --git a/src/cgame/default/cg_temp_entity.c b/src/cgame/default/cg_temp_entity.c index b41b0f28d..8997d1d82 100644 --- a/src/cgame/default/cg_temp_entity.c +++ b/src/cgame/default/cg_temp_entity.c @@ -31,6 +31,10 @@ void Cg_AddDecal(const r_decal_t *decal) { return; } + if (decal->image == NULL) { // the decal atlas image failed to precache + return; + } + cgi.AddDecal(cgi.view, decal); } @@ -1377,12 +1381,21 @@ static void Cg_RippleEffect(const r_bsp_brush_side_t *side, const vec3_t org, fl */ static void Cg_RippleSplashEffect(const vec3_t org, const vec3_t dir, int32_t brush_side, float size, bool splash) { - if (brush_side < 0 || brush_side > cgi.WorldModel()->bsp->num_brush_sides) { + const r_model_t *world = cgi.WorldModel(); + if (world == NULL || world->bsp == NULL) { + return; + } + + if (brush_side < 0 || brush_side >= world->bsp->num_brush_sides) { Cg_Warn("Invalid brush side %d\n", brush_side); return; } - const r_bsp_brush_side_t *side = cgi.WorldModel()->bsp->brush_sides + brush_side; + const r_bsp_brush_side_t *side = world->bsp->brush_sides + brush_side; + + if (side->material == NULL) { + return; // a side whose material did not resolve (e.g. a missing texture) + } Cg_RippleEffect(side, org, size); From 8e77c8e4edde8ff87207f6483ea180095f17ecf6 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 11:07:31 -0500 Subject: [PATCH 18/18] feat(demo): cinematic camera tools and bindable demo keys (#377) - 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. --- src/cgame/default/cg_demo.c | 325 +++++++++++++++++- src/cgame/default/cg_demo.h | 5 + src/cgame/default/cg_main.c | 10 +- src/cgame/default/cg_view.c | 2 + .../ui/controls/DemoBindsViewController.json | 127 ++++++- 5 files changed, 455 insertions(+), 14 deletions(-) diff --git a/src/cgame/default/cg_demo.c b/src/cgame/default/cg_demo.c index a7725cea6..a24664317 100644 --- a/src/cgame/default/cg_demo.c +++ b/src/cgame/default/cg_demo.c @@ -23,6 +23,15 @@ #include "game/default/bg_pmove.h" cvar_t *cg_demo_freecam; +cvar_t *cg_draw_demo_bar; +static cvar_t *cg_draw_demo_hud; +static cvar_t *cg_demo_letterbox; +static cvar_t *cg_demo_fov; +static cvar_t *cg_demo_roll; +static cvar_t *cg_demo_smoothing; +static cvar_t *cg_demo_orbit_radius; +static cvar_t *cg_demo_orbit_height; +static cvar_t *cg_demo_orbit_speed; /** * @brief Demo camera modes. @@ -31,6 +40,7 @@ typedef enum { CG_DEMO_CAM_LOCKED, // the recorded player's eye (default) CG_DEMO_CAM_FREE, // free-fly spectator under local input CG_DEMO_CAM_FOLLOW, // chase camera following a player entity + CG_DEMO_CAM_ORBIT, // cinematic auto-orbit around a player CG_DEMO_CAM_PATH, // cinematic keyframed dolly path } cg_demo_cam_t; @@ -56,9 +66,50 @@ static int32_t cg_demo_cam_count; static struct { cg_demo_cam_t mode; pm_state_t s; // free-fly spectator state (CG_DEMO_CAM_FREE) - int32_t follow; // followed entity number (CG_DEMO_CAM_FOLLOW) + int32_t follow; // followed entity number (CG_DEMO_CAM_FOLLOW / ORBIT) + + // cinematic camera smoothing (FOLLOW / ORBIT): the view is eased toward the + // computed target so player jitter does not transfer to the camera + vec3_t smooth_origin; + vec3_t smooth_angles; + uint32_t smooth_time; + bool smooth_valid; + + // auto-orbit state (CG_DEMO_CAM_ORBIT) + float orbit_angle; // current orbit yaw, degrees + uint32_t orbit_time; + bool orbit_valid; } cg_demo; +/** + * @return The entity number of the first player in the current frame, or -1. + */ +static int32_t Cg_DemoFirstPlayer(void) { + + const cl_frame_t *frame = &cgi.client->frame; + + for (int32_t i = 0; i < frame->num_entities; i++) { + const uint32_t snum = (frame->entity_state + i) & ENTITY_STATE_MASK; + const entity_state_t *s = &cgi.client->entity_states[snum]; + if (s->effects & EF_CLIENT) { + return s->number; + } + } + + return -1; +} + +/** + * @brief Resets the camera smoothing so the next FOLLOW/ORBIT frame eases from + * the current view rather than snapping from a stale position. + */ +static void Cg_DemoSeedSmoothing(void) { + cg_demo.smooth_origin = cgi.view->origin; + cg_demo.smooth_angles = cgi.view->angles; + cg_demo.smooth_time = cgi.client->unclamped_time; + cg_demo.smooth_valid = true; +} + /** * @return True if a demo is playing and the free camera is active. */ @@ -73,6 +124,13 @@ bool Cg_DemoOverridingView(void) { return cgi.client->demo_server && cg_demo.mode != CG_DEMO_CAM_LOCKED; } +/** + * @return True if the HUD should be hidden for a clean cinematic shot. + */ +bool Cg_DemoHidesHud(void) { + return cgi.client->demo_server && !cg_draw_demo_hud->integer; +} + /** * @brief Toggles the free demo camera, seeding it from the current view so the * camera does not jump when it is enabled. @@ -144,7 +202,11 @@ static void Cg_DemoFollow(int32_t dir) { } cg_demo.follow = players[index]; - cg_demo.mode = CG_DEMO_CAM_FOLLOW; + + if (cg_demo.mode != CG_DEMO_CAM_FOLLOW) { + cg_demo.mode = CG_DEMO_CAM_FOLLOW; + Cg_DemoSeedSmoothing(); + } cgi.Print("Following player %d\n", cg_demo.follow); } @@ -200,16 +262,72 @@ static void Cg_UpdateDemoFreeCamera(void) { cgi.view->origin = Vec3_Add(cg_demo.s.origin, cg_demo.s.view_offset); cgi.view->angles = cgi.client->angles; + cgi.view->angles.z += cg_demo_roll->value; // cinematic camera roll (Dutch angle) + + Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); +} + +/** + * @brief Eases the view toward the given target origin/angles using a + * time-constant exponential filter (cg_demo_smoothing, in seconds of lag), then + * writes the smoothed result into the view. With smoothing at 0 the view snaps. + */ +static void Cg_DemoSmoothView(const vec3_t origin, const vec3_t angles) { + + const float lag = cg_demo_smoothing->value; + const uint32_t now = cgi.client->unclamped_time; + + if (lag <= 0.f || !cg_demo.smooth_valid) { + cg_demo.smooth_origin = origin; + cg_demo.smooth_angles = angles; + } else { + const float dt = (float) (now - cg_demo.smooth_time); + const float alpha = Clampf(1.f - expf(-dt / (lag * 1000.f)), 0.f, 1.f); + cg_demo.smooth_origin = Vec3_Mix(cg_demo.smooth_origin, origin, alpha); + cg_demo.smooth_angles = Vec3_MixEuler(cg_demo.smooth_angles, angles, alpha); + } + + cg_demo.smooth_time = now; + cg_demo.smooth_valid = true; + + cgi.view->origin = cg_demo.smooth_origin; + cgi.view->angles = cg_demo.smooth_angles; Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); } +/** + * @brief Resolves the followed entity, re-acquiring if the current target is no + * longer a live player in this frame (e.g. after a seek, a disconnect, or the + * player leaving the recorder's view). Returns NULL if there is no one to follow. + */ +static const cl_entity_t *Cg_DemoFollowEntity(void) { + + if (cg_demo.follow >= 0 && cg_demo.follow < MAX_ENTITIES) { + const cl_entity_t *ent = &cgi.client->entities[cg_demo.follow]; + if (ent->frame_num == cgi.client->frame.frame_num && (ent->current.effects & EF_CLIENT)) { + return ent; // still current + } + } + + cg_demo.follow = Cg_DemoFirstPlayer(); // stale; re-acquire a present player + if (cg_demo.follow >= 0) { + return &cgi.client->entities[cg_demo.follow]; + } + + return NULL; +} + /** * @brief Positions the camera as a third-person chase of the followed player. */ static void Cg_UpdateDemoFollowCamera(void) { - const cl_entity_t *ent = &cgi.client->entities[cg_demo.follow]; + const cl_entity_t *ent = Cg_DemoFollowEntity(); + if (ent == NULL) { + return; + } + const vec3_t target = ent->origin; vec3_t forward, right, up; @@ -219,12 +337,103 @@ static void Cg_UpdateDemoFollowCamera(void) { desired.z += 50.f; const cm_trace_t tr = cgi.Trace(target, desired, Box3f(8.f, 8.f, 8.f), NULL, CONTENTS_MASK_CLIP_PLAYER); - cgi.view->origin = tr.end; - const vec3_t dir = Vec3_Subtract(target, cgi.view->origin); - cgi.view->angles = Vec3_Euler(Vec3_Normalize(dir)); + const vec3_t dir = Vec3_Subtract(target, tr.end); + Cg_DemoSmoothView(tr.end, Vec3_Euler(Vec3_Normalize(dir))); +} - Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); +/** + * @brief Slowly orbits the camera around the followed player at a configurable + * radius, height, and angular speed -- a hands-free cinematic shot. The orbit + * advances in real time so it is unaffected by demo speed or pausing. + */ +static void Cg_UpdateDemoOrbitCamera(void) { + + const cl_entity_t *ent = Cg_DemoFollowEntity(); + if (ent == NULL) { + return; + } + + const vec3_t target = Vec3_Add(ent->origin, Vec3(0.f, 0.f, 24.f)); + + const uint32_t now = cgi.client->unclamped_time; + if (cg_demo.orbit_valid) { + const float dt = (float) (now - cg_demo.orbit_time) / 1000.f; + cg_demo.orbit_angle += dt * cg_demo_orbit_speed->value; + } + cg_demo.orbit_time = now; + cg_demo.orbit_valid = true; + + const float yaw = Radians(cg_demo.orbit_angle); + const float radius = cg_demo_orbit_radius->value; + + vec3_t desired = target; + desired.x += cosf(yaw) * radius; + desired.y += sinf(yaw) * radius; + desired.z += cg_demo_orbit_height->value; + + const cm_trace_t tr = cgi.Trace(target, desired, Box3f(8.f, 8.f, 8.f), NULL, CONTENTS_MASK_CLIP_PLAYER); + + const vec3_t dir = Vec3_Subtract(target, tr.end); + Cg_DemoSmoothView(tr.end, Vec3_Euler(Vec3_Normalize(dir))); +} + +/** + * @brief demo_orbit — toggles the cinematic auto-orbit camera, targeting the + * followed player or, if none, the first player in the frame. + */ +static void Cg_DemoOrbit_f(void) { + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + if (cg_demo.mode == CG_DEMO_CAM_ORBIT) { + cg_demo.mode = CG_DEMO_CAM_LOCKED; + cgi.Print("Orbit camera disabled\n"); + return; + } + + if (cg_demo.follow < 0 || cg_demo.follow >= MAX_ENTITIES || + !(cgi.client->entities[cg_demo.follow].current.effects & EF_CLIENT)) { + cg_demo.follow = Cg_DemoFirstPlayer(); + } + + if (cg_demo.follow < 0) { + cgi.Print("No players to orbit\n"); + return; + } + + cg_demo.mode = CG_DEMO_CAM_ORBIT; + cg_demo.orbit_valid = false; + Cg_DemoSeedSmoothing(); + + cgi.Print("Orbit camera on player %d\n", cg_demo.follow); +} + +/** + * @brief demo_cinematic — one-shot toggle for a clean cinematic look: letterbox + * bars on, HUD and timeline hidden, extra camera smoothing. Toggling off + * restores the defaults. + */ +static void Cg_DemoCinematic_f(void) { + static bool on = false; + + if (!cgi.client->demo_server) { + cgi.Print("Not playing a demo\n"); + return; + } + + on = !on; + + if (on) { + cgi.Cbuf("cg_demo_letterbox 0.12; cg_draw_demo_hud 0; cg_draw_demo_bar 0; cg_demo_smoothing 0.3\n"); + cgi.Print("Cinematic mode enabled\n"); + } else { + cgi.Cbuf("cg_demo_letterbox 0; cg_draw_demo_hud 1; cg_draw_demo_bar 1; cg_demo_smoothing 0.15\n"); + cgi.Print("Cinematic mode disabled\n"); + } } #pragma mark - Camera paths @@ -430,17 +639,71 @@ void Cg_UpdateDemoView(void) { Cg_UpdateDemoFreeCamera(); } else if (cg_demo.mode == CG_DEMO_CAM_FOLLOW) { Cg_UpdateDemoFollowCamera(); + } else if (cg_demo.mode == CG_DEMO_CAM_ORBIT) { + Cg_UpdateDemoOrbitCamera(); } else if (cg_demo.mode == CG_DEMO_CAM_PATH) { Cg_UpdateDemoPathCamera(); } } +/** + * @brief Applies the cinematic FOV override during demo playback. When + * cg_demo_fov is greater than zero it replaces the computed field of view, + * allowing dramatic wide or zoomed shots. Called after Cg_UpdateFov. + */ +void Cg_UpdateDemoFov(void) { + + if (!cgi.client->demo_server) { + return; + } + + const float fov = Clampf(cg_demo_fov->value, 0.f, 170.f); + if (fov <= 0.f) { + return; + } + + // fov.x/fov.y are half-angles; derive them exactly as Cg_UpdateFov does + cgi.view->fov.x = fov / 2.f; + + const float width = cgi.view->viewport.z; + const float height = cgi.view->viewport.w; + + cgi.view->fov.y = Degrees(atanf(tanf(Radians(fov / 2.f)) * height / width)); +} + +/** + * @brief Draws cinematic letterbox bars (top and bottom) during demo playback. + * cg_demo_letterbox is the fraction of the screen height each bar covers. + */ +void Cg_DrawDemoLetterbox(void) { + + if (!cgi.client->demo_server) { + return; + } + + const float frac = Clampf(cg_demo_letterbox->value, 0.f, 0.45f); + if (frac <= 0.f) { + return; + } + + const GLint w = cgi.context->w; + const GLint h = cgi.context->h; + const GLint bar = (GLint) (h * frac); + + cgi.Draw2DFill(0, 0, w, bar, color_black); + cgi.Draw2DFill(0, h - bar, w, bar, color_black); +} + /** * @brief Draws the demo timeline scrubber bar during playback. The demo time * and duration come from the server via the CS_DEMO_STATUS config string. */ void Cg_DrawDemoBar(void) { + if (!cg_draw_demo_bar->integer) { + return; // bar hidden by the user + } + if (!cgi.client->demo_server) { return; } @@ -483,6 +746,21 @@ void Cg_DrawDemoBar(void) { cgi.Draw2DString(barX, barY - 22, text, color_white); } +/** + * @brief Resets all demo camera state. Called when a server connection is + * established (Cg_ClearState) so the camera mode, follow target, smoothing, + * orbit, and cinematic path start clean for each demo rather than leaking from + * the previously played one. + */ +void Cg_ClearDemo(void) { + + memset(&cg_demo, 0, sizeof(cg_demo)); + cg_demo.mode = CG_DEMO_CAM_LOCKED; + cg_demo.follow = -1; + + cg_demo_cam_count = 0; +} + /** * @brief Registers the demo camera cvar and commands. */ @@ -491,9 +769,42 @@ void Cg_InitDemo(void) { cg_demo_freecam = cgi.AddCvar("cg_demo_freecam", "1", CVAR_ARCHIVE, "Enables the free spectator camera during demo playback."); + cg_draw_demo_bar = cgi.AddCvar("cg_draw_demo_bar", "1", CVAR_ARCHIVE, + "Draws the demo playback timeline bar. Set to 0 to hide it."); + + cg_draw_demo_hud = cgi.AddCvar("cg_draw_demo_hud", "1", CVAR_ARCHIVE, + "Draws the HUD during demo playback. Set to 0 for a clean cinematic view."); + + cg_demo_letterbox = cgi.AddCvar("cg_demo_letterbox", "0", CVAR_ARCHIVE, + "Cinematic letterbox bar size during demo playback (0 to 0.45 of screen height)."); + + cg_demo_fov = cgi.AddCvar("cg_demo_fov", "0", CVAR_ARCHIVE, + "Cinematic field of view override during demo playback (0 to use the normal fov)."); + + cg_demo_roll = cgi.AddCvar("cg_demo_roll", "0", CVAR_ARCHIVE, + "Cinematic free-camera roll (Dutch angle), in degrees."); + + cg_demo_smoothing = cgi.AddCvar("cg_demo_smoothing", "0.15", CVAR_ARCHIVE, + "Follow/orbit camera smoothing, in seconds of lag (0 to disable)."); + + cg_demo_orbit_radius = cgi.AddCvar("cg_demo_orbit_radius", "180", CVAR_ARCHIVE, + "Cinematic orbit camera radius, in units."); + + cg_demo_orbit_height = cgi.AddCvar("cg_demo_orbit_height", "64", CVAR_ARCHIVE, + "Cinematic orbit camera height above the target, in units."); + + cg_demo_orbit_speed = cgi.AddCvar("cg_demo_orbit_speed", "30", CVAR_ARCHIVE, + "Cinematic orbit camera angular speed, in degrees per second."); + cgi.AddCmd("demo_freecam", Cg_DemoFreecam_f, CMD_CGAME, "Toggle the free-fly camera while watching a demo."); + cgi.AddCmd("demo_orbit", Cg_DemoOrbit_f, CMD_CGAME, + "Toggle the cinematic auto-orbit camera while watching a demo."); + + cgi.AddCmd("demo_cinematic", Cg_DemoCinematic_f, CMD_CGAME, + "Toggle a clean cinematic look (letterbox, no HUD, smoothing)."); + cgi.AddCmd("demo_follow_next", Cg_DemoFollowNext_f, CMD_CGAME, "Follow the next player while watching a demo."); diff --git a/src/cgame/default/cg_demo.h b/src/cgame/default/cg_demo.h index 58393b7fd..4127d2f3c 100644 --- a/src/cgame/default/cg_demo.h +++ b/src/cgame/default/cg_demo.h @@ -24,12 +24,17 @@ #if defined(__CG_LOCAL_H__) extern cvar_t *cg_demo_freecam; +extern cvar_t *cg_draw_demo_bar; +void Cg_ClearDemo(void); bool Cg_DemoInFreeCamera(void); +bool Cg_DemoHidesHud(void); bool Cg_DemoOverridingView(void); void Cg_DrawDemoBar(void); +void Cg_DrawDemoLetterbox(void); void Cg_InitDemo(void); void Cg_PredictDemoCamera(const GPtrArray *cmds); +void Cg_UpdateDemoFov(void); void Cg_UpdateDemoView(void); #endif /* __CG_LOCAL_H__ */ diff --git a/src/cgame/default/cg_main.c b/src/cgame/default/cg_main.c index bb9938eb8..45f0627a5 100644 --- a/src/cgame/default/cg_main.c +++ b/src/cgame/default/cg_main.c @@ -366,6 +366,8 @@ static void Cg_ClearState(void) { Cg_ClearHud(); Cg_ClearUi(); + + Cg_ClearDemo(); } /** @@ -451,13 +453,17 @@ static void Cg_UpdateScreen(const cl_frame_t *frame) { } else { - Cg_DrawHud(&frame->ps); + if (!Cg_DemoHidesHud()) { + Cg_DrawHud(&frame->ps); - Cg_DrawScores(&frame->ps); + Cg_DrawScores(&frame->ps); + } Cg_DrawDemoBar(); } + Cg_DrawDemoLetterbox(); // cinematic bars, drawn over everything + Cg_CheckEditor(); } diff --git a/src/cgame/default/cg_view.c b/src/cgame/default/cg_view.c index 876a3a53c..c42e750ff 100644 --- a/src/cgame/default/cg_view.c +++ b/src/cgame/default/cg_view.c @@ -349,6 +349,8 @@ void Cg_PrepareView(const cl_frame_t *frame) { Cg_UpdateFov(); + Cg_UpdateDemoFov(); + if (!Cg_DemoOverridingView()) { Cg_UpdateBob(ps1); } diff --git a/src/cgame/default/ui/controls/DemoBindsViewController.json b/src/cgame/default/ui/controls/DemoBindsViewController.json index 020a574b0..13c0f71bf 100644 --- a/src/cgame/default/ui/controls/DemoBindsViewController.json +++ b/src/cgame/default/ui/controls/DemoBindsViewController.json @@ -45,6 +45,18 @@ "bind": "demo_freecam" } }, + { + "class": "Input", + "label": { + "text": { + "text": "Orbit camera" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_orbit" + } + }, { "class": "Input", "label": { @@ -68,34 +80,67 @@ "class": "BindTextView", "bind": "demo_follow_prev" } + } + ] + } + }, + { + "class": "Box", + "label": { + "text": { + "text": "Cinematic" + } + }, + "contentView": { + "subviews": [ + { + "class": "Input", + "label": { + "text": { + "text": "Toggle cinematic mode" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cinematic" + } }, { "class": "Input", "label": { "text": { - "text": "Add camera keyframe" + "text": "Toggle HUD" } }, "control": { "class": "BindTextView", - "bind": "demo_cam_add" + "bind": "toggle cg_draw_demo_hud" } }, { "class": "Input", "label": { "text": { - "text": "Play camera path" + "text": "Toggle timeline bar" } }, "control": { "class": "BindTextView", - "bind": "demo_cam_play" + "bind": "toggle cg_draw_demo_bar" } } ] } - }, + } + ] + }, + { + "class": "StackView", + "classNames": [ + "column", + "container" + ], + "subviews": [ { "class": "Box", "label": { @@ -191,6 +236,78 @@ } ] } + }, + { + "class": "Box", + "label": { + "text": { + "text": "Camera path" + } + }, + "contentView": { + "subviews": [ + { + "class": "Input", + "label": { + "text": { + "text": "Add keyframe" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_add" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Play / stop path" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_play" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Clear path" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_clear" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Save path" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_save" + } + }, + { + "class": "Input", + "label": { + "text": { + "text": "Load path" + } + }, + "control": { + "class": "BindTextView", + "bind": "demo_cam_load" + } + } + ] + } } ] }