diff --git a/docs/ISSUES.md b/docs/ISSUES.md index de358652..0fb401bf 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,32 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff + +**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same +commit as this entry). **Symptom:** log out to character select — the old +world's ambient noise continues. **Root cause:** the character-session +reset manifest had NO audio step at all. Retail's logoff destroys the +world's sound sources WITH the world, so character select is silent; our +OpenAL world pool and the ambient scheduler are process-lifetime, so after +the reset the playing voices (including the continuous ambient beds) ran +on, and `AmbientSoundController.Tick` kept RE-FIRING deadlines against the +stale listener (the region stays installed and nothing suspends the +controller — `Suspend`/`StopAll` had zero callers outside the class; the +existing `WorldGenerationQuiescence` suspend only cycles around generation +REPLACES, i.e. teleports, never the logout reset). **Fix:** a new +`WorldAudioSessionGate` (engine `SuspendWorldAudio` — stops all sixteen +world-pool voices and gates new plays — plus ambient `StopAll` — drops +every deadline; the soundscape rebuilds on the next objcell observation +exactly as a cell change always did) wired as the reset manifest's new +"world audio" step, with the pool reopened at the entered-world edge via +the new `LiveSessionEnteredWorldBindings.ResumeWorldAudio` binding +(default-null, headless-safe), invoked FIRST in `ApplyEnteredWorld` so no +entered-world callback can emit into a closed pool. Covers logout, +reconnect, and full-stop uniformly (all run the same manifest). UI-pool +sounds (interface bank, portal cues) are untouched by design — retail's +logoff plays its cue through the same interface path. + ## #416 — Character-select roster hover highlight never clears (sweeping the roster leaves every row highlighted) **Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same diff --git a/src/AcDream.App/Audio/WorldAudioSessionGate.cs b/src/AcDream.App/Audio/WorldAudioSessionGate.cs new file mode 100644 index 00000000..3044f5cb --- /dev/null +++ b/src/AcDream.App/Audio/WorldAudioSessionGate.cs @@ -0,0 +1,39 @@ +namespace AcDream.App.Audio; + +/// +/// Logout-audio round (2026-08-17): the character-session reset's world-audio +/// teardown. Retail's logoff destroys every world sound source WITH the world +/// (CPlayerSystem::ExecuteLogOff @ 0x0055D780 world teardown — the +/// DirectSound buffers die with their owners), so the character-select screen +/// is silent. acdream's OpenAL voices and the ambient scheduler are +/// process-lifetime, so the session reset must stop them explicitly: +/// stops all sixteen world-pool voices +/// (the continuous ambient beds included) and drops every ambient deadline so +/// nothing re-fires against the stale listener at character select. The pool +/// re-opens at the next entered-world edge +/// (LiveSessionEnteredWorldBindings.ResumeWorldAudio); the ambient +/// soundscape needs no explicit resume — the next +/// AmbientSoundController.ObserveListener objcell change rebuilds it, +/// exactly as a cell change always does. +/// +public sealed class WorldAudioSessionGate +{ + private readonly OpenAlAudioEngine _engine; + private readonly AmbientSoundController? _ambient; + + public WorldAudioSessionGate( + OpenAlAudioEngine engine, + AmbientSoundController? ambient) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _ambient = ambient; + } + + public void SuspendForSessionReset() + { + _engine.SuspendWorldAudio(); + _ambient?.StopAll(); + } + + public void ResumeForWorldEntry() => _engine.ResumeWorldAudio(); +} diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 48cc2cfe..1baac0b7 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1118,6 +1118,11 @@ internal sealed class SessionPlayerCompositionPhase live.SelectionInteractions), new LiveSessionWorldRuntime( content.Dats, + content.Audio?.Engine is { } sessionAudioEngine + ? new AcDream.App.Audio.WorldAudioSessionGate( + sessionAudioEngine, + content.Audio.Ambient) + : null, live.WorldState, live.LiveEntities, sessionEvents, diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 2540d1c3..c9aafa9a 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -14,6 +14,7 @@ internal sealed class LiveSessionResetBindings public required Action MouseCapture { get; init; } public required Action PlayerPresentation { get; init; } public required Action TeleportPresentation { get; init; } + public required Action WorldAudio { get; init; } public required Action SessionDialogs { get; init; } public required Action SettingsCharacterContext { get; init; } public required Action EquippedChildren { get; init; } @@ -48,6 +49,14 @@ internal static class LiveSessionResetManifest new("mouse capture", bindings.MouseCapture), new("player presentation", bindings.PlayerPresentation), new("teleport presentation", bindings.TeleportPresentation), + // Logout-audio round (2026-08-17): retail's logoff destroys the + // world's sound sources with the world; ours must stop the + // sixteen world-pool voices (continuous ambient beds included) + // and drop the ambient deadlines, or the character-select screen + // keeps playing — and re-firing — the old world's ambience. The + // pool reopens at the next entered-world edge + // (LiveSessionEnteredWorldBindings.ResumeWorldAudio). + new("world audio", bindings.WorldAudio), new("session dialogs", bindings.SessionDialogs), new("settings character context", bindings.SettingsCharacterContext), // Attachment projections own GL-backed registrations and must leave diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index a743c41d..f5af448e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -67,6 +67,10 @@ internal sealed record LiveSessionInteractionRuntime( internal sealed record LiveSessionWorldRuntime( IDatReaderWriter Dats, + // Logout-audio round (2026-08-17): null only when audio is disabled + // (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world + // resume both no-op then. + Audio.WorldAudioSessionGate? WorldAudio, GpuWorldState WorldState, LiveEntityRuntime LiveEntities, LiveEntitySessionController EntitySession, @@ -210,7 +214,11 @@ internal sealed class LiveSessionRuntimeFactory }, SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(), LoadCharacterSettings: _interaction.Settings.LoadCharacterContext, - ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm), + ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm, + // Logout-audio round (2026-08-17): reopen the world-audio + // pool the session reset closed (see the reset manifest's + // "world audio" step). + ResumeWorldAudio: () => _world.WorldAudio?.ResumeForWorldEntry()), Connecting: (host, port, user) => _domain.Communication.Chat.OnSystemMessage( $"connecting to {host}:{port} as {user}", @@ -251,6 +259,7 @@ internal sealed class LiveSessionRuntimeFactory PlayerPresentation = ResetPlayerPresentation, TeleportPresentation = _world.Teleport.ResetGenerationPresentation, + WorldAudio = () => _world.WorldAudio?.SuspendForSessionReset(), SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(), SettingsCharacterContext = _interaction.Settings.RestoreDefaultCharacterContext, diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 66194e77..72e52bd2 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -30,7 +30,13 @@ public sealed record LiveSessionEnteredWorldBindings( Action RestoreLayout, Action SyncToolbar, Action LoadCharacterSettings, - Action ArmPlayerModeAutoEntry); + Action ArmPlayerModeAutoEntry, + /// Logout-audio round (2026-08-17): re-enables world-pool audio + /// after the session reset's suspend (retail's logoff tears the world's + /// sound sources down with the world; ours must re-open the pool at the + /// next world entry). Default no-op preserves headless and existing + /// construction sites. + Action? ResumeWorldAudio = null); public sealed record LiveSessionHostBindings( LiveSessionRoutingFactories Routing, @@ -269,6 +275,11 @@ public sealed class LiveSessionHost private void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { string name = selection.CharacterName; + // FIRST: the world-audio pool must be open before any entered-world + // callback can emit a sound (retail never closes its pool across a + // logoff — the sources die with the world — so the reopened pool is + // the earliest faithful moment). + _enteredWorld.ResumeWorldAudio?.Invoke(); _enteredWorld.SetActiveCharacter(name); _enteredWorld.RestoreLayout(); _enteredWorld.SyncToolbar(); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index b9235a49..478933dd 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -210,6 +210,7 @@ public sealed class LiveSessionResetPlanTests MouseCapture = Stage("mouse capture"), PlayerPresentation = Stage("player presentation"), TeleportPresentation = Stage("teleport presentation"), + WorldAudio = Stage("world audio"), SessionDialogs = Stage("session dialogs"), SettingsCharacterContext = Stage("settings character context"), EquippedChildren = Stage("equipped children"), @@ -295,6 +296,7 @@ public sealed class LiveSessionResetPlanTests "mouse capture", "player presentation", "teleport presentation", + "world audio", "session dialogs", "settings character context", "equipped children", diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 24e6948c..45fa4043 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -1019,6 +1019,7 @@ public sealed class CurrentGameRuntimeAdapterTests MouseCapture = noop, PlayerPresentation = noop, TeleportPresentation = noop, + WorldAudio = noop, SessionDialogs = noop, SettingsCharacterContext = noop, EquippedChildren = noop,