From 2914e43aa9a1750181143faaf4aaa7b7716b08cb Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 08:22:48 +0200 Subject: [PATCH] fix(audio): portal cues fire on the sequencer's sound events, not the tunnel visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice A4 hung UI_EnterPortal on TeleportAnimEvent.EnterTunnel — the first tunnel-family frame — so the cue landed a whole TunnelFadeIn after retail plays it. Retail's site is gmSmartBoxUI::BeginTeleportAnimation @0x004D638E, i.e. Begin(), which the sequencer already marks as TeleportAnimEvent.PlayEnterSound. That event has existed since the R6 portal-space work, complete with a 'Begin(): sound_ui_enter_portal' comment, and no consumer has ever handled it — the switch in LocalPlayerTeleportController had cases for Place, EnterTunnel, PlayExitSound and FireLoginComplete only, so the sequencer emitted PlayEnterSound into nothing. A4 filled the gap in the wrong place rather than filling it. Both cues now go through named presentation methods driven by the matching events: PlayEnterCue on PlayEnterSound, PlayExitCue on PlayExitSound (the TunnelFadeOut -> WorldFadeIn edge, @0x004D7405, the same tick the world viewport is revealed). EnterTunnel/ExitTunnel are visuals again. The exit cue was already firing at the right moment, since ExitTunnel was called from inside the PlayExitSound case — correct by accident, explicit now. Found by the user asking when the recall cues play. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-08-audio-parity-campaign.md | 9 ++++ .../LocalPlayerTeleportController.cs | 42 ++++++++++++++----- .../LocalPlayerTeleportControllerTests.cs | 32 ++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) 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);