acdream/src/AcDream.App/Update/LiveObjectFrameController.cs
Erik 7c4dd1ade7 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>
2026-08-08 22:53:41 +02:00

286 lines
12 KiB
C#

using AcDream.App.Audio;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Update;
internal interface ILiveObjectFramePhase
{
void Tick(float deltaSeconds);
}
internal interface IPostNetworkCommandFramePhase
{
void RunPostNetworkCommandPhase();
}
internal interface ILiveSpatialReconcilePhase
{
void Reconcile();
}
/// <summary>
/// Keeps the derived render projection synchronized after inbound mutation
/// while normal world simulation/presentation is blocked by portal reveal.
/// This is deliberately narrower than full spatial reconciliation: hidden
/// frames must not advance particles, effects, attachments, or lights.
/// </summary>
internal interface IRenderProjectionSyncPhase
{
void SynchronizeActiveSources();
}
internal interface IParticleRangeSource
{
float RangeMultiplier { get; }
}
internal sealed class SettingsParticleRangeSource : IParticleRangeSource
{
private readonly IRuntimeSettingsPreviewSource _settings;
public SettingsParticleRangeSource(IRuntimeSettingsPreviewSource settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public float RangeMultiplier =>
_settings.DisplayPreview.ParticleRange == ParticleRange.Extended
? ParticleVisibilityController.ExtendedRangeMultiplier
: 1f;
}
/// <summary>
/// Owns the shared effect-manager tail that follows ordinary and static
/// object worksets in acdream's registered TS-50/TS-51 adaptation.
/// </summary>
internal sealed class LiveEffectFrameController
{
private readonly TranslucencyFadeManager _translucencyFades;
private readonly AnimationHookFrameQueue _animationHooks;
private readonly EntityEffectController _entityEffects;
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ParticleVisibilityController _particleVisibility;
private readonly ParticleSystem _particles;
private readonly PhysicsScriptRunner _scripts;
private readonly IPhysicsScriptTimeSource _scriptTime;
private readonly IParticleRangeSource _particleRange;
public LiveEffectFrameController(
TranslucencyFadeManager translucencyFades,
AnimationHookFrameQueue animationHooks,
EntityEffectController entityEffects,
ParticleHookSink particleSink,
LiveEntityLightController lights,
ParticleVisibilityController particleVisibility,
ParticleSystem particles,
PhysicsScriptRunner scripts,
IPhysicsScriptTimeSource scriptTime,
IParticleRangeSource particleRange,
IAmbientFramePhase? ambientFrame = null)
{
_translucencyFades = translucencyFades
?? throw new ArgumentNullException(nameof(translucencyFades));
_animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks));
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_particleVisibility = particleVisibility
?? throw new ArgumentNullException(nameof(particleVisibility));
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_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
// CPhysicsObj::process_hooks @ 0x00511550 before its manager tail.
// acdream currently keeps non-AnimationDone hooks at this deferred
// shared boundary under TS-50; this extraction must not silently
// claim that adaptation is gone.
_translucencyFades.AdvanceAll(deltaSeconds);
_animationHooks.Drain();
_entityEffects.RefreshLiveOwnerPoses();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
_particleVisibility.Apply(_particles, _particleRange.RangeMultiplier);
// Retail CPhysicsObj::UpdateObjectInternal @ 0x005156B0 advances the
// ordinary-object Particle manager before Script. acdream's shared
// once-per-host tail and its static-order difference remain TS-51.
_particles.Tick(deltaSeconds);
_scripts.Tick(_scriptTime.CurrentScriptTime);
_ambientFrame?.TickAmbient(deltaSeconds);
}
}
/// <summary>
/// Owns the complete pre-network local/ordinary/static live-object phase.
/// </summary>
internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
{
private readonly RetailInboundEventDispatcher _inboundEvents;
private readonly RetailLocalPlayerFrameController _localPlayerFrame;
private readonly SelectionInteractionController? _selectionInteractions;
private readonly LiveEntityRuntime _liveEntities;
private readonly ILocalPlayerIdentitySource _localPlayer;
private readonly LiveWorldOriginState _origin;
private readonly LiveEntityAnimationScheduler _animations;
private readonly RetailStaticAnimatingObjectScheduler _staticAnimations;
private readonly LiveEntityAnimationPresenter _animationPresenter;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState>
_animatedEntities;
private readonly EquippedChildRenderController _equippedChildren;
private readonly LiveEffectFrameController _effects;
private readonly ILiveRenderProjectionSink? _renderProjections;
private readonly StaticRenderProjectionJournal? _staticRenderProjections;
private readonly List<WorldEntity> _activeStaticProjectionScratch = [];
public LiveObjectFrameController(
RetailInboundEventDispatcher inboundEvents,
RetailLocalPlayerFrameController localPlayerFrame,
SelectionInteractionController? selectionInteractions,
LiveEntityRuntime liveEntities,
ILocalPlayerIdentitySource localPlayer,
LiveWorldOriginState origin,
LiveEntityAnimationScheduler animations,
RetailStaticAnimatingObjectScheduler staticAnimations,
LiveEntityAnimationPresenter animationPresenter,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities,
EquippedChildRenderController equippedChildren,
LiveEffectFrameController effects,
ILiveRenderProjectionSink? renderProjections = null,
StaticRenderProjectionJournal? staticRenderProjections = null)
{
_inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents));
_localPlayerFrame = localPlayerFrame
?? throw new ArgumentNullException(nameof(localPlayerFrame));
_selectionInteractions = selectionInteractions;
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_staticAnimations = staticAnimations
?? throw new ArgumentNullException(nameof(staticAnimations));
_animationPresenter = animationPresenter
?? throw new ArgumentNullException(nameof(animationPresenter));
_animatedEntities = animatedEntities
?? throw new ArgumentNullException(nameof(animatedEntities));
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_effects = effects ?? throw new ArgumentNullException(nameof(effects));
_renderProjections = renderProjections;
_staticRenderProjections = staticRenderProjections;
}
public void Tick(float deltaSeconds) =>
_inboundEvents.Run(
this,
deltaSeconds,
static (controller, elapsed) => controller.TickCore(elapsed));
private void TickCore(float deltaSeconds)
{
_localPlayerFrame.AdvanceBeforeNetwork(deltaSeconds);
_selectionInteractions?.DrainOutbound();
Vector3? playerPosition =
_liveEntities.TryGetWorldEntity(
_localPlayer.ServerGuid,
out WorldEntity? player)
? player.Position
: null;
IReadOnlyDictionary<RuntimeEntityKey, LiveEntityAnimationSchedule> schedules =
_animations.Tick(
deltaSeconds,
playerPosition,
_localPlayerFrame.HiddenPartPoseDirty,
_origin.CenterX,
_origin.CenterY,
_animationPresenter.PrepareAnimation);
// CPhysics::UseTime @ 0x00509950 walks ordinary objects before the
// distinct static_animating_objects workset.
_staticAnimations.Tick(deltaSeconds);
if (_animatedEntities.Count > 0)
_animationPresenter.Present(schedules);
_equippedChildren.Tick();
_renderProjections?.SynchronizeActiveSources();
if (_staticRenderProjections is not null)
{
_staticAnimations.CopyActiveDatStaticEntitiesTo(
_activeStaticProjectionScratch);
_staticRenderProjections.SynchronizeActiveAnimatedSources(
_activeStaticProjectionScratch);
}
// Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs
// Script -> Particle -> process_hooks. acdream intentionally retains
// the TS-51 static-order difference here: ProcessHooks precedes the
// shared once-per-host Particle -> Script tail below.
_staticAnimations.ProcessHooks();
_effects.Tick(deltaSeconds);
}
}
/// <summary>
/// Re-composes spatial derivatives after authoritative root mutations without
/// advancing animation, physics, effects, particles, scripts, or fades.
/// </summary>
internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcilePhase
{
private readonly EntityEffectController _entityEffects;
private readonly EquippedChildRenderController _equippedChildren;
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ILiveRenderProjectionSink? _renderProjections;
public LiveSpatialPresentationReconciler(
EntityEffectController entityEffects,
EquippedChildRenderController equippedChildren,
ParticleHookSink particleSink,
LiveEntityLightController lights,
ILiveRenderProjectionSink? renderProjections = null)
{
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_renderProjections = renderProjections;
}
public void Reconcile()
{
_entityEffects.RefreshLiveOwnerPoses();
_equippedChildren.ReconcileSpatialMutations();
_renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
}
}