fix(audio): portal cues fire on the sequencer's sound events, not the tunnel visuals

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 08:22:48 +02:00
parent aa82ff7bf1
commit 2914e43aa9
3 changed files with 73 additions and 10 deletions

View file

@ -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<string> _order;
@ -1493,6 +1522,9 @@ public sealed class LocalPlayerTeleportControllerTests
}
public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
public readonly List<string> 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);