feat(audio): Campaign A slice A5 — retail's region ambient soundscape

acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 22:53:41 +02:00
parent 6eaa490bb3
commit 7c4dd1ade7
13 changed files with 1984 additions and 32 deletions

View file

@ -25,7 +25,8 @@ internal sealed record ContentAudioGraph(
OpenAlAudioEngine Engine,
DictionaryEntitySoundTable EntitySoundTables,
AudioHookSink? HookSink,
UiSoundController? UiSounds);
UiSoundController? UiSounds,
AmbientSoundController? Ambient);
internal sealed record ContentEffectsAudioResult(
IDatReaderWriter Dats,
@ -138,6 +139,9 @@ internal interface IContentEffectsAudioCompositionFactory
OpenAlAudioEngine engine,
DatSoundCache cache,
IDatReaderWriter dats);
AmbientSoundController CreateAmbient(
OpenAlAudioEngine engine,
DatSoundCache cache);
}
internal sealed class RetailContentEffectsAudioCompositionFactory
@ -253,6 +257,11 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
DatSoundCache cache,
IDatReaderWriter dats) =>
new(engine, cache, UiSoundTableResolver.Resolve(dats));
public AmbientSoundController CreateAmbient(
OpenAlAudioEngine engine,
DatSoundCache cache) =>
new(engine, cache);
}
internal enum ContentEffectsAudioCompositionPoint
@ -280,6 +289,7 @@ internal enum ContentEffectsAudioCompositionPoint
EntitySoundTablesCreated,
AudioSinkCreated,
UiSoundsCreated,
AmbientCreated,
AudioPublished,
AudioHookRegistered,
}
@ -485,6 +495,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
AudioHookSink? sink = null;
UiSoundController? uiSounds = null;
AmbientSoundController? ambient = null;
if (engine.IsAvailable)
{
sink = _factory.CreateAudioSink(engine, cache, soundTables);
@ -496,9 +507,12 @@ internal sealed class ContentEffectsAudioCompositionPhase :
+ "- interface cues silent"
: $"audio: UI sound bank = 0x{uiSounds.TableDid:X8}");
Fault(ContentEffectsAudioCompositionPoint.UiSoundsCreated);
ambient = _factory.CreateAmbient(engine, cache);
Fault(ContentEffectsAudioCompositionPoint.AmbientCreated);
}
graph = new ContentAudioGraph(cache, engine, soundTables, sink, uiSounds);
graph = new ContentAudioGraph(
cache, engine, soundTables, sink, uiSounds, ambient);
}
catch (Exception failure)
{