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>
286 lines
12 KiB
C#
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();
|
|
}
|
|
}
|