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

@ -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&lt;float&gt;</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;
}
}

View file

@ -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;
}

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)
{

View file

@ -10,6 +10,7 @@ using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Settings;
using AcDream.App.Audio;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
@ -797,6 +798,44 @@ internal sealed class SessionPlayerCompositionPhase
new LiveProjectionRescueRebucketter(
live.WorldState,
live.LiveEntities));
// Campaign A slice A5: retail rebuilds the ambient soundscape on every
// objcell change (24 m) and drains its deadline queue from the frame
// tick. The terrain words come from the loaded landblocks in the 3x3
// ring; the listener is the local player's cell and position.
IAmbientFramePhase? BuildAmbientFrame()
{
if (content.Audio?.Ambient is not { } ambient)
return null;
DatReaderWriter.DBObjs.Region? region =
content.Dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
if (region is null)
return null;
ambient.InstallRegion(region, LoadTerrainWords);
return new AmbientFramePhase(
ambient,
new LocalPlayerAmbientListenerSource(d.PlayerController));
ushort[]? LoadTerrainWords(uint landblockId)
{
if (!live.WorldState.TryGetLandblock(landblockId, out LoadedLandblock? loaded)
|| loaded?.Heightmap is not { } heightmap)
{
return null;
}
// The dat exposes terrain words as TerrainInfo; the gatherer wants
// the raw words. Converted here rather than in Core so the ambient
// model stays free of the dat type. This runs only on an objcell
// change (every 24 m), nine landblocks at a time.
var words = new ushort[heightmap.Terrain.Length];
for (int i = 0; i < words.Length; i++)
words[i] = (ushort)heightmap.Terrain[i];
return words;
}
}
var liveEffectFrame = new LiveEffectFrameController(
d.TranslucencyFades,
content.AnimationHookFrames,
@ -807,7 +846,8 @@ internal sealed class SessionPlayerCompositionPhase
content.ParticleSystem,
content.ScriptRunner,
d.UpdateClock,
new SettingsParticleRangeSource(d.Settings));
new SettingsParticleRangeSource(d.Settings),
BuildAmbientFrame());
var liveSpatialReconciler = new LiveSpatialPresentationReconciler(
live.EntityEffects,
live.EquippedChildren,

View file

@ -1,3 +1,4 @@
using AcDream.App.Audio;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
@ -88,7 +89,8 @@ internal sealed class LiveEffectFrameController
ParticleSystem particles,
PhysicsScriptRunner scripts,
IPhysicsScriptTimeSource scriptTime,
IParticleRangeSource particleRange)
IParticleRangeSource particleRange,
IAmbientFramePhase? ambientFrame = null)
{
_translucencyFades = translucencyFades
?? throw new ArgumentNullException(nameof(translucencyFades));
@ -102,8 +104,19 @@ internal sealed class LiveEffectFrameController
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
_scriptTime = scriptTime ?? throw new ArgumentNullException(nameof(scriptTime));
_particleRange = particleRange ?? throw new ArgumentNullException(nameof(particleRange));
_ambientFrame = ambientFrame;
}
/// <summary>
/// Campaign A slice A5: retail drains the ambient deadline queue from the
/// same per-frame tick that advances the rest of the effect system
/// (<c>SmartBox::UseTime</c> -> <c>Ambient::UseTime</c>). A typed
/// collaborator rather than a callback — extracted update owners must not
/// retain delegates (`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks`).
/// Null when audio is unavailable.
/// </summary>
private readonly IAmbientFramePhase? _ambientFrame;
public void Tick(float deltaSeconds)
{
// Retail's ordinary-object UpdatePositionInternal @ 0x00512C30 calls
@ -123,6 +136,7 @@ internal sealed class LiveEffectFrameController
// once-per-host tail and its static-order difference remain TS-51.
_particles.Tick(deltaSeconds);
_scripts.Tick(_scriptTime.CurrentScriptTime);
_ambientFrame?.TickAmbient(deltaSeconds);
}
}