Audio/NZSound.cs

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.

NetworkingFile AccessNative Interop
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;
	}
}