Defines GunCue, a data container for per-gun sound recordings and packed recording names, and GunSounds, a static helper that builds SoundEvent instances by combining a template SoundEvent with a cue's recordings, caches built events, serializes them to keys, and can reconstruct events from those keys.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace SWB.Base;
/// <summary>
/// A gun sound BUILT IN CODE: the recordings to play, and the event the cue was converted from.
///
/// Package trim step 3 (2026-10-03, Docs/STEP3_GUN_SOUNDS.md). Every gun cue was its own `.sound` file, and those
/// files differed only in their recordings: their settings came in ten sets. A converted prefab names one of ten
/// TEMPLATE events in the SoundEvent field and puts the recordings here, and the cue's event is built at play time
/// from the two (GunSounds). About 6,500 files leave the package.
///
/// ⛔ BESIDE THE SoundEvent FIELD, NEVER INSTEAD OF IT. A prefab with no cue (the user's Kitbash guns, a new port that
/// `Tools/gun_cues.py` has not converted yet) plays its event exactly as before. Changing the field's type would have
/// silenced every one of them.
/// </summary>
public class GunCue
{
/// <summary>The recordings, in the event's order; the template's selection mode picks among them.</summary>
[Property] public List<SoundFile> Clips { get; set; } = new();
/// <summary>
/// OR the recordings kept in a gun audio pack (step 4, GunAudioPacks): their names, in the event's order. A cue has
/// one or the other: `Tools/gun_packs.py` moves a cue's whole list here, or leaves it.
///
/// ⛔ NAMES WITHOUT AN EXTENSION, for the reason `Event` gives: with `.vsnd` on the end the compiled prefab would
/// still "load" every file the packs exist to retire.
/// </summary>
[Property] public List<string> Packed { get; set; } = new();
/// <summary>The event this cue was converted from, WITHOUT its extension (`sounds/weapons/x/x.mag_in`): its name
/// in logs, and in the Kitbash Editor, which names sounds by it.
///
/// ⛔ NO `.sound` ON THE END. The prefab compiler takes a string ending in a resource extension for a dependency, so
/// with it the converted prefab kept "loading" the very events this exists to retire.</summary>
[Property] public string Event { get; set; }
/// <summary>Converted: play the built event, not the field.</summary>
public bool IsSet => Clips is { Count: > 0 } || Packed is { Count: > 0 };
}
/// <summary>
/// Builds a GunCue's event: the template's settings (every one, through `CopyFrom`: volume, pitch, decibels,
/// distance, falloff curve, occlusion, the Weapons mixer bus) with the cue's recordings. Built once per template and
/// recordings, then cached.
///
/// ⛔ `CopyFrom`, NEVER PROPERTY BY PROPERTY. A hand copy that missed `DefaultMixer` would put every gun on the master
/// bus, where voices steal each other (MixerBus, 2026-09-15); four old events rely on keys being ABSENT.
/// ⚠️ A TEMPLATE EDITED IN THE EDITOR reaches built cues after the next restart (or `Forget`).
/// </summary>
public static class GunSounds
{
/// <summary>What a relayed shot carries for a built cue, instead of a path.</summary>
public const string KeyPrefix = "gun:";
// ⚠️ A NAME NO OLDER STATIC HAS: hotload carries statics forward by name (INSTRUCTIONS.md).
static readonly Dictionary<string, SoundEvent> _builtGunCueEvents = new();
static readonly HashSet<string> _warnedGunCueKeys = new();
/// <summary>The event a field and its cue play: the field itself when the cue is unset.</summary>
public static SoundEvent Resolve( SoundEvent field, GunCue cue )
{
if ( cue is null || !cue.IsSet ) return field;
return Build( field, cue.Clips, cue.Packed );
}
/// <param name="packed">Recordings in a gun audio pack, by name (step 4): cut by GunAudioPacks, on the spot if a
/// gun's own `Prepare` has not cut them yet.</param>
public static SoundEvent Build( SoundEvent template, IReadOnlyList<SoundFile> clips, IReadOnlyList<string> packed = null )
{
if ( template is null || (clips is null && packed is null) ) return null;
var key = Key( template, clips, packed );
if ( _builtGunCueEvents.TryGetValue( key, out var ev ) ) return ev;
var sounds = (clips ?? new List<SoundFile>()).Where( c => c is not null ).ToList();
if ( packed is not null )
foreach ( var name in packed )
if ( GunAudioPacks.Get( name ) is { } cut ) sounds.Add( cut );
// ⛔ NO RECORDINGS LEFT MEANS SILENCE, NOT THE TEMPLATE'S. A template carries no recordings of its own; a cue
// whose files are gone says so once and plays nothing.
if ( sounds.Count == 0 )
{
if ( _warnedGunCueKeys.Add( key ) )
Log.Warning( $"[gun-cue] none of the recordings load: {key}" );
return null;
}
ev = new SoundEvent();
ev.CopyFrom( template );
ev.Sounds = sounds;
_builtGunCueEvents[key] = ev;
return ev;
}
/// <summary>The cue as text any machine can rebuild: `gun:<template>|<clip>;<clip>`.</summary>
public static string Key( SoundEvent template, GunCue cue ) => Key( template, cue?.Clips, cue?.Packed );
/// <summary>
/// `gun:<template>|<clip>;<clip>`: a file is its path (`….vsnd`), a packed recording its name, which has no
/// extension. That is how `FromKey` tells them apart.
/// </summary>
public static string Key( SoundEvent template, IReadOnlyList<SoundFile> clips, IReadOnlyList<string> packed = null )
=> KeyPrefix + template?.ResourcePath + "|"
+ string.Join( ";", (clips ?? new List<SoundFile>()).Where( c => c is not null ).Select( c => c.ResourcePath )
.Concat( packed ?? new List<string>() ) );
public static bool IsKey( string s ) => s is not null && s.StartsWith( KeyPrefix );
/// <summary>A relayed key back into the event the shooter played.</summary>
public static SoundEvent FromKey( string key )
{
if ( !IsKey( key ) ) return null;
if ( _builtGunCueEvents.TryGetValue( key, out var cached ) ) return cached;
var body = key[KeyPrefix.Length..];
var bar = body.IndexOf( '|' );
if ( bar <= 0 ) return null;
var template = ResourceLibrary.Get<SoundEvent>( body[..bar] );
if ( template is null ) return null;
// ⚠️ A FILE ENDS IN `.vsnd`; ANYTHING ELSE IS A PACKED RECORDING'S NAME (step 4)
var parts = body[(bar + 1)..].Split( ';', System.StringSplitOptions.RemoveEmptyEntries );
var clips = parts.Where( p => p.EndsWith( ".vsnd", System.StringComparison.OrdinalIgnoreCase ) )
.Select( p => SoundFile.Load( p ) ).ToList();
var packed = parts.Where( p => !p.EndsWith( ".vsnd", System.StringComparison.OrdinalIgnoreCase ) ).ToList();
return Build( template, clips, packed.Count > 0 ? packed : null );
}
/// <summary>Drop every built cue (after editing a template, without restarting).</summary>
[ConCmd( "nz_gun_cues_forget" )]
public static void Forget()
{
_builtGunCueEvents.Clear();
_warnedGunCueKeys.Clear();
}
}