diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md index 9c039d34..b290b5ec 100644 --- a/docs/plans/2026-08-08-audio-parity-campaign.md +++ b/docs/plans/2026-08-08-audio-parity-campaign.md @@ -351,6 +351,15 @@ to ~215 conformance tests written against byte-decoded values. `ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an `Action` frame hook and forced the typed `IAmbientFramePhase`. +**A4 correction (2026-08-08, from a user question):** the enter cue was hung on the +`EnterTunnel` event — the first tunnel-family frame — instead of the sequencer's +`PlayEnterSound`, which is `Begin()` and is what retail's +`BeginTeleportAnimation` @ `0x004D638E` plays. That delayed it by a whole +TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events +(`PlayEnterSound` had been emitted and dropped by every consumer since R6), and +`PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals` pins the +moments. The exit cue was already correct. + **Still owed:** the user listening gate (A2 falloff, A4 cues, A5 ambients) and the connected gates for A3/A4. Open rows: AP-173, AP-174, TS-64, TS-65, TS-66, TS-67, TS-9 (re-scoped), #321. diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 0dc47301..ec960380 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -309,6 +309,8 @@ internal interface ILocalPlayerTeleportPresentation : IDisposable (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) Tick(float deltaSeconds, bool worldReady); void TickTunnel(float deltaSeconds); + void PlayEnterCue(); + void PlayExitCue(); void EnterTunnel(); void ExitTunnel(); void SetWaitCue(bool visible); @@ -360,17 +362,26 @@ internal sealed class LocalPlayerTeleportPresentation /// public Action? UiSoundSink { get; set; } - public void EnterTunnel() - { - _tunnel.Enter(); - UiSoundSink?.Invoke(SoundId.UI_EnterPortal); - } + /// + /// Sound_UI_EnterPortal, at retail's moment: the START of the + /// teleport animation (gmSmartBoxUI::BeginTeleportAnimation @ + /// 0x004D638E), which the sequencer marks as + /// TeleportAnimEvent.PlayEnterSound — NOT when the tunnel viewport + /// first appears, which is a TunnelFadeIn later. + /// + public void PlayEnterCue() => UiSoundSink?.Invoke(SoundId.UI_EnterPortal); - public void ExitTunnel() - { - _tunnel.Exit(); - UiSoundSink?.Invoke(SoundId.UI_ExitPortal); - } + /// + /// Sound_UI_ExitPortal @ 0x004D7405, at the + /// TunnelFadeOut to WorldFadeIn edge — the sequencer's + /// TeleportAnimEvent.PlayExitSound, the same tick the world viewport + /// is revealed. + /// + public void PlayExitCue() => UiSoundSink?.Invoke(SoundId.UI_ExitPortal); + + public void EnterTunnel() => _tunnel.Enter(); + + public void ExitTunnel() => _tunnel.Exit(); public void SetWaitCue(bool visible) => _tunnel.SetWaitCue(visible); public void Reset() @@ -601,6 +612,14 @@ internal sealed class LocalPlayerTeleportController if (!IsCurrentLifetime(generation, sequence)) return; break; + case TeleportAnimEvent.PlayEnterSound: + // Retail plays the enter cue as the animation BEGINS, before + // the tunnel is visible. The sequencer has always emitted + // this event; nothing consumed it until Campaign A. + _presentation.PlayEnterCue(); + if (!IsCurrentLifetime(generation, sequence)) + return; + break; case TeleportAnimEvent.EnterTunnel: _presentation.EnterTunnel(); if (!IsCurrentLifetime(generation, sequence)) @@ -611,6 +630,9 @@ internal sealed class LocalPlayerTeleportController // cell blocking at the exact portal/world viewport swap. // LoginComplete remains one WorldFadeIn second later. _worldReveal.RevealWorldViewport(); + if (!IsCurrentLifetime(generation, sequence)) + return; + _presentation.PlayExitCue(); if (!IsCurrentLifetime(generation, sequence)) return; _presentation.ExitTunnel(); diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 48f4657b..b1f9ae53 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -1453,6 +1453,35 @@ public sealed class LocalPlayerTeleportControllerTests } } + [Fact] + public void PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals() + { + // Retail plays Sound_UI_EnterPortal as the animation BEGINS + // (gmSmartBoxUI::BeginTeleportAnimation @ 0x004D638E) and + // Sound_UI_ExitPortal at the TunnelFadeOut -> WorldFadeIn edge + // (@ 0x004D7405). The sequencer marks both with dedicated events. Hanging + // the enter cue on EnterTunnel instead — the first tunnel-family frame — + // delays it by a whole TunnelFadeIn, which is what Campaign A slice A4 + // originally did. + var harness = new Harness(); + harness.Controller.OnTeleportStarted(21); + + harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + + // The tunnel becoming visible must NOT emit a second cue. + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter", "exit"], harness.Presentation.Cues); + Assert.False(harness.Presentation.IsPortalViewportVisible); + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order; @@ -1493,6 +1522,9 @@ public sealed class LocalPlayerTeleportControllerTests } public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick"); + public readonly List Cues = new(); + public void PlayEnterCue() => Cues.Add("enter"); + public void PlayExitCue() => Cues.Add("exit"); public void EnterTunnel() => IsPortalViewportVisible = true; public void ExitTunnel() => IsPortalViewportVisible = false; public void SetWaitCue(bool visible) => WaitCueValues.Add(visible);