Skip to content

Audio Authoring

How to name, place, and wire a sound. Architecture and routing: 01_Audio_Architecture.md.

Naming standard (D7)

Engine assets — prefix is determined by the asset's class, enforced by Eternal.Audio.Audit:

Class Prefix Example
SoundWave SW_ SW_Sword_Blood_01
MetaSound Source MSS_ MSS_FoleySound_Dirt_Run
MetaSound Patch MSP_ MSP_StemMixer
SoundCue (legacy only — new work uses MetaSounds) SC_ SC_Equip_Jewelry
Sound Attenuation SAT_ SAT_FootstepsSoundAttenuation
Sound Submix SMX_ SMX_SFXCombat
SoundClass SCL_ SCL_Heartbeat
Sound Concurrency SCC_ SCC_SFXCombat
Control Bus (AudioModulation) SCB_ SCB_Greed
Modulation Patch (AudioModulation) SCP_ SCP_AmbienceDuck
Reverb Effect RVB_ RVB_Cave_Reverb
Submix Effect Preset SFXP_ SFXP_Reverb_Default

Descriptors are PascalCase_With_Underscores, variations zero-padded two digits (_01). System context goes in the name when the folder alone is ambiguous (SW_Footstep_Carpet_01, SW_Foley_Dirt_01).

External source files (WAVs outside the engine) use a different convention — see 03_External_Deliverable_Spec.md. Raw masters live outside Content/ (sibling of ArtSource/).

Folder layout

  • Content/Audio/_Common/Mix/ — the entire routing graph (submixes, classes, concurrency, control buses).
  • Content/Audio/<System>/ — playable assets grouped by system (Combat, Footsteps, Movement, Music, UI, Levels/, Creature, …).
  • Level-specific one-offs live under Content/Audio/Levels/<Level>/.

Wiring recipes (what exists today)

Montage-timed combat sound (swing, whoosh, effort)

Two routes, by what the sound needs:

  • Plain one-shot (swing whoosh, creature vocal): add an AnimNotify_PlaySound on the montage's Audio notify track, ~0.05–0.15s before the hit window opens. This is the established convention — attack montages across all weapon sets play MSS_Weapon_Swing this way, and creature attack/hit montages play the SC_Creature_Archanoid_* cues. Enforced: the AudioAudit "Montage windup audio" section warns on any montage that opens a ComboHitWindow with no audio notify by 0.25s after the first window opening (the grace absorbs hand-placed sounds that ride the swing feel).
  • Config-driven effect (audio + VFX + shake + hitstop as one authored row): add a UAnimNotify_CombatEffect notify with an effect tag, then add/edit that tag's row in Content/DataAssets/Combat/CombatEffectsConfiguration EffectConfigurations and add an Audio layer (FAudioEffectLayer) to its Layers with the sound. A row plays only the channels it has layers for — an audio-only row is just a single Audio layer, no hit-stop zeroing needed. A row that should stay silent (VFX/shake-only) must set bExpectedSilent — unflagged silent rows warn in the audit, and a flagged row that still carries an audio layer errors.

Either way the notify rides montage replication — remote clients hear it with zero extra work.

Weapon × surface impact

Rows live in CombatEffectsConfiguration.ImpactMappings as (Combat.Weapon.* × Combat.Surface.*) → FCombatEffectConfig. Fallback order: exact pair → WeaponTypeDefaultsSurfaceTypeDefaultsDefaultHitEffect. The surface tag arrives automatically from the hit's physical material (UCombatEffectsSettings::GetSurfaceTag). All 10 surfaces resolve; you only author the pairs that should sound distinct.

Charge audio

ChargeComponent fires Audio.Charge.Weapon.Level1-3 into the effects manager; the tags are mapped in EffectConfigurations to SC_Charge_Weapon_Level1-3 (pitched 0.9/1.0/1.15 duplicates of the AttackUp riser — placeholder until commissioned charge SFX land). Swapping the sound = editing the row.

Item pickup / equip / inventory drop

FItemManifest fragments carry the item's inventory-interaction 2D sound (played on pickup and on dropping the item into the grid) — author on the item's manifest data asset:

  • Equipment items: FEquipmentFragment.ItemSound (the SC_Equip_* family covers metal/leather/jewelry/glass; all equipment manifests are wired).
  • Non-equipment items: FItemSoundFragment.ItemSoundUInventoryStatics::GetItemSound falls back to it when no equipment fragment exists. Wired: stone plates → SW_StonePlate_Placement, glyphs → SW_GlyphSocket (their glyph-wall placement sounds are separate, on StonePlateFragment.PlacementSound / the glyph controller). Cores, fragments and quest items stay silent until fitting commissioned SFX exist (organic/bone thunks, plan 3.x); the state-keyed loot drop stingers are plan 4.2.

UI button click / hover

Plain UButtons carry the sounds directly on their style: PressedSlateSoundSW_UI_Button_Click, HoveredSlateSoundSW_UI_Button_Hover (both routed SCL_UI). The whole widget fleet was wired 2026-07-07; new buttons should set both fields (headless recipe: load the button template by subobject path <WBP>.<Name>:WidgetTree.<Button> and round-trip widget_style). Full-screen click-catchers (e.g. the sequence skip button) stay silent on purpose. When the planned CommonButton migration happens, these move to shared UCommonButtonStyle assets — one place instead of per-widget fields.

Screen-effect sounds

FScreenEffectDefinition.Sound is read by UScreenEffectsController (owner-local 2D): the big-hit pulse plays its sound with the visual pulse; the low-health vignette plays its sound once when the Below35 gate engages. Neither may be a heartbeat or a loop — stamina owns the heartbeat channel. No assets are authored yet (external-delivery slot, plan 3.x).

Ability / status audio via the GameplayCue registry (zero code)

Any GameplayCue tag can play audio by adding a row to Content/DataAssets/Audio/GameplayCueAudioConfig (registered under Project Settings → Game → GameplayCue Audio). UEternalGameplayCueManager consults the registry after normal cue routing:

  • Sound — one-shot at the cue location on Executed and OnActive (burst cues, apply stings).
  • LoopingSound — attached to the target from OnActive/WhileActive until Removed (status/duration cues; late joiners pick the loop up via WhileActive without re-hearing the apply).
  • VolumeMultiplier/PitchMultiplier — placeholder tuning without duplicating the asset.

Lookup is by exact tag. Do not add rows for cues whose audio already flows through a dedicated config (weapon×surface impacts via GameplayCue.Combat.Impact, parry, weapon-identity loops, aura loops) — they would double-play. Wired today: GameplayCue.Status.ShockSC_SFX_Loop_Electric (shocked characters crackle for the status duration).

A cue that also owns a CombatEffectsConfiguration row must pick one path for sound. The poise cues are the worked example: GameplayCue.Poise.Sunder and .Stagger take their sound from the registry, while their Effects.Poise.* rows carry bExpectedSilent (no audio layer) and own only VFX / shake / hit stop. GameplayCue.Poise.ParryReflect went the other way and had its registry row removed entirely — which is why the debug window below will correctly report it as "no registry row".

Note where its sound actually ended up, because it is not the obvious row: Effects.Poise.ParryCrack (the cue's own row) is VFX-only. The fold described below moved the sound onto the defender's Effects.Parry row (MSS_Combat_Parry), so the attacker-side reflect cue and the sound that plays for it live on different rows. If you are tracing "where does the parry sound come from", neither the registry nor the cue's own row is the answer.

Layering a beat against the hit it lands inside

Combat state cues (a stagger, a break, a parry crack) fire on the same frame as the impact row of the hit that caused them. Three rules, learned the hard way:

  1. Do not solve masking with volume. Ducking the state cue under the hit is backwards, and turning it up just makes both loud. Give the beat its own SoundClass and duck the parent — see Ducking a beat out of its own bus.
  2. Subordinate cues should stay subordinate. The stagger "not yet" tell is deliberately quieter than the break shatter; contrast is what makes the break read, and it is spent by anything that raises the floor.
  3. Fold competing sounds rather than layering them. When the parry clang and the posture-crack fought, the crack replaced the clang in the Effects.Parry row — one sound, one meaning.

Round-robin presets keep repeated beats from reading as a loop: the poise stagger/sunder sets are presets of MSS_Weapon_Punch with round-robin selection and mild pitch variation, which is the cheapest way to add variation without new MetaSound graphs.

Name the asset after what it is, not where it came from. MSS_Poise_ParryCrack was renamed to MSS_Combat_Parry once the fold above made it the only parry sound rather than the poise-tier layer — the poise-namespaced name implied a layering that no longer existed. The Effects.Poise.ParryCrack tag kept its name, because it now unambiguously means the attacker-side reflect row, which is correctly poise-scoped.

Weapon-identity element loop

FWeaponEffectConfig (rows in Content/DataAssets/Combat/WeaponEffectsConfiguration) carries LoopingSound + LoopAttenuation next to its Niagara/overlay fields — attached at the weapon socket while the identity cue is active. No element loop assets are owned yet (the only live identity row is Bleed); commissioned SFX (plan 3.x) fill these.

Aura loop

UAuraVisualConfig layer 4 (bUseAudioLoop + LoopSound + LoopAttenuation) — attached to the aura owner, faded out when the aura GE is removed. No aura loop assets owned yet.

Routing a new sound into the mix

Set the wave/MetaSound's Sound Class to the bus it belongs to (SCL_SFXCombat, SCL_UI, …) and its attenuation to a shared SAT_ asset. MetaSound sources set their output submix likewise. The audit flags sounds with no class and sounds whose class chain doesn't root in SCL_Master. (All legacy waves were routed 2026-07-07 — the audit baseline is 0 routing violations.)

Ability audio layers (windup / travel / impact)

Every ability owes its applicable layers (plan 1.3 model; the create wizard lists them per archetype):

  • Windup — the action-initiation sound on the montage (swing whoosh, charge loop, body effort). Enforced by the AudioAudit montage check above.
  • Travel — only for abilities that spawn something moving: ProjectileConfig's LaunchSound/FlightLoopSound/FlybySound (both the player and enemy projectile hierarchies). The ability validator warns while all three are unset.
  • Impact — the connect, via the weapon×surface impact matrix or the ability's impact cue. The matrix always resolves (DefaultHitEffect thud fallback); distinct pairs are external content (plan 3.x).

A plain melee swing is two layers (windup + impact); travel only exists when something flies.

Music (stateful stem bed, plan 2.1/2.2)

UEternalMusicSubsystem (GameInstance, client-only) plays exactly one UMusicStateAsset on a Quartz clock. Author music as an asset, never as code:

  • UMusicStateAsset (Content/DataAssets/Audio/MusicState_*): tempo/meter + named stems + a state→gain matrix (Explore / Combat / Boss / Rest / RemnantRealm). All stems start sample-locked and never stop; a state change is a bar-quantized gain fade. Optional per-state EnterStinger + a LiberationStinger (domain boss kill).
  • Stem sounds must: route to SCL_Music, loop cleanly, and set VirtualizationMode = PlayWhenSilent — a muted stem that loses its transport position desyncs the whole bed when it fades back in. The audit enforces all three plus row/stem cross-references.
  • The active asset is EternalAudioSettings.DefaultMusicState (DefaultGame.ini) until per-Domain assets land (external deliverables, plan 3.x). Today's content is the recovered menu theme as a single placeholder stem.
  • Base state comes from where the player is: world-map node type (IsSafeZone → Rest, DomainHQ → Boss until the dungeon clears) and the Remnant Realm subsystem. A dungeon or HQ clear falls back to the zone's base state structurally, not on a timer.
  • Combat overlays the base state. UCombatEngagementSubsystem (server) maintains the replicated loose tag State.InCombat on a character's ASC for exactly as long as at least one living enemy has it as an attack target. The director reads the tag off the local pawn and holds Combat for CombatHoldSeconds past the last enemy disengaging, so a lull mid-fight doesn't cut the bed. Combat cannot outrank Boss or Remnant Realm — priority lives in EternalMusic::ResolveState.

Control buses (plan 2.4)

The music director is the sole writer: SCB_CombatIntensity follows the music state (0 rest → 1 boss), SCB_Depth follows the current node's effective depth (DepthNormalizationMax saturates it), and SCB_AmbienceVolume (volume semantics, default 1.0, attached to SMX_Ambience's output volume modulation) carries the combat duck — the intensity→duck mapping lives in the subsystem, not in an opaque curve asset. Never write these buses from gameplay code; route new inputs through the subsystem so the mix stays one system. (When mappings multiply, migrate to AudioModulation patches — SCP_ prefix is reserved.)

Not built yet (don't look for it)

  • Ambience beds / AudioGameplayVolumes — plan 2.3.
  • UUISoundSet — deliberately not built: plain-button style fields are the seam until the CommonButton migration (Oliwer ruling 2026-07-07), at which point shared UCommonButtonStyle assets take over.

Verifying your work

UnrealEditor-Cmd.exe <project.uproject> -run=AudioAudit -unattended -nopause -nullrhi

Zero naming violations is the standing baseline (established 2026-07-07). If your change adds one, rename before committing. Spam rule for later phases: any ability castable faster than 1/s needs round-robin variations + a concurrency cap.

The Eternal.Audio debug window (in PIE)

The audit is static analysis; it cannot tell you what you actually heard. The Cog window Eternal.Audio answers that at runtime:

Pane Shows
Live sounds Active AudioComponents — what is playing right now, and what is looping when it shouldn't be
Routed-cue history The last 128 gameplay cues that reached the registry, including the ones that matched no row

It exists because of a specific failure that no other tool catches: a new cue sound was authored, the beat played, and the honest report was "I mostly hear the hit thud" — with no way to tell whether the new sound was masked, mis-routed, or never played at all. "No row" entries are recorded rather than dropped for exactly that reason; a cue that legitimately owns its sound elsewhere will show up as "no row" and that is expected (see the poise cues above).

Recording lives in UEternalGameplayCueManager, so the history covers every routed cue regardless of who fired it.