Skip to content

feat: Add SoundEvent API for emitting sounds with parameters - #1402

Open
makadore wants to merge 2 commits into
roflmuffin:mainfrom
makadore:feat/sound-event
Open

feat: Add SoundEvent API for emitting sounds with parameters#1402
makadore wants to merge 2 commits into
roflmuffin:mainfrom
makadore:feat/sound-event

Conversation

@makadore

@makadore makadore commented Aug 25, 2026

Copy link
Copy Markdown

EmitSound takes volume and pitch arguments that never reach the client. It goes through CBaseEntity::EmitSoundFilter, and the CMsgSosStartSoundEvent that comes out of it carries no packed_params at all, so there is nothing for the client to apply. Captured from a live server, this is the whole message the current path sends:

soundevent_guid=347 soundevent_hash=3838664315 source_entity_index=5 seed=347

The source already says as much: m_nPitch is annotated "not working, can't fix, i think the game abandon it", and m_flVolume is a volume_atten rather than a volume. This was raised in #919, and #126 is still open.

This adds a SoundEvent that builds the message directly, so sound operator parameters can be chosen per emit.

using var sound = new SoundEvent("Weapon_AK47.Single");
sound.SourceEntityIndex = (int)player.Index;
sound.SetParam(SoundEvent.Volume, 0.5f);
sound.SetParam(SoundEvent.Pitch, 1.5f);
sound.EmitToAll();

Emit returns the guid, which SoundEvent.Stop takes to end a sound early. Recipients come from a RecipientFilter, so a sound can go to one player, one team, or everyone.

SetParam takes any public.* parameter name, not a fixed set, so anything the sound's operator stack reads can be driven from a plugin without a change here first:

sound.SetParam("public.velocity", 250f);
sound.SetParam(SoundEvent.Position, new Vector(0, 0, 0));

Volume, pitch and position are the three this was tested against. Whether any other parameter does something depends on the stack behind the sound, which is noted in the API documentation.

Implementation

Built on the core side using net messages, which is the approach suggested in the discussion.

packed_params is a flat sequence of parameters, each one a 32 bit hash of the parameter name, a type tag, a 16 bit length, then the value. The types that appear in game traffic are 0x02 for integers, 0x08 for floats and 0x0A for vectors. Parsing 365 parameters captured from ordinary gameplay leaves no remainder.

Two seeds are in play, and both go through the SDK's own MurmurHash2LowerCase: sound event names hash with 0x53524332, parameter names with STRINGTOKEN_MURMURHASH_SEED. Nothing about hashing is reimplemented here.

SetParam replaces an earlier value for the same parameter rather than appending one, so a reused sound event does not grow its blob.

EmitSound is left alone, so no existing behaviour changes.

Verification

Checked against the current game build on a live server:

  • the hash this produces for a sound name is identical to the one the game puts in its own message for that name
  • volume and pitch are audibly different across values, where the existing EmitSound path plays 1.0 and 0.05 identically
  • Stop cuts a sound short
  • 2500 emits, half of them deliberately abandoned to the finalizer, with recipients disconnected mid-playback: resident memory did not grow

SoundEventTests.cs covers the packing, the parameter replacement, the integer type, the empty case and the hash agreement. I could not get the in-game xunit runner to report results in my environment, and the existing RayTrace and UserMessage tests behave identically there, so I ran the same assertions through an equivalent harness instead. Built and run on both platforms: gcc 13 on Linux, and MSVC 14.44 through the same vsdevcmd and CMake steps CI uses on windows-latest. The new files compile clean on both, with no warnings of their own. The checks listed above pass on a Linux server and on a Windows one running the MSVC build, including the hash agreement with the game.

Not covered

CMsgSosSetSoundEventParams would let a playing sound be changed rather than only started and stopped, which is what a smooth fade needs. It reuses the same parameter blob and would be a small follow-up, left out here to keep this reviewable.

A sound name is resolved by the client, so a name that does not exist plays nothing and reports no error. The existing EmitSound behaves the same way. This is called out in the API documentation.

The discussion and the research behind it are @samyycX's, including the finding that the parameters are entirely net message based.

@makadore
makadore requested a review from roflmuffin as a code owner August 25, 2026 18:36
EmitSound goes through CBaseEntity::EmitSoundFilter, which sends the sound event
message without any packed parameters, so the volume and pitch arguments never
reach the client and have no effect.

Build the CMsgSosStartSoundEvent message directly instead, so sound operator
parameters can be set per emit. Volume and pitch work, and any other public.*
parameter the sound stack reads can be set as well.

EmitSound is left as it is, so nothing existing changes behaviour.
return Assert.Single(captured, sound => sound.Guid == (uint)guid);
}

private static List<Parameter> Unpack(byte[] packed)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test file should have all of the implementation knowledge of how the parameters are packed removed, so it's just testing the API and that running the tests do not crash the server.

What might be useful is to create a test that emits a specific sound audible to all players using all of the available parameters. i.e. play the AK sound pitched down in a loop increasing in volume pitch and distance, and then a player can connect to the test server to verify the results manually (if needed) by running css_itest AudibleSoundTest (example name)

The tests asked whether specific bytes landed in specific places, which pinned
them to the wire format rather than to the API. They now exercise the API and
check that a server survives it.

Adds an audible test that plays a sound to everyone with volume, pitch and then
position swept, so the result can be checked by ear with css_itest
AudibleSoundTest.

Also documents what SourceEntityIndex does, which is what decides whether
public.position is used at all.
@makadore

Copy link
Copy Markdown
Author

Done, and testing it turned up something worth having in the documentation.

The test file no longer knows anything about how parameters are packed. No hashes, no type tags, no unpacking, no message id, no hooks. It exercises the API and checks a server survives it: handles, guids, every value type, setting the same parameter twice, emitting to one player, stopping, disposing twice.

AudibleSoundTest plays a sound to everyone with volume, then pitch, then position swept, announcing each step in chat, so it can be checked by ear on its own with css_itest AudibleSoundTest.

Checking the sweep by ear is what caught the interesting part. Position appeared to do nothing, and I nearly wrote it up as delivered but inert, until I noticed I had been attaching the sound to the listener and then trying to move it away from them. SourceEntityIndex is what decides this, and it has three modes rather than two:

-1, the default plays at each recipient, public.position is ignored
0 plays at public.position in the world
any entity index attached to that entity and follows it

Zero is how the game emits placed sounds, which is visible in its own messages: they carry an entity index of 0 together with real map coordinates. With that, volume, pitch and position are all audible, including direction and falloff. That is now on SourceEntityIndex rather than left for the next person to work out.

The follow-up I mentioned, CMsgSosSetSoundEventParams for changing a sound while it plays, is ready whenever you want it. Say the word and I will open it separately rather than growing this one.

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.

2 participants