Static NZSound class defining named sound cue constants and utilities for playing, tracking, gating and debugging game audio. It exposes methods to play 2D and 3D sounds (including network-shared variants), volume multipliers, existence checks, audible-range caching, ambient budgeting/culling and basic tracing/count metrics.
using Sandbox;
using System;
namespace NZombies;
/// <summary>
/// Every sound the game plays, named in one place.
///
/// ⚠️ CALL SITES NAME A CUE, NEVER A FILE. The .sound assets under
/// Assets/sounds/nz own volume, pitch variance, random sample selection and
/// distance falloff — code that reaches past them to a .vsnd gets one fixed
/// sample at full volume with no 3D, and has to reinvent all of it in C#.
///
/// Generated by Tools/make_sound_events.py from the extracted nz_moo audio;
/// re-run it after adding samples rather than hand-editing the assets.
/// </summary>
public static class NZSound
{
/// <summary>A zombie going over on a Banana Colada slick bar.</summary>
public const string BananaSlip = "nz.banana.slip";
// ── the slide ────────────────────────────────────────────────────────────
// The originals, from "nZombies Rezzurrection Main Content" (workshop 3380229475),
// `sound/nz_moo/player/slide/`. The GMod system picks by SURFACE and plays a second
// clip when the slide ENDS — see gamemode/sliding/sh_hooks.lua, which keys two tables
// (`slide_sounds`, `end_sounds`) off `tr.MatType`.
//
// ⚠️ THE SURFACE SET IS NOT SYMMETRIC, and that is the source's own shape rather than a
// gap in the port: sliding has grass/metal/mud/snow/water plus a default, but ENDING has
// only grass/metal/mud plus a default. Snow and water end on the default clip.
//
// ⚠️ MAT_VENT AND MAT_GRATE ALIAS TO METAL upstream, in both tables. Whatever maps s&box
// surfaces onto these should keep that.
/// <summary>Sliding, on anything without a surface of its own.</summary>
public const string Slide = "nz.slide.default";
public const string SlideGrass = "nz.slide.grass";
public const string SlideMetal = "nz.slide.metal";
public const string SlideMud = "nz.slide.mud";
public const string SlideSnow = "nz.slide.snow";
/// <summary>Sliding through water. Upstream plays this ON TOP of the surface clip
/// whenever `WaterLevel() > 0`, rather than instead of it.</summary>
public const string SlideWater = "nz.slide.water";
/// <summary>The scrape that closes a slide.</summary>
public const string SlideEnd = "nz.slide.end";
public const string SlideEndGrass = "nz.slide.end.grass";
public const string SlideEndMetal = "nz.slide.end.metal";
public const string SlideEndMud = "nz.slide.end.mud";
/// <summary>Sliding over a Banana Colada slick — replaces the surface clip.</summary>
public const string SlideSquish = "nz.slide.squish";
public const string ZombieIdle = "nz.zombie.idle";
public const string ZombieAttack = "nz.zombie.attack";
/// <summary>
/// The claw LANDING on a player — distinct from ZombieAttack, which is the
/// lunge/voice as the swing starts.
///
/// The original keeps the same split: `AttackSounds` for the swing and
/// `AttackImpactSounds` for the impact (nz_zombiebase_moo.lua:7951, the
/// `_zhd` set). A swing that misses should still be heard; only a swing that
/// connects should sound like it hurt.
/// </summary>
public const string ZombieHit = "nz.zombie.hit";
public const string ZombieDeath = "nz.zombie.death";
/// <summary>
/// The gore (`ZombieAI.Gore.cs`): a limb or a head coming off, and the blood after it — the original's GibSounds, HeadGibSounds
/// and BloodGushSnds, from nz_moo_misc.gma, levelled beside the death cries (2026-09-28).
/// </summary>
public const string ZombieGoreLimb = "nz.zombie.gore.limb";
public const string ZombieGoreHead = "nz.zombie.gore.head";
public const string ZombieGoreGush = "nz.zombie.gore.gush";
public const string ZombieSprint = "nz.zombie.sprint";
public const string ZombieTaunt = "nz.zombie.taunt";
public const string ZombieBehind = "nz.zombie.behind";
public const string ZombieSpawn = "nz.zombie.spawn";
public const string ZombieStep = "nz.zombie.step";
public const string ZombieStepRun = "nz.zombie.step_run";
// ── HELLHOUND ────────────────────────────────────────────────────────────
// The Black Ops `vox/_devildog` set. Separate cues rather than a reskin of
// the walker's, because a dog round has to be identifiable by EAR — the
// original's whole tell is that you hear the pack before you see it.
/// <summary>Movement growl, the hound's ambient voice.</summary>
public const string HoundIdle = "nz.hound.idle";
/// <summary>Snarl reserved for close range. The original splits `move` from
/// `close` (12 clips vs 4) so a hound on top of you sounds different from one
/// across the map — that difference is the warning.</summary>
public const string HoundClose = "nz.hound.close";
/// <summary>The lunge. Pairs with <see cref="HoundHit"/> the same way
/// ZombieAttack pairs with ZombieHit — swing versus connect.</summary>
public const string HoundAttack = "nz.hound.attack";
/// <summary>The bite LANDING (`vox/chomp`).</summary>
public const string HoundHit = "nz.hound.hit";
public const string HoundDeath = "nz.hound.death";
public const string HoundStep = "nz.hound.step";
/// <summary>The spawn howl. Carries much further than the rest (2600) because
/// it is the round's announcement, not a positional cue.</summary>
public const string HoundSpawn = "nz.hound.spawn";
public const string HoundBark = "nz.hound.bark";
/// <summary>Samantha announcing a special round — the original's `DogRound`
/// event, `nz_moo/announcer/sammantha/announce_special.mp3`.
///
/// ⚠️ NOT AT THE ROUND'S START. The original fires it on a 3 second timer
/// (sv_round.lua:303) and holds the first hound back to 6 seconds, so the
/// announcement lands in silence and you have three seconds to hear it and
/// move before anything arrives. Playing it on the round change instead
/// buries it under the round sting.</summary>
public const string AnnouncerSpecial = "nz.announcer.special";
/// <summary>
/// The pest round's own announcement, in place of the hellhound one.
/// </summary>
///
/// ⚠️ BUILT FROM THE PEST'S OWN SPRINT VOX, pitched down and played dry on the Announcer
/// mixer — a swarm heard from everywhere rather than a voice line, because there is no pest
/// vox to speak one. It is a PLACEHOLDER with a real cue name: drop a proper recording into
/// `nz.announcer.pest.sound` and nothing in code changes.
///
/// ⛔ THE POINT IS THAT IT IS NOT SAMANTHA'S HELLHOUND LINE. Basalt's special round is a
/// fifty-strong pest horde, and announcing it with the dog cue tells the player to expect the
/// wrong thing — which is worse than announcing nothing.
public const string AnnouncerPest = "nz.announcer.pest";
public const string RoundStart = "nz.round.start";
public const string RoundEnd = "nz.round.end";
public const string GameOver = "nz.round.gameover";
/// <summary>
/// The swing itself, played on EVERY press whether or not it connects. A knife
/// silent on a miss gives no feedback that the input registered at all, which
/// reads as the key being broken rather than as the swing having missed.
///
/// ⛔ AIR ONLY — `zombies/fly/attack/whoosh/_og/swing_0*`, the pack's zombie-swipe
/// whoosh, which is a blade moving through nothing and hits no material.
///
/// ⚠️ THIS SLOT HELD `knife_slash_01..05` AND THAT WAS THE BUG. Those are the
/// blade LANDING in a body, so every swing sounded like a kill and hit, miss and
/// wall were indistinguishable — the branching in <see cref="NZombies.Knife"/>
/// was correct the whole time. A name that says "slash" describes the moment of
/// contact, not the wind-up; audition the sample or read the folder it came from
/// (this one sat under `attack/`, not `weapons/`) before trusting the filename.
/// </summary>
public const string Knife = "nz.knife";
/// <summary>
/// The knife going into a zombie — `weapons/knife/knife_slash_0*`, the pack's own
/// blade-in-flesh samples, now used for the hit they actually are.
///
/// ⚠️ LAYERED OVER the swing rather than replacing it, so a hit is the whoosh
/// AND the impact — the swing already happened, and cutting it off mid-flight to
/// substitute a hit sound is what makes melee feel disconnected.
///
/// ⚠️ Five samples so a flurry of hits does not machine-gun one waveform.
/// </summary>
public const string KnifeFlesh = "nz.knife.flesh";
/// <summary>
/// The frag going off — `weapons/arc9/bo1_rpg/rocket_explosion.wav` from the BO1
/// pack.
///
/// ⚠️ THE RPG'S BLAST, NOT THE FRAG'S. The pack ships exactly one explosion wav
/// and it belongs to the rocket launcher; searching for a grenade-specific one
/// returns only announcer lines about grenade rounds. Borrowed rather than
/// invented, and worth replacing if a frag cue ever turns up.
///
/// ⚠️ Distance 6000 and 95dB — an explosion is the one sound in this game that
/// should carry across the whole map, unlike the weapon cues which are tuned to
/// play at the ear.
/// </summary>
public const string GrenadeExplode = "nz.grenade.explode";
/// <summary>
/// Salvage collected. Four variants — `nz_moo/effects/pickup_salvage/pickup_00–03`.
///
/// ⚠️ THE ORIGINAL HAS A FALLBACK SET TOO (`bo6/other/salvage (1–4)`), used only when
/// the primary files are absent. Both exist in the packs; the primary ones are shorter
/// and are what `sv_killstreaks` prefers, so only they are ported. Recorded so nobody
/// hunts for a second set that was skipped on purpose.
/// </summary>
public const string PickupSalvage = "nz.pickup.salvage";
// -- PERK EFFECT CUES -----------------------------------------------------
/// <summary>
/// Elemental Pop's reload discharge. Six variants, picked at random by the event.
///
/// ⚠️ THIS IS ELECTRIC CHERRY'S SOUND, and that is correct rather than a stand-in:
/// this port's roster merges Cherry INTO Elemental Pop as its base, so
/// `NZ.Cherry.Shock` is the cue for exactly this effect. Files are
/// `nzr/2022/perks/cherry/zm_common.all.sabl.1796–1801`.
/// </summary>
public const string PerkCherryShock = "nz.perk.cherry_shock";
// ══ AMMO MOD CUES ════════════════════════════════════════════════════════
//
// ⛔ ALL TEN EXTRACTED FROM `nz_fox_stuff_cache.lua`, WHICH IS THE ONLY PLACE THE AAT SOUNDS
// ARE DEFINED. The gamemode addon ships zero audio — every sound is a path into the `nzr`
// workshop packs, and that file is the map from event name to wav list. Guessing the paths
// from the event names would not have worked: Fallout's live under `nzr/2024/aat/` while every
// other mod's are under `nzr/2022/perks/pop/`.
//
// ⚠ FIVE OF THE SIX MODS WERE SILENT BEFORE THIS. Only Fire Works had its sounds, and
// Dead Wire was borrowing Electric Cherry's shock. Nothing was wrong with the code; the
// assets simply had never been pulled.
/// <summary>Blast Furnace's detonation on a corpse. Four flame bursts.</summary>
public const string PopBlastFurnaceDie = "nz.pop.blastfurnace.die";
/// <summary>Blast Furnace's heavier incendiary crack. Three variants.</summary>
public const string PopBlastFurnaceExpl = "nz.pop.blastfurnace.expl";
/// <summary>Dead Wire's zap, per hop. Two variants.</summary>
public const string PopDeadwireShock = "nz.pop.deadwire.shock";
/// <summary>
/// Dead Wire's soul-drain wail. Seven variants.
///
/// ⚠ UPSTREAM PLAYS THIS ON EVERY ZAP, alongside the shock. Two overlapping cues per hop
/// across seven hops is a lot of noise, so ours plays it only when a zap KILLS — which is
/// what the name describes anyway.
/// </summary>
public const string PopDeadwireDie = "nz.pop.deadwire.die";
/// <summary>Thunderwall's launcher crack. One shot.</summary>
public const string PopThunderwallShoot = "nz.pop.thunderwall.shoot";
/// <summary>Cryofreeze freezing one zombie. Three variants.</summary>
public const string PopCryofreezeFreeze = "nz.pop.cryofreeze.freeze";
/// <summary>Cryofreeze thawing. Three variants.</summary>
public const string PopCryofreezeShatter = "nz.pop.cryofreeze.shatter";
/// <summary>Cryofreeze's blast of cold air. Two variants.</summary>
public const string PopCryofreezeWind = "nz.pop.cryofreeze.wind";
/// <summary>The fallout pit landing.</summary>
public const string AatFalloutStart = "nz.aat.fallout.start";
/// <summary>
/// The fallout pit's hum while it lasts.
///
/// ⚠ A LOOP FILE PLAYED AS A ONE-SHOT. Upstream starts it and calls
/// `StopSound` on removal; `Sound.Play` has no handle to stop, so the pit plays it once
/// at spawn and lets it run. The clip is long enough to cover a four-second pit, and a
/// hum that outlives the pit by a moment is better than one cut off mid-cycle.
/// </summary>
public const string AatFalloutLoop = "nz.aat.fallout.loop";
// ══ BRUTUS ═══════════════════════════════════════════════
//
// ⚠️ SEVEN OF THESE ARE NAMED BY `brutus.zvar`, NOT BY CODE. `ZombieVariant` stores sound
// events as plain strings, so the asset is what binds idle/close/attack/hit/death/spawn/step -
// and a typo there fails at LOAD with a green compile. These constants exist so the two can be
// compared by eye, and so anything firing one from C# has a name to use.
//
// ⚠️ `Helmet` AND `Swing` AND `Arrive` ARE THE CODE-SIDE ONES. The helmet break plays from
// `BrutusHelmet.Break`; the other two have no caller yet and are here because their clips were
// extracted with the rest.
/// <summary>The helmet coming off. Upstream plays both its helmet files at once.</summary>
public const string BrutusHelmetBreak = "nz.brutus.helmet";
/// <summary>
/// A shot landing on the helmet. Mapped to his gear cue.
///
/// ⚠️ SAME EVENT AS HIS MELEE IMPACT ON PURPOSE — `nz.brutus.hit` is `brutus_gear_00..04`, the
/// armour rustle, and it is the only metal-ish audio this project owns. Named separately so a
/// real armour-impact cue can be dropped in later without hunting call sites.
/// </summary>
public const string BrutusHelmetHit = "nz.brutus.hit";
/// <summary>Arrival voice lines - 14 of them.</summary>
public const string BrutusArrive = "nz.brutus.arrive";
/// <summary>Melee whoosh.</summary>
public const string BrutusSwing = "nz.brutus.swing";
/// <summary>The seven the variant asset names. Kept for comparison, not for calling.</summary>
public const string BrutusSpawn = "nz.brutus.spawn";
public const string BrutusIdle = "nz.brutus.idle";
public const string BrutusClose = "nz.brutus.close";
public const string BrutusAttack = "nz.brutus.attack";
public const string BrutusHit = "nz.brutus.hit";
public const string BrutusDeath = "nz.brutus.death";
public const string BrutusStep = "nz.brutus.step";
// ══ PEST ═════════════════════════════════════════════════
//
// ⚠️ ALL FIVE ARE NAMED BY `pest.zvar`, none from code — it has no unique ability, so there is
// nothing for C# to fire. That is not a gap: its character is speed and the swarm, and both are
// expressed in the variant.
/// <summary>
/// The flies.
///
/// ⛔ ITS IDLE IS AN INSECT SWARM, NOT A VOICE, AND THAT IS THE ENEMY. A WWII "Pest" is a
/// pestilence — thirteen `flies_pan` samples are what you hear before you see it, and they are
/// the only thing that makes it recognisable in a room full of groaning. Pointing this at the
/// sprint vox instead would have produced a fast zombie with no identity.
/// </summary>
public const string PestIdle = "nz.pest.idle";
/// <summary>Its running voice. Also the spawn and the close cue.</summary>
public const string PestSprint = "nz.pest.sprint";
/// <summary>
/// The lunge.
///
/// ⚠️ SAME UPSTREAM FOLDER AS `PestSprint`, split by filename. `..._sprint_attack_N` also starts
/// with `..._sprint`, so the plain sprint cue has to exclude the attacks explicitly — see the
/// FILTERS in make_sound_events.py.
/// </summary>
public const string PestAttack = "nz.pest.attack";
public const string PestHit = "nz.pest.hit";
public const string PestDeath = "nz.pest.death";
// ══ SHRIEKER ═════════════════════════════════════════════
//
// ⚠️ THE PACK CALLS IT "sonic" AND WE CALL IT THE SHRIEKER — its own `ENT.PrintName` does too.
// The rename happens once, in `extract_sounds.py`'s manifest, so nothing downstream has to
// remember two names for one enemy.
/// <summary>Its idle rasp — `vox_sonic_zombie_ambient_00..03`.</summary>
public const string ShriekerIdle = "nz.shrieker.idle";
/// <summary>The snarl when it is on you. Also its hit and melee cue.</summary>
public const string ShriekerClose = "nz.shrieker.close";
public const string ShriekerSpawn = "nz.shrieker.spawn";
public const string ShriekerDeath = "nz.shrieker.death";
/// <summary>
/// The wind-up before the scream — `evt_sonic_charge_up`, and no pitch variance on its event.
///
/// ⛔ IT IS THE ONLY WARNING THE BLIND HAS. The scream itself cannot be dodged once it starts,
/// so this cue is what tells a player to close the distance or break line of sight; pitching it
/// would stretch or shorten the window it is describing.
/// </summary>
public const string ShriekerCharge = "nz.shrieker.charge";
/// <summary>
/// The scream — `zmb_sonic_scream` plus `evt_sonic_attack_flux`, audible from 3000u.
///
/// ⚠️ THE FURTHEST-CARRYING CUE IN THE ZOMBIE SET, on purpose: an attack you are meant to hear
/// land on somebody else, in another room, and understand.
/// </summary>
public const string ShriekerScream = "nz.shrieker.scream";
/// <summary>The pulse it lets go when it dies — `zmb_sonic_explode`.</summary>
public const string ShriekerExplode = "nz.shrieker.explode";
// ══ NAPALM ZOMBIE ════════════════════════════════════════
//
// ⚠️ SIX OF THESE TEN ARE NAMED BY `napalm.zvar`, LIKE BRUTUS'S — idle/close/attack/hit/death/
// spawn/step are `ZombieVariant` strings, so the asset binds them and a typo there fails at
// LOAD with a green compile. The four below the line are the ones `NapalmZombie` fires itself,
// because nothing in `ZombieVariant` describes "winding up to explode".
/// <summary>Its burning groan — `zmb_napalm_ambient_00..03`.</summary>
public const string NapalmIdle = "nz.napalm.idle";
/// <summary>
/// The snarl when it has you.
///
/// ⚠️ THE PACK'S `attack/` SAMPLES, USED AS A PROXIMITY VOICE. This enemy has no melee swing to
/// play them on — it erupts instead — so they do the job Brutus's `close` cue does: telling you
/// it has noticed you without you having to look.
/// </summary>
public const string NapalmClose = "nz.napalm.close";
/// <summary>2D, like the walker's — the point is that one is behind YOU.</summary>
public const string NapalmBehind = "nz.napalm.behind";
/// <summary>
/// Taking a hit, and also dying.
///
/// ⛔ THE PACK SHIPS NO DEATH VOX FOR THIS ENEMY — `_napalm/` has amb, attack, behind, pain,
/// spawn, step and explosion, and no `death/`. Upstream does not need one: its napalm zombie
/// dies by exploding, and the explosion IS the death sound. Ours leaves a fire pool and wants a
/// voice under it, so `napalm.zvar` points `DeathSound` here too. Swap it the day a real one
/// turns up rather than leaving the slot empty, which would fall back to a WALKER's death
/// rattle coming out of a burning giant.
/// </summary>
public const string NapalmHit = "nz.napalm.hit";
public const string NapalmSpawn = "nz.napalm.spawn";
public const string NapalmStep = "nz.napalm.step";
// ── the four the component fires ─────────────────────────────
/// <summary>
/// The wind-up. One sample, `evt_napalm_charge`, and its event carries NO pitch variance.
///
/// ⛔ BECAUSE PITCH IS A PLAYBACK-RATE CHANGE AND THIS CUE IS A CLOCK. The usual "0.9 1.1"
/// would make a 2.5s charge last anywhere from 2.27s to 2.78s while the fuse it describes is
/// exactly `FuseSeconds` — finishing early leaves silence before the blast, finishing late is
/// still winding up after it. Both read as the enemy being broken.
/// </summary>
public const string NapalmCharge = "nz.napalm.charge";
/// <summary>
/// The blast — `zmb_napalm_explode` and `evt_napalm_zombie_explo`.
///
/// ⚠️ AUDIBLE FROM 4000u AGAINST A 380u BLAST, for the same reason the camera shake reaches
/// 1200: you are meant to know one went off across the map. A range matched to the damage
/// would turn the sound into a hit indicator.
/// </summary>
public const string NapalmExplode = "nz.napalm.explode";
/// <summary>The fire pool catching — `evt_zombie_flare_00/01`. Death blast only.</summary>
public const string NapalmFlare = "nz.napalm.flare";
/// <summary>
/// The loop it burns with, the whole time it is alive.
///
/// ⛔ THE ONLY LOOPING CUE IN THE ZOMBIE SET, AND THE REASON THE ENEMY IS FAIR. Everything else
/// about this thing kills you from outside melee range after a wind-up you may not have seen
/// start; the burning is what tells you one is in the room before any of that. It is played
/// from `NapalmZombie` as a tracked handle that follows the body and is stopped on death —
/// `Sound.Play( cue, position )` is a STATIC emitter and would stay where the zombie was born.
/// </summary>
public const string NapalmLoop = "nz.napalm.loop";
/// <summary>
/// The other half of the original pair (`NZ.Cherry.Sweet`, sabl.1789) — a one-off vox
/// line the GMod hooks play alongside the shock. Ported and NOT yet used, because
/// nothing has established when it should fire.
/// </summary>
public const string PerkCherrySweet = "nz.perk.cherry_sweet";
/// <summary>
/// Widow's Wine spending a grenade to snare.
///
/// ⛔ NOT THE ORIGINAL'S SOUND, AND THE ORIGINAL'S IS NOT AVAILABLE. GMod emits
/// `TFA_BO3_SPIDERNADE.Explode`, a soundscript belonging to a TFA SWEP addon — no
/// definition in any installed lua and no matching audio in any of the 102 workshop
/// packs, checked by name and by folder. This is `weapons/bo3/semtex/semtex_pin_pull`,
/// chosen because a pin pull IS the sound of spending a grenade, which is what the perk
/// does. Swap it the moment a better source turns up.
/// </summary>
public const string PerkWidowCharge = "nz.perk.widow_charge";
public const string PowerupPickup = "nz.powerup.pickup";
/// <summary>
/// The hum a powerup makes lying on the floor — `powerups/powerup_lp_zhd.wav`,
/// the original's own `PLP` loop.
///
/// ⛔ SHORT RANGE (900u) AND LOOPED. Its job is to tell you something dropped
/// NEARBY, the same way the Pack-a-Punch hum does — a powerup audible across the
/// map would have you hunting a sound you cannot reach before its 30 seconds are
/// up.
/// </summary>
public const string PowerupLoop = "nz.powerup.loop";
/// <summary>
/// Max Ammo — `powerups/maxammo_flux.mp3`, the flux sweep everyone knows.
///
/// ⚠️ Distance 0, so it does NOT attenuate. A powerup effect is a thing that
/// happened to the whole team; hearing a teammate's Max Ammo faintly because they
/// grabbed it two rooms away is worse than hearing it flat.
/// </summary>
public const string PowerupMaxAmmo = "nz.powerup.maxammo";
/// <summary>
/// Samantha saying it — `announcer/sammantha/announce_maxammo.mp3`.
///
/// ⛔ THE VOICE IS A SEPARATE CUE FROM THE FLUX, and both play. `maxammo_flux`
/// is the sound of the ammo arriving; this is the announcer telling the room what
/// happened. The original layers them and dropping either leaves the pickup
/// feeling half-finished — the flux alone is anonymous, the voice alone is flat.
///
/// ⚠️ 2D like the box's leave-announcement, and for the same reason: the
/// announcer is not IN the world. Every player needs to hear it wherever they
/// were standing when someone else grabbed it.
///
/// ⚠️ `richtofen/announce_maxammo.mp3` is the alternate voice, already in the
/// pack, if the announcer is ever made selectable.
/// </summary>
public const string AnnouncerMaxAmmo = "nz.announcer.maxammo";
/// <summary>Samantha: "Double Points!" — `announcer/sammantha/announce_2x.mp3`.</summary>
public const string AnnouncerDoublePoints = "nz.announcer.2x";
/// <summary>Samantha naming the bonus points drop.</summary>
public const string AnnouncerBonusPoints = "nz.announcer.bonus";
/// <summary>Samantha: "Carpenter!"</summary>
public const string AnnouncerCarpenter = "nz.announcer.carpenter";
/// <summary>Samantha: "Nuke!"</summary>
public const string AnnouncerNuke = "nz.announcer.nuke";
/// <summary>
/// The nuke going off — `powerups/nuke_flux.mp3`.
///
/// ⚠️ `nuke_ignite.mp3` is the OTHER half, for the blast itself when the effect
/// is wired. This one is the pickup flux, the counterpart to `maxammo_flux`.
/// </summary>
public const string PowerupNuke = "nz.powerup.nuke";
/// <summary>The Fire Sale jingle — `powerups/firesale_jingle.mp3`.</summary>
public const string PowerupFireSale = "nz.powerup.firesale";
/// <summary>Samantha: "Fire sale!" — `announcer/sammantha/announce_sale.mp3`.</summary>
public const string AnnouncerFireSale = "nz.announcer.firesale";
/// <summary>
/// "Insta-kill!" — `nz/powerups/insta_kill.mp3`.
///
/// ⚠️ FROM A DIFFERENT PACK to the other five. `nz_moo`'s Samantha and Richtofen
/// sets both ship every other powerup line and NOT this one; it lives in the props
/// archive (3380229475) beside the box jingle and the power cues, which is the
/// same pack that caught this out before.
///
/// ⛔ SEARCHING ONE ARCHIVE IS NOT SEARCHING. I first reported this line as
/// nonexistent after scanning `nz_moo_misc` alone — six packs carry insta-kill
/// audio. Scan every GMA before calling content missing.
/// </summary>
public const string AnnouncerInstaKill = "nz.announcer.insta";
public const string PerkVend = "nz.perk.vend";
/// <summary>
/// A teleporter firing, heard at the PAD. `weapons/tfa_waw/teslanade/teleport_out.wav`.
///
/// ⚠️ Carries to 2400 rather than a machine's 1200. A teleporter going off is a map event the
/// rest of the team should hear from another room — it is the cue that says somebody moved.
/// </summary>
public const string TeleporterOut = "nz.teleporter.out";
/// <summary>Arriving, heard at the DESTINATION. `nz_moo/effects/teleport_in_00.mp3`.</summary>
public const string TeleporterIn = "nz.teleporter.in";
/// <summary>The pad spinning up, one second before departure. `teslanade/warmup.wav`.</summary>
public const string TeleporterWarmup = "nz.teleporter.warmup";
/// <summary>
/// One soul caught by a soul box. 11 files, picked at random.
///
/// ⚠️ THE ENTITY ASKS FOR A PATH THAT NO LONGER EXISTS. `nz_script_soulcatcher` plays
/// `nz/souls/nuke_spirit/nuke_spirit0-10.wav` and not one of those eleven is on disk in this
/// pack version — but `nz_moo/zombies/vox/nuke_death/soul_00..10.mp3` is exactly eleven files
/// indexed 0..10, which is the same set under its current name. Renamed, not missing.
///
/// ⚠️ RANDOMISED ACROSS ALL ELEVEN, which is what the entity's own `math.random(0,10)` does.
/// Twenty identical chimes while filling one box would read as a stuck sound.
/// </summary>
public const string SoulCatch = "nz.soul.catch";
/// <summary>A soul box FILLED. Same eleven files, louder and carrying further, so it reads as
/// the bigger version of the cue you have been hearing rather than an unrelated sound.</summary>
public const string SoulFull = "nz.soul.full";
/// <summary>
/// What a RIDER hears in transit. `teleport.mp3`, and it is 2D.
///
/// ⛔ UI = TRUE IN THE EVENT, which is unusual for a world sound and is the point. Upstream
/// sends this with `ply:SendLua([[surface.PlaySound(...)]])` — to the travelling player only,
/// not emitted from the pad. It must not attenuate or be occluded by the wall they are about
/// to pass through, and nobody standing outside should hear it.
/// </summary>
public const string TeleporterCharge = "nz.teleporter.charge";
/// <summary>
/// The music-box tune, from the buy until the lid shuts.
///
/// The original's own cue — `sh_sounds.lua:89` names
/// `nz/randombox/random_box_jingle.wav`, played by `SpawnWeapon` the moment
/// the weapon starts rising (random_box/shared.lua:286).
///
/// ⚠️ IN A DIFFERENT PACK FROM ALMOST EVERYTHING ELSE — `nz/`, not `nz_moo/`,
/// so it lives in the props archive (3380229475) beside the power cues rather
/// than in nz_moo_misc. A scan of the usual pack finds nothing and reads as
/// "the mod has no box sound".
///
/// ⚠️ 7.18s, which is longer than a fast grab. The box STOPS it when the lid
/// shuts — a tune still playing out of a closed box tells every other player
/// there is something to run to.
/// </summary>
public const string BoxJingle = "nz.box.jingle";
/// <summary>
/// The bear. `nz/randombox/teddy_bear_laugh.wav`, played by `DoFinalSelection`
/// the instant the teddy is revealed (random_box_windup:319).
/// </summary>
public const string BoxTeddy = "nz.box.teddy";
/// <summary>
/// The announcer as the box leaves — `Announcer_Teddy_Zombies.wav`.
///
/// ⚠️ 2D, unlike the laugh. `MoveAway` plays it with `nzSounds:Play`, not
/// `PlayEnt` (random_box/shared.lua:374) — the announcer is not IN the world,
/// and every player needs to know the box has gone regardless of where they
/// were standing when it happened.
///
/// ⚠️ Delayed 0.25s after the leave starts, as the original does, so it lands
/// under the animation rather than on top of the laugh.
/// </summary>
public const string BoxBye = "nz.box.bye";
/// <summary>The box vanishing. `nz/randombox/poof.wav`.</summary>
public const string BoxPoof = "nz.box.poof";
/// <summary>
/// Pack-a-Punch chewing on a weapon. `nz/machines/pap_up.wav`, 5.90s.
///
/// ⚠️ Longer than the 3.5s it actually works, so it is still going when the
/// ready cue lands on top of it. That overlap is the original's too — the
/// machine is meant to sound like it is straining, not like it finished early.
/// </summary>
public const string PapWork = "nz.pap.work";
/// <summary>The weapon is done and waiting. `nz/machines/pap_ready.wav`.</summary>
public const string PapReady = "nz.pap.ready";
/// <summary>
/// The machine idling. `nz_moo/perkacolas/pap/pap_loop.wav`, 1.23s, looped.
///
/// ⚠️ SHORT RANGE (700u) on purpose. Its job is to tell you a Pack-a-Punch is
/// NEAR — audible round the corner, not across the map. The jingle is the cue
/// that carries.
///
/// ⚠️ Only while the power is on. The original gates every machine's ambience
/// the same way, and a humming machine you cannot use is a promise the map does
/// not keep.
/// </summary>
public const string PapLoop = "nz.pap.loop";
/// <summary>
/// The tune, every so often. `mus_packapunch_jingle.mp3`.
///
/// The original plays perk-machine jingles on a long random timer
/// (`NextJingle = CurTime() + math.random(0,600)`, perk_machine:159) rather
/// than on a loop — it is meant to be a thing you occasionally notice, not a
/// soundtrack.
/// </summary>
public const string PapJingle = "nz.pap.jingle";
/// <summary>Using the machine. `nz_moo/perkacolas/pap_sting.mp3`.</summary>
public const string PapSting = "nz.pap.sting";
/// <summary>
/// The extra crack an upgraded weapon makes, LAYERED OVER its own shot sound.
///
/// The original's `UpgradedShoot = "nz_moo/weapons/uber_shot.mp3"`. ⚠️ It does
/// not REPLACE the weapon's report — every gun keeps its own voice and gains a
/// signature on top, which is why one 0.01 MB sample works for all 31 rather
/// than needing a packed variant per weapon.
/// </summary>
public const string PapShoot = "nz.weapon.pap_shoot";
/// <summary>
/// Walking into a machine. `nz_moo/perkacolas/bump/vend_00..02.mp3`.
///
/// ⚠️ NOT NAMED FOR PACK-A-PUNCH, though that is the only thing using it today.
/// The original keeps these in a shared `bump/` folder because every vending
/// machine makes the same noise when you run into it, and the perks are next.
/// </summary>
public const string MachineBump = "nz.machine.bump";
// ── UI / MUSIC ───────────────────────────────────────────────────────────
//
// ⚠️ SOURCED FROM THE SAME PACK AS EVERYTHING ELSE, not written from scratch.
// The mod ships `effects/ui` — a real menu click set — and fourteen map
// LOADING-SCREEN tracks, which are the closest thing it has to menu music:
// already the right length and mood for a lobby. Added to the manifest in
// Tools/extract_sounds.py so the import stays reproducible.
//
// ⚠️ THE PACK SPLITS EVERY UI SOUND INTO `_fnt` AND `_rear` — front and rear
// surround channels of ONE cue, not two different sounds. Only the front half
// belongs in a 2D UI blip; playing both would just double it.
//
// Click is `main_click`, hover is the lighter `2nd_click` at half the volume —
// hover fires on every row the pointer crosses, so it has to sit under the
// click rather than next to it.
public const string UiClick = "nz.ui.click";
public const string UiHover = "nz.ui.hover";
public const string MusicLobby = "nz.music.lobby";
/// <summary>
/// One per second while the lobby counts down.
///
/// ⚠️ NOTHING AT ZERO — RoundManager already plays <see cref="RoundStart"/>
/// the moment the round begins, so a "go" here would double up with it.
/// </summary>
public const string UiCountdown = "nz.ui.countdown";
/// <summary>
/// Spending points, and being refused.
///
/// ⚠️ TAKEN FROM THE ORIGINAL'S OWN CALL SITES. `_PLAYER:TakePoints` plays
/// `effects/purchases/buy_classic.mp3` on every successful spend and
/// `_PLAYER:Buy` plays `effects/purchases/deny.wav` when the player cannot
/// afford it (points/sh_points.lua:103-146).
///
/// ⚠️ The whole purchases folder came across, so the buy sound can be swapped
/// for any of the other games' (`buy_t8`, `buy_iw7`, `buy_rd`, five `t10`
/// variants) by editing nz.purchase.sound — no re-extraction needed.
/// </summary>
public const string Purchase = "nz.purchase";
/// <inheritdoc cref="Purchase"/>
public const string PurchaseDeny = "nz.purchase.deny";
/// <summary>
/// A barrier coming down.
///
/// ⚠️ `sh_tools_door.lua` defaults every door and buyable prop to
/// `soundpath = "nz_moo/effects/disappear.wav"`, so this is what a cleared
/// debris plays unless a mapper overrides it per-barrier. We have no
/// per-barrier override yet, which is why this is one cue rather than a
/// lookup.
/// </summary>
public const string DebrisClear = "nz.debris.clear";
/// <summary>
/// A map's own sound for one of the game's moments (`MapConfig.Gameplay`: `RoundStartSound`, `RoundEndSound`,
/// `GameOverSound`, `DebrisSound`): the cue the loaded config names, or <paramref name="fallback"/>, the game's own,
/// when it names none.
/// </summary>
public static string MapCue( string configured, string fallback )
=> string.IsNullOrWhiteSpace( configured ) ? fallback : configured.Trim();
// ── basalt's own (2026-09-27): *"a deep stone gong or horn for Basalt's round start and end … and a soft chime when you
// enter a new room"*, and *"opening a debris could have a more ancient rock sound"*. Taken from the user's GMod packs
// (`sounds/nz/basalt/`), cut to fit, and matched in loudness to the cue each stands in for. Basalt's config names them.
/// <summary>BO4 Ancient Evil's round start, one of two, cut to 14 s with a 3 s fade — the game's own run 8.5 to 14 s.</summary>
public const string BasaltRoundStart = "nz.basalt.round.start";
/// <summary>
/// Ancient Evil's round end, one of two, cut to 9.5 s with a 2.5 s fade. ⚠️ SHORTER THAN THE GAP BETWEEN ROUNDS
/// (`RoundManager.PrepTime`, 10 s), so it is over before the next round's start plays: whole, it ran 18 s into it.
/// </summary>
public const string BasaltRoundEnd = "nz.basalt.round.end";
/// <summary>Ancient Evil's game over, cut to 25 s with a 4 s fade — the game's own run 21 to 36 s.</summary>
public const string BasaltGameOver = "nz.basalt.gameover";
/// <summary>Origins' Pack-a-Punch collapse — stone blocks coming down, 3.4 s — for a barrier opening.</summary>
public const string BasaltDebris = "nz.basalt.debris";
/// <summary>Origins' challenge-medal chime, soft and 2.5 s, for a player's first visit to a room (`RoomNames.NoteShown`).</summary>
public const string BasaltRoom = "nz.basalt.room";
/// <summary>
/// Basalt's mystery-box spin — MADE HERE, not taken from a pack (`Tools/basalt_box_sound.py`, 2026-09-27): a lid of stone
/// grinding open, twenty notes on struck stone in D minor pentatonic over an ember drone, and a chord and a stone gong as the
/// weapon arrives. On the original jingle's clock (7.2 s, the rise from 1.04 s to 5.64 s) and at its loudness, and played
/// the way it is — in the world, stopped when the lid shuts.
/// </summary>
public const string BasaltBoxSpin = "nz.basalt.box.spin";
/// <summary>
/// Basalt's lobby — MADE HERE (`Tools/basalt_lobby_music.py`, 2026-09-27): a 96 s stereo bed of drone, magma, wind, the
/// box's stones and a far gong, rendered on a circle so it loops without a seam (the lobby restarts it, `NZMusic.Tick`).
/// Set by heard loudness a little under the lobby tracks, and played the way they are.
/// </summary>
public const string BasaltLobby = "nz.basalt.lobby";
/// <summary>
/// Basalt's ambience — MADE HERE (`Tools/basalt_ambience.py`, 2026-09-28): 128 s of the mountain's rumble, wind in the halls,
/// lava bubbling far off, a stone settling now and then; no melody. Quiet on purpose, under the game (`MapAmbience`).
/// </summary>
public const string BasaltAmbience = "nz.basalt.ambience";
/// <summary>
/// Basalt's power motif — MADE HERE (`Tools/basalt_power_motif.py`, 2026-09-28): the box's figure (D A F A, G D C D) on two bronze
/// war horns an octave apart over war drums, a lyre and a low chant, climbing to a held D with the box's stones and the stone gong;
/// 12.8 s. Played over the game's power-on sound by `PowerMotif`, levelled near basalt's round start.
/// </summary>
public const string BasaltPower = "nz.basalt.power";
/// <summary>
/// A flame, for a wall buy — the Blast Furnace's four flame bursts (`sounds/nz/pop/blastfurnace/`), at the wall, levelled to the
/// cha-ching it stands in for. Basalt's `Gameplay.WallBuySound` (2026-09-28): *"replace the wallbuy sound with a flame sound"*.
/// </summary>
public const string WallBuyFlame = "nz.wallbuy.flame";
/// <summary>
/// Basalt's Easter egg music (`EggMusic`, 2026-09-28), from the user's GMod packs, each made a seamless loop and levelled to
/// -12 LUFS by `Tools/basalt_music.py`, the events at 0.40 on the Music mixer (about -20 LUFS heard):
/// - the boss fight's: Gorod Krovi's dragon fight (144 s); the alternatives, Zetsubou no Shima's Takeo fight (128 s) and Der
/// Eisendrache's boss fight "Dark Tech" (115 s);
/// - the altar defense's: Gorod Krovi's Pavlov's defend (114 s); the alternative, BO6's trial underscore (123 s).
/// `nz_egg_music boss|defend [cue]` plays one by hand and tries another.
/// </summary>
public const string BasaltMusicBoss = "nz.basalt.music.boss";
/// <inheritdoc cref="BasaltMusicBoss"/>
public const string BasaltMusicBossTakeo = "nz.basalt.music.boss.takeo";
/// <inheritdoc cref="BasaltMusicBoss"/>
public const string BasaltMusicBossDarkTech = "nz.basalt.music.boss.darktech";
/// <inheritdoc cref="BasaltMusicBoss"/>
public const string BasaltMusicDefend = "nz.basalt.music.defend";
/// <inheritdoc cref="BasaltMusicBoss"/>
public const string BasaltMusicDefendTrial = "nz.basalt.music.defend.trial";
/// <summary>
/// A board being ripped off a barricade.
///
/// ⚠️ The original's OWN cue, not the door sound. `breakable_entry`'s
/// DoPlankPullSequence names `nz_moo/barricade/snap/_old/snap_00..05.mp3` —
/// the `_old` set specifically, which is what the lua actually plays even
/// though a newer `break_*` set ships alongside it. Six samples, one per board.
/// </summary>
public const string BarricadeBreak = "nz.barricade.break";
/// <summary>
/// A board being nailed back on.
///
/// The original's own cue: `breakable_entry` plays `nz_moo/barricade/slam/
/// slam_00..05.mp3` from DoPlankRepairSequence — the counterpart to the snap
/// set, and the reason both were pulled from the pack together.
/// </summary>
public const string BarricadeRepair = "nz.barricade.repair";
/// <summary>
/// The electricity coming on, and going off again.
///
/// The original's own cues, named outright in
/// `gamemode/electricity/sh_sync.lua`: `nz/machines/power_up.wav` and
/// `power_down.wav`.
///
/// ⚠️ 2D, not positional — `UI: true` on both assets. The original plays
/// them with `surface.PlaySound`, which is heard by every player at full
/// volume wherever they are standing. That is the point: the power coming on
/// is a MAP-WIDE event, and a player across the map needs to know it happened
/// as much as the one who pulled the lever.
/// </summary>
public const string PowerOn = "nz.power.on";
public const string PowerOff = "nz.power.off";
/// <summary>Master switch, for testing without the noise.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>Log every cue as it fires, with where it was emitted and how far
/// that is from the listener. The only way to check trigger correctness
/// without listening to it.</summary>
public static bool Trace { get; set; }
/// <summary>Cue -> how many times it has fired. Survives across rounds so a
/// wave can be checked against its own zombie count.</summary>
public static readonly Dictionary<string, int> Counts = new();
static void Record( string cue, Vector3 at, bool positioned )
{
Counts[cue] = Counts.GetValueOrDefault( cue ) + 1;
if ( !Trace ) return;
// Distance to the LISTENER, not to the player — if those two ever differ
// (the camera trails the player), the listener is what the mix uses and
// the player position would be quietly misleading.
//
// ⚠️ Sound.Listener is a Transform STRUCT, not a nullable object — it can
// never be null and `?.` on it is a compile error.
var ear = Ear;
Log.Info( positioned
? $"[nz-audio] {cue,-20} at {at.x:0},{at.y:0},{at.z:0} "
+ $"{at.Distance( ear ):0}u from ear (#{Counts[cue]})"
: $"[nz-audio] {cue,-20} 2D (#{Counts[cue]})" );
}
static Scene Scene => Game.ActiveScene;
/// <summary>Where the game is listening from. This — not the player — is what
/// every 3D mix is computed against.</summary>
public static Vector3 Ear => Sound.Listener.Position;
/// <summary>
/// Ambient voice starts allowed per second. **0 means unlimited, and that is
/// the default.**
///
/// ⚠️ DESIGNER DECISION: NO VOICE LIMIT. Every zombie that wants to groan
/// gets to. A cap was tried at 6/sec and measured — with 35 alive it threw
/// away 38% of in-range groans — and was removed on the call that a horde
/// should sound like a horde. The engine is not the constraint either: 1024
/// concurrent 3D sounds all played with none refused (nz_sound_stress).
///
/// This stays settable ONLY as a performance instrument — if a big wave ever
/// costs frames, `nz_sound_budget 8` narrows it without touching anything
/// else. It is not a mixing tool and should not be left set.
///
/// ⚠️ CHANGING THIS DEFAULT IN CODE DOES NOT CHANGE A RUNNING GAME. Statics
/// survive a hotload with their current value, so after editing the initial
/// value here the old one is still live until something assigns it. Caught
/// exactly that: the cap read 6 for a full test run after being removed from
/// the source. Set it with nz_sound_budget, or restart play.
/// </summary>
public static int AmbientBudget { get; set; }
static int _ambientPlaying;
static TimeSince _sinceAmbientReset;
// ── PER-CUE VOLUME ───────────────────────────────────────────────────────
//
// A live multiplier per cue, so the mix can be balanced by ear in game and
// the numbers baked into the .sound assets afterwards.
//
// ⚠️ A MULTIPLIER, NOT AN ABSOLUTE. Each .sound already carries its own
// Volume, and that stays the source of truth — this scales it. So 1.0 always
// means "as authored", and what gets written back into the asset later is
// simply its Volume times this.
//
// ⚠️ RUNTIME ONLY — these do NOT survive a restart, by design. They are for
// finding the numbers, not for storing them.
static readonly Dictionary<string, float> _volume = new();
/// <summary>This cue's multiplier. 1.0 = exactly as the asset authored it.</summary>
public static float VolumeOf( string cue ) => _volume.GetValueOrDefault( cue, 1f );
public static void SetVolume( string cue, float mult )
{
if ( MathF.Abs( mult - 1f ) < 0.001f ) _volume.Remove( cue );
else _volume[cue] = mult;
}
public static void ResetVolumes() => _volume.Clear();
/// <summary>Only the cues actually moved off 1.0 — what needs baking in.</summary>
public static IEnumerable<KeyValuePair<string, float>> Adjusted => _volume;
/// <summary>
/// Scale a freshly started voice by its cue's multiplier.
///
/// ⚠️ BY REF, BECAUSE SoundHandle MAY BE A VALUE TYPE. ZombieAI already
/// carries a note about this — it writes handles back into its list after
/// mutating them "in case SoundHandle is a value type". Taking it by ref here
/// means the caller's copy is the one that gets scaled either way, so the
/// question never has to be answered.
/// </summary>
static void ApplyVolume( string cue, ref SoundHandle h )
{
float mult = VolumeOf( cue );
if ( mult == 1f || !h.IsValid() ) return;
h.Volume *= mult;
}
/// <summary>Play a 2D cue — UI, stings, anything non-diegetic.</summary>
public static SoundHandle Play( string cue )
{
if ( !Enabled ) return default;
Record( cue, Vector3.Zero, positioned: false );
try
{
var h = Sound.Play( cue );
ApplyVolume( cue, ref h );
return h;
}
catch ( Exception e ) { Missing( cue, e ); return default; }
}
/// <summary>
/// Play a UI cue, but only if its asset actually exists.
///
/// ⛔ A MISSING CUE DOES NOT THROW AND DOES NOT WARN — verified earlier in this
/// file: Sound.Play prints "Couldn't find sound event X" at Info level and
/// returns a dead handle. On a hover handler that is once per mouse move, so
/// the console fills with engine chatter and the actual problem (no asset) is
/// never stated. Checking first turns that into one line that says what to add.
/// </summary>
public static SoundHandle PlayUi( string cue )
{
if ( !Enabled ) return default;
if ( !Exists( cue ) )
{
if ( _warnedMissingAsset.Add( cue ) )
Log.Warning( $"[nz-audio] no asset for '{cue}' — nothing will play. "
+ $"Add Assets/sounds/nz/{cue}.sound pointing at a .vsnd. "
+ "See NZSound's UI/MUSIC block." );
return default;
}
return Play( cue );
}
static readonly HashSet<string> _warnedMissingAsset = new();
/// <summary>Play a cue in the world.</summary>
/// <summary>
/// Play here AND on every other machine. For things that exist only on the host.
///
/// ⚠️ USE THIS FOR A SOUND WHOSE SOURCE IS HOST-ONLY — zombies, above all. Everything else
/// should keep using <see cref="Play(string, Vector3)"/>: a sound both machines already make
/// for themselves would play twice on the client if it came through here.
///
/// ⚠️ IT RETURNS THE LOCAL HANDLE, so callers that track a voice (`TrackVoice`) keep working
/// unchanged — the remote copies are fire-and-forget, which is right for a one-shot at a
/// position nobody local is going to follow.
/// </summary>
/// <summary>
/// Play a POSITIONLESS cue here and on every other machine. For host-only announcements.
///
/// ⚠️ THE 2D TWIN OF `PlayShared`. A round sting or an announcer line has no place in the
/// world — giving it one makes it quieter when the player turns around, which for "round 12"
/// is simply wrong.
///
/// ⚠️ SAME RULE: only for things that happen on the host alone. A cue both machines already
/// fire for themselves would double on the client.
/// </summary>
public static SoundHandle PlayShared( string cue )
{
if ( Networking.IsActive && NZombies.NZGame.IsHost )
NZombies.NZNet.UiSound( cue );
return Play( cue );
}
public static SoundHandle PlayShared( string cue, Vector3 position, string category = null )
{
if ( Networking.IsActive && NZombies.NZGame.IsHost )
NZombies.NZNet.WorldSound( cue, position );
return Play( cue, position, category );
}
/// <param name="category">
/// Which <see cref="SoundGate"/> budget this call spends, when the cue alone cannot say.
///
/// ⛔ NEEDED BECAUSE THE CUE IS NOT ALWAYS A KNOWN NAME. `ZombieVariant` carries `IdleSound`,
/// `StepSound`, `AttackSound` and the rest as authored `[Property]` strings, so a hellhound or a
/// boss plays whatever a prefab names — and a gate that resolved categories from a fixed table
/// would let every one of those through ungated while looking like it was working. The caller
/// knows which budget it is spending; the cue string does not.
/// </param>
public static SoundHandle Play( string cue, Vector3 position, string category = null )
{
if ( !Enabled ) return default;
// ⛔ THE ONE CHOKE POINT, WHICH IS WHY THE GATE GOES HERE. PlayAmbient ends with
// `return Play( cue, position )`, so gating this covers crowd voices AND the one-shots
// (hit, death, spawn, impact) that bypass the ambient path entirely.
//
// ⚠️ IT IS NOT AmbientBudget AND DOES NOT REPLACE IT. That budget is a rolling ONE-SECOND
// window counter, and its own comment says why: there is no callback when a sound ends. A
// one-second window structurally cannot see thirteen impacts in a SINGLE frame, which is
// what hitscan penetration produces — so the gate counts per frame as well as live, and
// tracks real handles to do it. The two coexist; the budget is still unlimited by default.
if ( !SoundGate.Allow( cue, category ) ) return default;
Record( cue, position, positioned: true );
try
{
// ⛔ A GUN CUE BUILT IN CODE ARRIVES AS A KEY, NOT A PATH (Weapon.RelayShotSound): the template and the
// recordings, rebuilt here exactly as the shooter built them. A path plays as it always did.
var h = SWB.Base.GunSounds.IsKey( cue )
? (SWB.Base.GunSounds.FromKey( cue ) is { } built ? Sound.Play( built, position ) : default)
: Sound.Play( cue, position );
ApplyVolume( cue, ref h );
SoundGate.Note( cue, h, category );
return h;
}
catch ( Exception e ) { Missing( cue, e ); return default; }
}
/// <summary>
/// Play a crowd voice, subject to the ambient budget.
///
/// Returns a DEAD handle when the cue was dropped — by the budget or for
/// being out of earshot. Callers that follow their emitters can assign the
/// result unconditionally; a dead handle simply never needs moving.
/// </summary>
public static SoundHandle PlayAmbient( string cue, Vector3 position, string category = null )
{
if ( !Enabled ) return default;
// ⚠️ THIS IS NOT A LIMIT ON WHAT YOU CAN HEAR. The SoundEvent's falloff
// curve reaches zero AT its Distance, so anything past that is rendered
// silent by the engine regardless — dropping it changes no audio, it just
// stops paying for sound nobody receives. Traced groans firing at 2821
// units on a cue whose falloff ends at 1400.
//
// Toggleable anyway, so it can be ruled out rather than trusted.
if ( CullOutOfEarshot
&& AudibleRange( cue ) is float range
&& position.Distance( Ear ) > range )
{
CulledOutOfRange++;
return default;
}
// Unlimited by default — see AmbientBudget. Everything below this line
// only runs when someone has deliberately set a cap for testing.
if ( AmbientBudget > 0 )
{
// The counter is a rolling window, not a live voice count — we get no
// callback when a sound finishes, and tracking handles for 80 zombies
// to save a groan is not worth it. One second is longer than any of
// these samples, so the window under-counts rather than over-counts.
if ( _sinceAmbientReset > 1f )
{
_sinceAmbientReset = 0f;
_ambientPlaying = 0;
}
if ( _ambientPlaying >= AmbientBudget ) { DroppedOverBudget++; return default; }
_ambientPlaying++;
}
return Play( cue, position, category );
}
/// <summary>
/// Warn ONCE per cue, for genuine exceptions.
///
/// ⚠️ THIS DOES NOT CATCH A MISSING SOUND EVENT. Verified by asking for a
/// nonsense cue: Sound.Play does not throw, it prints "Couldn't find sound
/// event X" at Info level and returns a dead handle. So a typo'd cue is
/// silent, not loud, and no try/catch anywhere will change that — use
/// <see cref="Exists"/> / nz_sound_check to actually verify.
///
/// The guard stays because these are called from per-frame code, where a real
/// exception would otherwise put thousands of identical lines in the console.
/// </summary>
static void Missing( string cue, Exception e )
{
if ( !_warned.Add( cue ) ) return;
Log.Warning( $"[nz-audio] cue '{cue}' failed — {e.Message}. "
+ "Re-run Tools/make_sound_events.py if the asset is missing." );
}
static readonly HashSet<string> _warned = new();
/// <summary>Does this cue resolve to a real SoundEvent? The only honest
/// answer available, since playing a missing one fails quietly.</summary>
public static bool Exists( string cue )
=> ResourceLibrary.TryGet<SoundEvent>( $"sounds/nz/{cue}.sound", out _ );
/// <summary>
/// How far this cue carries, or null if it is 2D and carries everywhere.
///
/// Read from the SoundEvent so the number lives in ONE place — hardcoding it
/// in C# would mean retuning a falloff in the asset silently stops matching
/// what the cull believes.
/// </summary>
public static float? AudibleRange( string cue )
{
if ( _ranges.TryGetValue( cue, out var cached ) ) return cached;
float? range = null;
if ( ResourceLibrary.TryGet<SoundEvent>( $"sounds/nz/{cue}.sound", out var ev )
&& ev.DistanceAttenuation && ev.Distance > 0f )
range = ev.Distance;
_ranges[cue] = range;
return range;
}
static readonly Dictionary<string, float?> _ranges = new();
/// <summary>Skip ambient cues emitted beyond their own falloff distance. Not
/// an audible limit — those are silent either way — but it can be turned off
/// to prove that.</summary>
public static bool CullOutOfEarshot { get; set; } = true;
/// <summary>Ambient cues dropped for being out of earshot. Diagnostic only —
/// a high number next to a low play count means the horde is spread out, not
/// that anything is broken.</summary>
public static int CulledOutOfRange { get; private set; }
/// <summary>
/// Ambient cues dropped because the budget was already spent.
///
/// ⚠️ THIS IS THE NUMBER THAT SAYS WHETHER THE HORDE IS BEING THINNED. Out of
/// earshot is free — nobody could have heard those. This one is voices that
/// were in range and got refused, so a large ratio against the play count
/// means the budget is the thing deciding how big the crowd sounds.
/// </summary>
public static int DroppedOverBudget { get; private set; }
public static void ResetStats()
{
Counts.Clear();
CulledOutOfRange = 0;
DroppedOverBudget = 0;
}
}