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:
parent
6eaa490bb3
commit
7c4dd1ade7
13 changed files with 1984 additions and 32 deletions
321
src/AcDream.App/Audio/AmbientSoundController.cs
Normal file
321
src/AcDream.App/Audio/AmbientSoundController.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Audio;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||
|
||||
namespace AcDream.App.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Drives retail's region ambient soundscape: rebuild on an objcell change,
|
||||
/// drain the deadline queue every frame, and play each firing as a one-shot.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>Ambient</c> system is a weighted-accumulation + timer-queue
|
||||
/// engine, NOT looping voices. On every objcell change (24 m granularity)
|
||||
/// <c>CellManager::ChangePosition</c> @ <c>0x4559B0</c> rebuilds per-sound
|
||||
/// weights over the 3×3 landblock ring × 64 land cells each; 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 is simply a one-shot re-fired
|
||||
/// every <c>min_rate</c> seconds with a freshly rolled table pick and crossfade
|
||||
/// volume.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Continuous beds play from centre (no position, no pan, no distance
|
||||
/// attenuation); intermittent ones play positionally at a random accumulated
|
||||
/// bearing. Both go through the ambient volume knob, which retail applies
|
||||
/// TWICE — once in <c>PlayAmbientSound*</c> and again inside
|
||||
/// <c>GetAttenuation</c> — so the slider is effectively squared. That quirk is
|
||||
/// reproduced here because two independent research lanes byte-confirmed the
|
||||
/// double application (see TS-65).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AmbientSoundController
|
||||
{
|
||||
private readonly OpenAlAudioEngine _engine;
|
||||
private readonly DatSoundCache _cache;
|
||||
private readonly AmbientSoundScheduler _scheduler;
|
||||
private readonly AmbientSoundGatherer _gatherer;
|
||||
private readonly ISoundRandom _rng;
|
||||
private readonly List<AmbientSoundFiring> _firings = [];
|
||||
|
||||
private Region? _region;
|
||||
private Func<uint, ushort[]?> _landblocks = static _ => null;
|
||||
private uint _currentObjCell;
|
||||
private Vector3 _listenerPosition;
|
||||
private double _clock;
|
||||
private bool _suspended;
|
||||
|
||||
public AmbientSoundController(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
ISoundRandom? rng = null)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_rng = rng ?? new SoundRandom();
|
||||
_scheduler = new AmbientSoundScheduler(_rng);
|
||||
_gatherer = new AmbientSoundGatherer(_scheduler);
|
||||
}
|
||||
|
||||
/// <summary>Live instance count. Diagnostic use.</summary>
|
||||
public int InstanceCount => _scheduler.Instances.Count;
|
||||
|
||||
/// <summary>Instances holding a deadline. Diagnostic use.</summary>
|
||||
public int QueuedCount => _scheduler.QueuedCount;
|
||||
|
||||
/// <summary>
|
||||
/// Install the region whose authored ambient data drives the soundscape, and
|
||||
/// the terrain-word source for the 3×3 ring.
|
||||
/// </summary>
|
||||
public void InstallRegion(Region region, Func<uint, ushort[]?> landblocks)
|
||||
{
|
||||
_region = region ?? throw new ArgumentNullException(nameof(region));
|
||||
_landblocks = landblocks ?? throw new ArgumentNullException(nameof(landblocks));
|
||||
_currentObjCell = 0;
|
||||
_scheduler.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report the listener's cell and position. A change of objcell triggers the
|
||||
/// rebuild — retail's trigger is <c>CellManager::ChangePosition</c>, not a
|
||||
/// landblock streaming event, so the cadence is every 24 m rather than every
|
||||
/// 192 m.
|
||||
/// </summary>
|
||||
public void ObserveListener(
|
||||
uint objCellId,
|
||||
Vector3 position,
|
||||
Vector3 landblockLocalPosition,
|
||||
bool seenOutside = false)
|
||||
{
|
||||
_listenerPosition = position;
|
||||
if (_region is null || objCellId == _currentObjCell)
|
||||
return;
|
||||
|
||||
// Latch the new cell FIRST and unconditionally, the way
|
||||
// CellManager::ChangePosition assigns load_pos at its tail. Clearing it
|
||||
// inside the indoor branch would leave the change edge permanently
|
||||
// armed while standing still indoors.
|
||||
_currentObjCell = objCellId;
|
||||
|
||||
// Indoors is silent by design: retail's CEnvCell::add_ambient_sounds is
|
||||
// an empty folded ret and the EnvCell format carries no sound data. The
|
||||
// gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, so an
|
||||
// interior that can see the sky still gets the OUTDOOR set — a cottage
|
||||
// does not cut the ambience dead.
|
||||
if (IsIndoorCell(objCellId) && !seenOutside)
|
||||
{
|
||||
_scheduler.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
_gatherer.Rebuild(
|
||||
_region,
|
||||
(objCellId >> 16 << 16) | 0xFFFFu,
|
||||
landblockLocalPosition,
|
||||
_landblocks,
|
||||
_clock);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the ambient clock and fire everything now due. Called once per
|
||||
/// frame from the same tick that drives the rest of the effect system.
|
||||
/// </summary>
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (deltaSeconds > 0)
|
||||
_clock += deltaSeconds;
|
||||
|
||||
if (_suspended || !_engine.IsAvailable || _region is null)
|
||||
return;
|
||||
|
||||
_firings.Clear();
|
||||
_scheduler.Tick(_clock, _firings, _listenerPosition);
|
||||
Emit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop contributing while the world is being replaced. The scheduler's
|
||||
/// deadlines are dropped rather than paused: the next rebuild re-arms
|
||||
/// everything audible, which is what a cell change does anyway. Dropping
|
||||
/// them also avoids a salvo of every overdue bed firing at once on resume.
|
||||
/// </summary>
|
||||
public void Suspend()
|
||||
{
|
||||
_suspended = true;
|
||||
StopAll();
|
||||
}
|
||||
|
||||
public void Resume() => _suspended = false;
|
||||
|
||||
/// <summary>Drop every instance and deadline (world teardown / reset).</summary>
|
||||
public void StopAll()
|
||||
{
|
||||
_scheduler.Clear();
|
||||
_currentObjCell = 0;
|
||||
}
|
||||
|
||||
private void Emit()
|
||||
{
|
||||
foreach (AmbientSoundFiring firing in _firings)
|
||||
Play(firing);
|
||||
_firings.Clear();
|
||||
}
|
||||
|
||||
private void Play(in AmbientSoundFiring firing)
|
||||
{
|
||||
SoundTable? table = _cache.GetSoundTable(firing.Instance.SoundTableDid);
|
||||
if (table is null)
|
||||
return;
|
||||
|
||||
// The variant pick and the entry's probability gate apply to ambients
|
||||
// exactly as they do everywhere else — PlayAmbientSound* rolls the same
|
||||
// PlayProbability inline.
|
||||
var entry = SoundCookbook.Select(
|
||||
table,
|
||||
(DRWSound)(uint)firing.Instance.Descriptor.Sound,
|
||||
_rng);
|
||||
if (entry is null)
|
||||
return;
|
||||
|
||||
uint waveId = (uint)entry.Id;
|
||||
if (waveId == 0)
|
||||
return;
|
||||
|
||||
WaveData? wave = _cache.GetWave(waveId);
|
||||
if (wave is null)
|
||||
return;
|
||||
|
||||
// Retail pre-multiplies by the ambient knob here and GetAttenuation
|
||||
// multiplies by it again — the squared-slider quirk (TS-65).
|
||||
float volume = firing.Volume * _engine.AmbientVolume;
|
||||
|
||||
if (firing.Position is { } position)
|
||||
{
|
||||
_engine.PlayAmbient3DWave(waveId, wave, position, volume, entry.Priority);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continuous bed: from centre, no position and no attenuation — but
|
||||
// still through the SAME 16-voice priority pool as everything else.
|
||||
// Retail has one pool (SoundManager::playing_sounds_[0x10]); the UI pool
|
||||
// is ours, and parking beds there would let a portal cue chop one
|
||||
// mid-wave and would discard the entry's priority.
|
||||
_engine.PlayAmbientFromCenter(waveId, wave, volume, entry.Priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor land cells are <c>0x…FFFF</c> style ids below 0x0100 in the cell
|
||||
/// word; anything above that is an EnvCell (indoor), which retail gives no
|
||||
/// ambients.
|
||||
/// </summary>
|
||||
private static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-frame ambient step, as a typed collaborator. Extracted update owners
|
||||
/// may not retain delegates, so the effect phase takes this rather than an
|
||||
/// <c>Action<float></c>.
|
||||
/// </summary>
|
||||
public interface IAmbientFramePhase
|
||||
{
|
||||
void TickAmbient(float deltaSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the ambient controller to the live listener: reports the local
|
||||
/// player's cell and position (retail rebuilds on an objcell change, and reads
|
||||
/// the viewer's position), then drains the deadline queue.
|
||||
/// </summary>
|
||||
public sealed class AmbientFramePhase : IAmbientFramePhase
|
||||
{
|
||||
private readonly AmbientSoundController _ambient;
|
||||
private readonly IAmbientListenerSource _listener;
|
||||
|
||||
public AmbientFramePhase(AmbientSoundController ambient, IAmbientListenerSource listener)
|
||||
{
|
||||
_ambient = ambient ?? throw new ArgumentNullException(nameof(ambient));
|
||||
_listener = listener ?? throw new ArgumentNullException(nameof(listener));
|
||||
}
|
||||
|
||||
public void TickAmbient(float deltaSeconds)
|
||||
{
|
||||
if (_listener.TryGetListener(out AmbientListenerPose pose))
|
||||
{
|
||||
_ambient.ObserveListener(
|
||||
pose.ObjCellId,
|
||||
pose.Position,
|
||||
pose.LandblockLocalPosition,
|
||||
pose.SeenOutside);
|
||||
}
|
||||
_ambient.Tick(deltaSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The listener's pose, in both frames the ambient system needs.
|
||||
/// </summary>
|
||||
/// <param name="ObjCellId">The land/env cell — the rebuild trigger.</param>
|
||||
/// <param name="Position">
|
||||
/// The streamed-frame position, used for PLAYBACK (it is the frame the audio
|
||||
/// engine's listener lives in).
|
||||
/// </param>
|
||||
/// <param name="LandblockLocalPosition">
|
||||
/// The landblock-local position, x/y in <c>[0, 192)</c>, used for the CELL WALK.
|
||||
/// Mixing the two frames culls every contribution.
|
||||
/// </param>
|
||||
/// <param name="SeenOutside">
|
||||
/// True when an interior cell can see the sky; retail gives those the outdoor
|
||||
/// ambient set rather than silence.
|
||||
/// </param>
|
||||
public readonly record struct AmbientListenerPose(
|
||||
uint ObjCellId,
|
||||
Vector3 Position,
|
||||
Vector3 LandblockLocalPosition,
|
||||
bool SeenOutside);
|
||||
|
||||
/// <summary>Supplies the listener's current pose.</summary>
|
||||
public interface IAmbientListenerSource
|
||||
{
|
||||
bool TryGetListener(out AmbientListenerPose pose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAmbientListenerSource"/> over the canonical local-player movement
|
||||
/// owner. Retail's ambient listener is the same viewer the mixer uses; the cell
|
||||
/// is what decides when to rebuild.
|
||||
/// </summary>
|
||||
public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource
|
||||
{
|
||||
private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player;
|
||||
|
||||
public LocalPlayerAmbientListenerSource(
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player) =>
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
|
||||
public bool TryGetListener(out AmbientListenerPose pose)
|
||||
{
|
||||
if (_player.Controller is { } controller)
|
||||
{
|
||||
AcDream.Core.Physics.Position cell = controller.CellPosition;
|
||||
pose = new AmbientListenerPose(
|
||||
controller.CellId,
|
||||
controller.Position,
|
||||
cell.Frame.Origin,
|
||||
// Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside`,
|
||||
// so a sky-lit interior keeps the outdoor set. acdream's
|
||||
// seen_outside lives on the cell's collision record rather than
|
||||
// its Position, and resolving it here needs a physics-cache
|
||||
// lookup this source does not own — deferred as TS-66. Until
|
||||
// then every interior is silent, which is right for a dungeon
|
||||
// and wrong for a cottage.
|
||||
SeenOutside: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
pose = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue