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
|
|
@ -135,10 +135,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
|
||||
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
|
||||
|
||||
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
|
||||
private readonly Dictionary<int, uint> _ambientSources = new();
|
||||
private int _nextAmbientHandle = 1;
|
||||
|
||||
// ── Public volume knobs ──────────────────────────────────────────────────
|
||||
public float MasterVolume { get; set; } = 1f;
|
||||
public float SfxVolume { get; set; } = 1f;
|
||||
|
|
@ -456,25 +452,109 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
|
||||
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
|
||||
|
||||
public int StartAmbient(SoundId id, float x, float y, float z)
|
||||
/// <summary>
|
||||
/// Play a positional ambient one-shot — retail's
|
||||
/// <c>SoundManager::PlayAmbientSound</c> @ <c>0x00550820</c>. Identical to a
|
||||
/// world sound except that <c>GetAttenuation</c> is told this is ambient, so
|
||||
/// the AMBIENT volume knob is the master multiply rather than the effect one.
|
||||
/// </summary>
|
||||
public bool PlayAmbient3DWave(
|
||||
uint waveId,
|
||||
WaveData wave,
|
||||
Vector3 position,
|
||||
float volume,
|
||||
float priority)
|
||||
{
|
||||
// Looping ambient — needs a decoded wave + WaveId. The hook sink
|
||||
// doesn't route ambient; a separate landblock-attached ambient
|
||||
// system (outside R5) will drive this. For now: reserve a handle.
|
||||
int handle = _nextAmbientHandle++;
|
||||
return handle;
|
||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||
|
||||
RetailVoiceMix mix = RetailSoundMixer.Mix(
|
||||
_listenerPosition,
|
||||
_listenerHeadingDegrees,
|
||||
position,
|
||||
volume,
|
||||
AmbientMaster);
|
||||
if (!mix.Play) return false;
|
||||
|
||||
uint buffer = EnsureBuffer(waveId, wave);
|
||||
if (buffer == 0) return false;
|
||||
|
||||
int slotIdx = AcquireWorldSlot(priority);
|
||||
if (slotIdx < 0) return false;
|
||||
|
||||
Slot3D slot = _pool3D[slotIdx];
|
||||
_al.SourceStop(slot.SourceId);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(
|
||||
slot.SourceId,
|
||||
SourceFloat.Gain,
|
||||
RetailSoundMixer.LinearGain(mix.Decibels));
|
||||
ApplyPan(slot.SourceId, mix.Pan);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||
_al.SourcePlay(slot.SourceId);
|
||||
|
||||
slot.InUse = true;
|
||||
slot.OwnerId = 0;
|
||||
slot.Priority = priority;
|
||||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void StopAmbient(int handle)
|
||||
/// <summary>
|
||||
/// Play a non-positional ambient bed — retail's
|
||||
/// <c>SoundManager::PlayAmbientSoundFromCenter</c> @ <c>0x005508B0</c>. A
|
||||
/// continuous ambient has no position at all (its <c>GetSoundPos</c> is a
|
||||
/// folded <c>xor eax,eax</c>), so there is no pan and no distance
|
||||
/// attenuation: it is a stereo bed centred on the listener. Distance 0 takes
|
||||
/// the flat branch of the curve, scaled by the ambient knob.
|
||||
/// </summary>
|
||||
public bool PlayAmbientFromCenter(
|
||||
uint waveId,
|
||||
WaveData wave,
|
||||
float volume,
|
||||
float priority)
|
||||
{
|
||||
if (!_available || _al is null) return;
|
||||
if (_ambientSources.TryGetValue(handle, out var src))
|
||||
{
|
||||
_al.SourceStop(src);
|
||||
_ambientSources.Remove(handle);
|
||||
}
|
||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||
|
||||
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, AmbientMaster, out int decibels))
|
||||
return false;
|
||||
|
||||
uint buffer = EnsureBuffer(waveId, wave);
|
||||
if (buffer == 0) return false;
|
||||
|
||||
// The SAME 16-voice priority pool as everything else. Retail has exactly
|
||||
// one (`SoundManager::playing_sounds_[0x10]`), reached by
|
||||
// PlayAmbientSoundFromCenter @ 0x5508B0 -> PlaySoundInternal @ 0x54FEC0;
|
||||
// the UI pool is acdream's own. Parking beds there would let a portal
|
||||
// cue chop one mid-wave and would discard the authored priority.
|
||||
int slotIdx = AcquireWorldSlot(priority);
|
||||
if (slotIdx < 0) return false;
|
||||
|
||||
Slot3D slot = _pool3D[slotIdx];
|
||||
_al.SourceStop(slot.SourceId);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(
|
||||
slot.SourceId,
|
||||
SourceFloat.Gain,
|
||||
RetailSoundMixer.LinearGain(decibels));
|
||||
ApplyPan(slot.SourceId, 0); // dead centre: a bed has no bearing
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||
_al.SourcePlay(slot.SourceId);
|
||||
|
||||
slot.InUse = true;
|
||||
slot.OwnerId = 0;
|
||||
slot.Priority = priority;
|
||||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ambient master multiply, folded with acdream's extra master slider —
|
||||
/// the ambient counterpart of <see cref="EffectMaster"/>. See AP-174.
|
||||
/// </summary>
|
||||
private float AmbientMaster => MasterVolume * AmbientVolume;
|
||||
|
||||
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
|
||||
public void StopMusic() { /* ditto */ }
|
||||
|
||||
|
|
@ -567,10 +647,9 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
{
|
||||
if (IsSourceBoundTo(_poolUi[i], bufferId)) return true;
|
||||
}
|
||||
foreach (uint sourceId in _ambientSources.Values)
|
||||
{
|
||||
if (IsSourceBoundTo(sourceId, bufferId)) return true;
|
||||
}
|
||||
// Ambients share the world and UI pools scanned above; there is no
|
||||
// separate ambient source list any more (slice A5 deleted the
|
||||
// looping-handle model retail never had).
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue