Skip to content

POV demos: a shot right after a reload plays its firing sound instead of an empty click (#1029) - #1040

Merged
4 commits merged into
devfrom
fix/pov-dry-fire-click-1029
Oct 26, 2026
Merged

4 commits merged into
devfrom
fix/pov-dry-fire-click-1029

Conversation

@ccoventry

Copy link
Copy Markdown
Owner

Release note: Fixed: in POV demos, a Garand or K98 shot fired right after a reload no longer plays an empty-gun click instead of the shot (ds_sound_fix_pov_dry_fire, on by default).

Closes #1029

What was wrong

In a POV demo, the recording player's own gunshots are the events his game predicted. The demo saves those events, and playback replays them and nothing else: the game's weapon prediction doesn't run during a demo (HUD_PostRunCmd is never called). That's also why the empty-gun click (357_cock1.wav) never plays in playback, real or not.

A round the server fired that the recorder's game didn't predict has no event, so it plays nothing at all: no sound, no muzzle flash, no recoil. That's the "click". It happens when fire is pressed a moment before the gun is ready (all 4 cases in the known demo were 40-110 ms early).

It's about 1 shot in 360 (94 of 33,807 shots, 153 POV demos, 34 recorder names). Full findings are on #1029.

What this does

ds_sound_fix_pov_dry_fire (default 1; 2 logs every round), new module goldsrc-hooks/src/pov_dry_fire.rs.

  • It reads the server's clip count from CurWeapon.
  • It pairs each one-round drop with a saved shot sound from the half second before it.
  • For a drop with no saved sound, it calls the gun's own fire handler for the recorder. That's the same code a recorded shot uses, so it plays the sound, muzzle flash, shell and first-person fire animation.
  • The handlers are the ones missing_shots already notes. missing_shots gains play_fire_event, and EventArgs is shared.

Checked offline with the same logic over the 153 demos (pov_dry_fire_survey's SIM lines):

  • it plays 56 rounds;
  • 52 land on a shot the server's weapon data shows fired with no saved event;
  • the other 4 look the same on inspection;
  • it leaves the other 42 unsaved shots silent rather than risk doubling a shot.

Decisions

  • Trigger: the server's clip count, not "the client is about to click". The client never clicks during playback (measured on POST). The clip count catches the same shots, and a real empty press drops no clip, so it's never touched.
  • Only a one-round drop counts. Larger drops were all a weapon taken away at round end, and the first count after a weapon is put away is skipped (a switch or a death).
  • Pairing window 0.5 s, oldest saved sound first. Over the 153 demos, 0.5 s and 0.75 s play the same false rounds, and 0.5 s misses fewer real ones.
  • Seeks use the demo player's clock, not the engine's. The stepped forward seek replays seconds of messages while the engine clock runs smoothly. The first test build played 6 rounds during one seek; fixed.
  • No new address, so no new verify_*_offsets.py. CurWeapon's handler comes from msglog's table, which verify_msglog_table.py checks (passes). The fire handlers are noted at runtime. client.dll only, so both builds.
  • No cfg_scan / command_suggest.js tier. Tiers are for pipeline-internal commands. This is an ordinary user setting, allowed everywhere, like ds_player_fix_model_flicker.
  • Weapon id → fire script:
    • checked against saved events: ids 3-12, 20 and 24;
    • from the dod crate's id table: the rest (British guns, MGs), which no surveyed demo fired.
  • Not part of ds_spec_match_pov; HLTV is untouched (IsSpectateOnly()).

Test done

All on ktps8w3-dyelife_ih_anzio_h1 with this branch's DLL and ds_sound_fix_pov_dry_fire 2, Brain account.

POST (probe local\game-probe\20261025-005939):

  • Played straight from 835 s: one stand-in, the Garand round at 855.81 s (2 left). The other 13 Garand rounds paired with their own saved sound, 42-109 ms before.
  • Your path (787.7 s, then a seek to 849.7 s): the same round, at 855.81 s.
  • Stepped forward seeks (818 → 849.7, 859 → 889, 895 → 958): no rounds played.
  • 961.89 s: the last-round stand-in (0 left).
  • Real empty press at 892.2 s: nothing.
  • ds_sound_fix_pov_dry_fire 0, then 850 → 859 s: no rounds played.
  • Muzzle flash and recoil: in the HLAE frames at the 855.8 shot with it on, none with it off (local\review-images\1029\post_fix_on_855_8.png, post_fix_off_855_8.png). In HLAE's sound.wav, an onset at 855.84 s that isn't there with it off.
  • First build: it played 6 rounds during the stepped seek; that's why seeks now use the demo player's clock.

PRE (probe local\game-probe\20261025-010145):

  • the same stand-ins at 855.80 s (straight), 855.85 s (after the seek) and 961.89 s;
  • nothing during seeks, at 892.2 s, or with the setting at 0;
  • muzzle flash in the frames (pre_fix_on_855_8.png) and a sound onset at 855.87 s.

R&D build, POST (logs every client sound, HUD_PostRunCmd and HUD_TxferPredictionData; probes 20261024-231648 and 20261024-233046):

  • HUD_PostRunCmd is never called during playback;
  • 357_cock1.wav never plays;
  • the 855.7 shot is silent with or without the seek.

Builds and tests:

  • cargo test -p goldsrc-hooks: 782 passed, 9 of them new;
  • clippy 1.98.1 -D warnings for host, i686 and analysis examples;
  • cargo fmt --check;
  • npm run test:unit: 854 passed;
  • app-boot.spec.js: 3 passed;
  • verify_msglog_table.py: OK.

Your test

Test DLL: local\test-1029\dodstudio_goldsrc_hooks.dll (pick 1029 in the Hooks DLL dropdown).

  1. POST or PRE: viewdemo ktps8w3-dyelife_ih_anzio_h1, then ds_demo_seek_to 849.7, and let it play.
  2. 14:15.7 on the demo bar (855.7 s): ddL's Garand kill on icyHOT ~ seanality. Listen for a Garand shot, and watch for the muzzle flash and the rifle's recoil. Before, it was silent.
  3. 16:01.9 (961.9 s): the same, the last round of a clip, so it should ping.
  4. 14:52.2 (892.2 s): a real empty press. Nothing should change.
  5. ds_sound_fix_pov_dry_fire 0, then seek back to 849.7. The 855.7 shot is silent again, as stock.

Other open PRs touching the same files

Whichever merges second regenerates console_commands_data.js.

🤖 Generated with Claude Code

ccoventry and others added 4 commits October 24, 2026 23:55
A POV demo's own gunshots are the recorder's predicted fire events, and
playback replays only those: weapon prediction does not run during a demo
(HUD_PostRunCmd is never called), so the client never plays its empty
click there either. A round the server fired that the recorder's client
did not predict (fire pressed a moment before the gun was ready) has no
event and plays nothing.

ds_sound_fix_pov_dry_fire (default 1) watches the server's clip count
(CurWeapon), pairs each drop with the recorded shot sound just before it,
and plays the gun's own fire handler for a round with none: sound, muzzle
flash, shell and fire animation. POV demos only; a real empty press drops
no clip and is left alone.

Also adds the probe and survey used to find it (153 POV demos, 34
recorder names: 220 of 33,937 shots have no recorded event).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or seeks

Checked against the same logic run over 153 POV demos (the survey's SIM
lines): a multi-round drop is a weapon taken at the round's end, the
first count after a weapon is put away is no round, and the engine clock
runs smooth through the demo player's stepped seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

POV demos: a shot right after a reload sometimes clicks instead of firing (Garand, K98)

1 participant